package agentworkspace import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "reflect" "sort" "strings" "iop/packages/go/agentguard" "iop/packages/go/agenttask" ) const changeSetSchemaVersion uint32 = 1 // ChangeOperationKind identifies the transition from the pinned base entry to // the reviewed task entry. type ChangeOperationKind string const ( ChangeOperationAdd ChangeOperationKind = "add" ChangeOperationModify ChangeOperationKind = "modify" ChangeOperationDelete ChangeOperationKind = "delete" ) // ChangeEntry is the immutable filesystem identity for one side of an // operation. Git state is deliberately excluded: integration operates on // content, type, mode, and symlink identity while the workspace fingerprint // separately covers the complete Git state. type ChangeEntry struct { Kind SnapshotEntryKind `json:"kind"` Mode uint32 `json:"mode"` ContentDigest string `json:"content_digest,omitempty"` SymlinkTarget string `json:"symlink_target,omitempty"` } // ChangeOperation records one normalized workspace-relative transition. // ContentFile is present only for a regular result and is relative to the // immutable change-set root. type ChangeOperation struct { Kind ChangeOperationKind `json:"kind"` Path string `json:"path"` Base *ChangeEntry `json:"base,omitempty"` Result *ChangeEntry `json:"result,omitempty"` ContentFile string `json:"content_file,omitempty"` } // ValidationEvidence binds review acceptance evidence to the frozen content. // Digest is content-addressed evidence supplied by the project workflow // adapter; raw command output does not enter the host record. type ValidationEvidence struct { Name string `json:"name"` Result string `json:"result"` Digest string `json:"digest"` } // ChangeSetLocator contains only device-local immutable record paths. type ChangeSetLocator struct { OverlayRecord string `json:"overlay_record"` Root string `json:"root"` Record string `json:"record"` ContentRoot string `json:"content_root"` } // ChangeSet is the content-addressed immutable output of a review-PASS task // overlay. Revision covers every field except the derived locator. type ChangeSet struct { SchemaVersion uint32 `json:"schema_version"` ID agenttask.ChangeSetID `json:"id"` Revision string `json:"revision"` ArtifactID agenttask.ArtifactID `json:"artifact_id"` ProjectID agenttask.ProjectID `json:"project_id"` WorkspaceID agenttask.WorkspaceID `json:"workspace_id"` WorkUnitID agenttask.WorkUnitID `json:"work_unit_id"` AttemptID agenttask.AttemptID `json:"attempt_id"` OverlayRevision string `json:"overlay_revision"` CanonicalRoot string `json:"canonical_root"` BaseFingerprint string `json:"base_fingerprint"` ConfigRevision string `json:"config_revision"` GrantRevision string `json:"grant_revision"` Operations []ChangeOperation `json:"operations"` WriteSet []string `json:"write_set"` ValidationEvidence []ValidationEvidence `json:"validation_evidence"` Locator ChangeSetLocator `json:"locator"` } // Identity returns the shared-runtime identity used by ReviewResult and the // Integrator port. func (changeSet ChangeSet) Identity() agenttask.ChangeSetIdentity { return agenttask.ChangeSetIdentity{ ID: changeSet.ID, Revision: changeSet.Revision, ArtifactID: changeSet.ArtifactID, } } // FreezeRequest identifies one reviewed overlay and its evidence. type FreezeRequest struct { Descriptor agentguard.IsolationDescriptor ArtifactID agenttask.ArtifactID ValidationEvidence []ValidationEvidence } // Freeze compares the reviewed task view with its exact pinned snapshot, // copies result content into an immutable record, and installs it // idempotently under the retained overlay. func (backend *Backend) Freeze( ctx context.Context, request FreezeRequest, ) (ChangeSet, error) { if err := ctx.Err(); err != nil { return ChangeSet{}, err } if request.ArtifactID == "" { return ChangeSet{}, errors.New("agentworkspace: artifact identity is required") } evidence, err := normalizeValidationEvidence(request.ValidationEvidence) if err != nil { return ChangeSet{}, err } record, err := backend.LoadRecord(request.Descriptor) if err != nil { return ChangeSet{}, err } snapshot, err := readWorkspaceSnapshot(record.Locator.SnapshotRecord) if err != nil { return ChangeSet{}, fmt.Errorf("agentworkspace: load frozen base: %w", err) } baseEntries := changeEntriesFromSnapshot(snapshot) resultEntries, err := scanChangeEntries(ctx, record.Locator.ViewRoot) if err != nil { return ChangeSet{}, fmt.Errorf("agentworkspace: scan reviewed task view: %w", err) } operations := buildChangeOperations(baseEntries, resultEntries) if len(operations) == 0 { return ChangeSet{}, errors.New("agentworkspace: reviewed overlay has no file operations") } taskRoot := filepath.Dir(record.Locator.OverlayRecord) changeSetsRoot := filepath.Join(taskRoot, "change-sets") if err := os.MkdirAll(changeSetsRoot, 0o700); err != nil { return ChangeSet{}, fmt.Errorf("agentworkspace: create change-set root: %w", err) } staging, err := os.MkdirTemp(changeSetsRoot, ".freezing-") if err != nil { return ChangeSet{}, fmt.Errorf("agentworkspace: create change-set staging root: %w", err) } cleanup := true defer func() { if cleanup { _ = os.RemoveAll(staging) } }() for index := range operations { operation := &operations[index] if operation.Result == nil || operation.Result.Kind != SnapshotEntryRegular { continue } operation.ContentFile = filepath.ToSlash(filepath.Join("content", operation.Path)) source := filepath.Join(record.Locator.ViewRoot, filepath.FromSlash(operation.Path)) destination := filepath.Join(staging, filepath.FromSlash(operation.ContentFile)) digest, copyErr := digestAndCopyRegular(source, destination) if copyErr != nil { return ChangeSet{}, fmt.Errorf( "agentworkspace: freeze content %q: %w", operation.Path, copyErr, ) } if digest != operation.Result.ContentDigest { return ChangeSet{}, fmt.Errorf( "agentworkspace: task content %q changed while freezing", operation.Path, ) } if err := os.Chmod(destination, 0o400); err != nil { return ChangeSet{}, err } } writeSet := make([]string, len(operations)) for index, operation := range operations { writeSet[index] = operation.Path } changeSet := ChangeSet{ SchemaVersion: changeSetSchemaVersion, ArtifactID: request.ArtifactID, ProjectID: agenttask.ProjectID(record.ProjectID), WorkspaceID: agenttask.WorkspaceID(record.WorkspaceID), WorkUnitID: agenttask.WorkUnitID(record.WorkUnitID), AttemptID: agenttask.AttemptID(record.AttemptID), OverlayRevision: record.Revision, CanonicalRoot: record.CanonicalRoot, BaseFingerprint: snapshot.Revision, ConfigRevision: record.ConfigRevision, GrantRevision: record.GrantRevision, Operations: operations, WriteSet: writeSet, ValidationEvidence: evidence, } changeSet.Revision = changeSetRevision(changeSet) changeSet.ID = agenttask.ChangeSetID( "change-" + strings.TrimPrefix(changeSet.Revision, "sha256:"), ) finalRoot := filepath.Join(changeSetsRoot, string(changeSet.ID)) changeSet.Locator = ChangeSetLocator{ OverlayRecord: record.Locator.OverlayRecord, Root: finalRoot, Record: filepath.Join(finalRoot, "change-set.json"), ContentRoot: filepath.Join(finalRoot, "content"), } if err := writeJSONFile( filepath.Join(staging, "change-set.json"), changeSet, 0o400, ); err != nil { return ChangeSet{}, err } if err := os.Rename(staging, finalRoot); err != nil { if !os.IsExist(err) { if _, statErr := os.Stat(finalRoot); statErr != nil { return ChangeSet{}, fmt.Errorf( "agentworkspace: install immutable change set: %w", err, ) } } existing, loadErr := readChangeSet(filepath.Join(finalRoot, "change-set.json")) if loadErr != nil { return ChangeSet{}, loadErr } if !reflect.DeepEqual(existing, changeSet) { return ChangeSet{}, errors.New( "agentworkspace: retained change-set identity collision", ) } return existing, nil } cleanup = false return changeSet, nil } // LoadChangeSet resolves an immutable record through the exact retained // isolation and shared-runtime change-set identity. func (backend *Backend) LoadChangeSet( isolation agenttask.IsolationIdentity, identity agenttask.ChangeSetIdentity, ) (ChangeSet, error) { taskName := strings.TrimPrefix(isolation.ID, "overlay:") if taskName == "" || strings.ContainsAny(taskName, `/\`) { return ChangeSet{}, errors.New("agentworkspace: invalid overlay identity") } if identity.ID == "" || identity.Revision == "" || identity.ArtifactID == "" { return ChangeSet{}, errors.New("agentworkspace: incomplete change-set identity") } if !validChangeSetIdentity(identity) { return ChangeSet{}, errors.New("agentworkspace: invalid change-set identity") } recordPath := filepath.Join( backend.tasks, taskName, "change-sets", string(identity.ID), "change-set.json", ) changeSet, err := readChangeSet(recordPath) if err != nil { return ChangeSet{}, err } overlayPath := filepath.Join(backend.tasks, taskName, "overlay.json") overlay, err := readOverlayRecord(overlayPath) if err != nil { return ChangeSet{}, err } if changeSet.Identity() != identity || changeSet.OverlayRevision != isolation.Revision || changeSet.BaseFingerprint != isolation.PinnedBaseRevision || changeSet.CanonicalRoot != overlay.CanonicalRoot || changeSet.Locator.OverlayRecord != overlayPath || overlay.Revision != isolation.Revision || overlay.SnapshotRevision != isolation.PinnedBaseRevision { return ChangeSet{}, errors.New("agentworkspace: change-set identity mismatch") } return changeSet, nil } func normalizeValidationEvidence( input []ValidationEvidence, ) ([]ValidationEvidence, error) { if len(input) == 0 { return nil, errors.New("agentworkspace: validation evidence is required") } result := append([]ValidationEvidence(nil), input...) sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name }) for index, evidence := range result { if strings.TrimSpace(evidence.Name) == "" || strings.TrimSpace(evidence.Result) == "" || strings.TrimSpace(evidence.Digest) == "" { return nil, errors.New("agentworkspace: validation evidence is incomplete") } if index > 0 && result[index-1].Name == evidence.Name { return nil, fmt.Errorf( "agentworkspace: duplicate validation evidence %q", evidence.Name, ) } } return result, nil } func changeEntriesFromSnapshot( snapshot WorkspaceSnapshot, ) map[string]ChangeEntry { entries := make(map[string]ChangeEntry, len(snapshot.Entries)) for _, entry := range snapshot.Entries { if entry.Kind == SnapshotEntryMissing { continue } entries[entry.Path] = ChangeEntry{ Kind: entry.Kind, Mode: entry.Mode, ContentDigest: entry.ContentDigest, SymlinkTarget: entry.SymlinkTarget, } } return entries } func scanChangeEntries( ctx context.Context, root string, ) (map[string]ChangeEntry, error) { entries := make(map[string]ChangeEntry) err := filepath.WalkDir(root, func( path string, entry fs.DirEntry, walkErr error, ) error { if walkErr != nil { return walkErr } if err := ctx.Err(); err != nil { return err } if path == root { return nil } relative, err := filepath.Rel(root, path) if err != nil { return err } if relative == ".git" { if entry.IsDir() { return filepath.SkipDir } return nil } if strings.HasPrefix(relative, ".git"+string(filepath.Separator)) { return nil } slashPath := filepath.ToSlash(relative) info, err := os.Lstat(path) if err != nil { return err } changeEntry := ChangeEntry{Mode: uint32(info.Mode())} switch { case info.IsDir(): changeEntry.Kind = SnapshotEntryDirectory case info.Mode().IsRegular(): changeEntry.Kind = SnapshotEntryRegular digest, err := digestAndCopyRegular(path, "") if err != nil { return err } changeEntry.ContentDigest = digest case info.Mode()&os.ModeSymlink != 0: changeEntry.Kind = SnapshotEntrySymlink target, err := os.Readlink(path) if err != nil { return err } if err := validateSnapshotSymlink(root, path, target); err != nil { return err } changeEntry.SymlinkTarget = target changeEntry.ContentDigest = digestText(target) default: return fmt.Errorf( "agentworkspace: unsupported task object %q with mode %s", slashPath, info.Mode(), ) } entries[slashPath] = changeEntry return nil }) return entries, err } func buildChangeOperations( base map[string]ChangeEntry, result map[string]ChangeEntry, ) []ChangeOperation { paths := make(map[string]struct{}, len(base)+len(result)) for path := range base { paths[path] = struct{}{} } for path := range result { paths[path] = struct{}{} } ordered := make([]string, 0, len(paths)) for path := range paths { ordered = append(ordered, path) } sort.Strings(ordered) operations := make([]ChangeOperation, 0) for _, path := range ordered { baseEntry, baseExists := base[path] resultEntry, resultExists := result[path] if baseExists && resultExists && baseEntry == resultEntry { continue } operation := ChangeOperation{Path: path} switch { case !baseExists: operation.Kind = ChangeOperationAdd case !resultExists: operation.Kind = ChangeOperationDelete default: operation.Kind = ChangeOperationModify } if baseExists { copy := baseEntry operation.Base = © } if resultExists { copy := resultEntry operation.Result = © } operations = append(operations, operation) } return operations } func changeSetRevision(changeSet ChangeSet) string { copy := changeSet copy.ID = "" copy.Revision = "" copy.Locator = ChangeSetLocator{} encoded, _ := json.Marshal(copy) return digestBytes(encoded) } func readChangeSet(path string) (ChangeSet, error) { var changeSet ChangeSet if err := readJSONFile(path, &changeSet); err != nil { return ChangeSet{}, fmt.Errorf("agentworkspace: read change set: %w", err) } if changeSet.SchemaVersion != changeSetSchemaVersion || changeSet.Revision != changeSetRevision(changeSet) || changeSet.ID != agenttask.ChangeSetID( "change-"+strings.TrimPrefix(changeSet.Revision, "sha256:"), ) || !validChangeSetIdentity(changeSet.Identity()) { return ChangeSet{}, errors.New("agentworkspace: change-set record is corrupt") } if len(changeSet.Operations) == 0 || len(changeSet.Operations) != len(changeSet.WriteSet) || len(changeSet.ValidationEvidence) == 0 { return ChangeSet{}, errors.New("agentworkspace: change-set record is incomplete") } for index, operation := range changeSet.Operations { if operation.Path != changeSet.WriteSet[index] || filepath.ToSlash(filepath.Clean(operation.Path)) != operation.Path || operation.Path == "." || strings.HasPrefix(operation.Path, "../") { return ChangeSet{}, errors.New("agentworkspace: invalid change-set write path") } if index > 0 && changeSet.Operations[index-1].Path >= operation.Path { return ChangeSet{}, errors.New( "agentworkspace: change-set operations are not strictly ordered", ) } switch operation.Kind { case ChangeOperationAdd: if operation.Base != nil || operation.Result == nil { return ChangeSet{}, errors.New("agentworkspace: invalid add operation") } case ChangeOperationModify: if operation.Base == nil || operation.Result == nil { return ChangeSet{}, errors.New("agentworkspace: invalid modify operation") } case ChangeOperationDelete: if operation.Base == nil || operation.Result != nil { return ChangeSet{}, errors.New("agentworkspace: invalid delete operation") } default: return ChangeSet{}, errors.New("agentworkspace: invalid operation kind") } if operation.Result != nil && operation.Result.Kind == SnapshotEntryRegular { if operation.ContentFile != filepath.ToSlash( filepath.Join("content", operation.Path), ) { return ChangeSet{}, errors.New( "agentworkspace: invalid regular content locator", ) } } else if operation.ContentFile != "" { return ChangeSet{}, errors.New( "agentworkspace: non-regular operation contains content", ) } } evidence, err := normalizeValidationEvidence(changeSet.ValidationEvidence) if err != nil || !reflect.DeepEqual(evidence, changeSet.ValidationEvidence) { return ChangeSet{}, errors.New( "agentworkspace: invalid change-set validation evidence", ) } cleanPath := filepath.Clean(path) if changeSet.Locator.Record != cleanPath || changeSet.Locator.Root != filepath.Dir(cleanPath) || changeSet.Locator.ContentRoot != filepath.Join(filepath.Dir(cleanPath), "content") || !filepath.IsAbs(changeSet.CanonicalRoot) || filepath.Clean(changeSet.CanonicalRoot) != changeSet.CanonicalRoot { return ChangeSet{}, errors.New("agentworkspace: invalid change-set locator") } return changeSet, nil } func validChangeSetIdentity(identity agenttask.ChangeSetIdentity) bool { const prefix = "sha256:" if !strings.HasPrefix(identity.Revision, prefix) { return false } digest := strings.TrimPrefix(identity.Revision, prefix) decoded, err := hex.DecodeString(digest) return err == nil && len(decoded) == sha256.Size && string(identity.ID) == "change-"+digest && identity.ArtifactID != "" }