diff --git a/cli/cmd/ao/git_read.go b/cli/cmd/ao/git_read.go index b231b3b43..76ef4a651 100644 --- a/cli/cmd/ao/git_read.go +++ b/cli/cmd/ao/git_read.go @@ -33,29 +33,54 @@ func gitDiscoveryEnv() []string { return env } -// gitChangedFiles lists worktree-modified paths (read-only, bounded) for -// handoff evidence. Returns nil when git is unavailable or the tree is clean. -func gitChangedFiles(cwd string, limit int) []string { +// gitChangedFiles includes tracked and untracked work for handoff evidence. +// A failed observation is distinct from a successfully observed clean tree. +func gitChangedFiles(cwd string, limit int) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond) defer cancel() - command := exec.CommandContext(ctx, "git", "diff", "--name-only", "HEAD") + command := exec.CommandContext(ctx, "git", "status", "--porcelain=v1", "-z", "--untracked-files=all") command.Dir = cwd command.Env = gitDiscoveryEnv() out, err := command.Output() - if err != nil || strings.TrimSpace(string(out)) == "" { - return nil + if err != nil { + return nil, fmt.Errorf("observe Git status: %w", err) + } + files, err := parseGitStatus(string(out)) + if err != nil { + return nil, err + } + if limit > 0 && len(files) > limit { + files = files[:limit] } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if limit > 0 && len(lines) > limit { - lines = lines[:limit] + return files, nil +} + +func parseGitStatus(raw string) ([]string, error) { + if raw == "" { + return nil, nil } - result := make([]string, 0, len(lines)) - for _, line := range lines { - if line = strings.TrimSpace(line); line != "" { - result = append(result, line) + if !strings.HasSuffix(raw, "\x00") { + return nil, fmt.Errorf("unterminated Git status record") + } + records := strings.Split(strings.TrimSuffix(raw, "\x00"), "\x00") + var paths []string + for i := 0; i < len(records); i++ { + record := records[i] + if len(record) < 4 || record[2] != ' ' { + return nil, fmt.Errorf("invalid Git status record") + } + paths = append(paths, record[3:]) + // Porcelain -z emits a rename/copy destination followed by the + // original path as a separate NUL-delimited field without a status. + if strings.ContainsAny(record[:2], "RC") { + i++ + if i == len(records) || records[i] == "" { + return nil, fmt.Errorf("missing Git rename/copy source") + } + paths = append(paths, records[i]) } } - return result + return paths, nil } // resolveRepoRoot is read-only discovery. AgentOps does not mutate Git state. diff --git a/cli/cmd/ao/handoff.go b/cli/cmd/ao/handoff.go index 5ad4c28a4..0546f673e 100644 --- a/cli/cmd/ao/handoff.go +++ b/cli/cmd/ao/handoff.go @@ -103,11 +103,17 @@ func runHandoff(cmd *cobra.Command, args []string) error { } func collectHandoffState(cwd string) *handoffState { + files, err := gitChangedFiles(cwd, 20) + if err != nil { + // State is optional: absence means unavailable, whereas an emitted + // git_dirty=false must represent an actual successful observation. + return nil + } state := &handoffState{} if branch, err := getCurrentBranch(cwd); err == nil { state.GitBranch = branch } - state.ModifiedFiles = gitChangedFiles(cwd, 20) + state.ModifiedFiles = files state.GitDirty = len(state.ModifiedFiles) > 0 command := exec.Command("git", "log", "--oneline", "-5", "--no-decorate") command.Dir = cwd diff --git a/cli/cmd/ao/handoff_git_test.go b/cli/cmd/ao/handoff_git_test.go new file mode 100644 index 000000000..cade4c927 --- /dev/null +++ b/cli/cmd/ao/handoff_git_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "testing" +) + +func TestHandoffCollectIncludesUntrackedNames(t *testing.T) { + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir, cmd.Env = dir, gitDiscoveryEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + } + run("init", "-q") + run("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-qm", "baseline") + clean := collectHandoffState(dir) + if clean == nil || clean.GitDirty { + t.Fatalf("clean repository state = %+v", clean) + } + names := []string{"new-work.go", "café.go"} + if runtime.GOOS != "windows" { + names = append(names, " spaced\n.go ") + } + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte("package work\n"), 0o600); err != nil { + t.Fatal(err) + } + } + state := collectHandoffState(dir) + if state == nil || !state.GitDirty { + t.Fatalf("untracked work reported clean: %+v", state) + } + for _, name := range names { + if !slices.Contains(state.ModifiedFiles, name) { + t.Errorf("collected paths %q omit exact filename %q", state.ModifiedFiles, name) + } + } + data, err := json.Marshal(handoffArtifact{SchemaVersion: 1, ID: "handoff-20260909T120000Z", CreatedAt: "2026-09-09T12:00:00Z", State: state}) + if err != nil { + t.Fatal(err) + } + var instance any + if err := json.Unmarshal(data, &instance); err != nil { + t.Fatal(err) + } + if err := compileHandoffSchema(t).Validate(instance); err != nil { + t.Fatalf("collected state violates the handoff schema: %v", err) + } + files, err := gitChangedFiles(dir, 1) + if err != nil || len(files) != 1 { + t.Fatalf("bounded collection = %q, %v", files, err) + } +} + +func TestHandoffCollectUnavailableIsNotClean(t *testing.T) { + if state := collectHandoffState(t.TempDir()); state != nil { + t.Fatalf("non-repository produced a supposedly known Git state: %+v", state) + } +} + +func TestParseGitStatusPreservesRenameAndCopyPaths(t *testing.T) { + raw := "R new\nname\x00 old name \x00C café.go\x00source.go\x00?? untracked.go\x00" + got, err := parseGitStatus(raw) + want := []string{"new\nname", " old name ", "café.go", "source.go", "untracked.go"} + if err != nil || !slices.Equal(got, want) { + t.Fatalf("paths = %q, %v; want %q", got, err, want) + } + for _, invalid := range []string{"?? unterminated", "x\x00", "??\x00", "R target\x00", "C target\x00\x00"} { + if _, err := parseGitStatus(invalid); err == nil { + t.Errorf("accepted incomplete status %q", invalid) + } + } +} diff --git a/cli/internal/doctor/doctor_test.go b/cli/internal/doctor/doctor_test.go index 0c55619a1..03fe4daef 100644 --- a/cli/internal/doctor/doctor_test.go +++ b/cli/internal/doctor/doctor_test.go @@ -74,7 +74,7 @@ func TestMutate_RoundTrip(t *testing.T) { t.Fatalf("file content = %q, want %q", got, newContent) } // Backup exists and is byte-identical to original. - backup := filepath.Join(ra.BackupsDir(), ".agents", "ao", "thing.txt") + backup := recordedBackup(t, ra, 0) bgot, err := os.ReadFile(backup) if err != nil { t.Fatalf("backup missing: %v", err) diff --git a/cli/internal/doctor/engine.go b/cli/internal/doctor/engine.go index 070adebef..ef8f73c87 100644 --- a/cli/internal/doctor/engine.go +++ b/cli/internal/doctor/engine.go @@ -481,11 +481,13 @@ func applyFixers(repoRoot string, mctx *MutateContext, env *DetectEnv, findings res, err := fx.Fix(mctx.WithFixer(fixerID), env, fs) actions += res.ActionsTaken skipped = append(skipped, res.Skipped...) - if err != nil || !res.Fixed { + if err != nil || res.Err != nil { failed = true continue } - fixed += len(fs) + if res.Fixed { + fixed += len(fs) + } } return actions, fixed, failed, skipped } @@ -680,8 +682,8 @@ type UndoResult struct { } // Undo reads a run's actions.jsonl in reverse and restores each mutated file -// from backups/. Under strict mode (default) it fails if a backup is missing -// or the restored hash does not match the recorded before_hash. +// from backups/. All required backups are validated before any restoration. +// Under strict mode (default), missing backups and rename conflicts also fail. func Undo(repoRoot, runID string, strict, dryRun bool) (*UndoResult, error) { runDir, err := resolveRunDir(repoRoot, runID) if err != nil { @@ -692,9 +694,15 @@ func Undo(repoRoot, runID string, strict, dryRun bool) (*UndoResult, error) { return &UndoResult{RunID: runID, ExitCode: ExitFixFailed}, err } res := &UndoResult{RunID: filepath.Base(runDir), ExitCode: ExitHealthy} - for i := len(records) - 1; i >= 0; i-- { - rec := records[i] - if err := undoOne(repoRoot, runDir, rec, strict, dryRun, res); err != nil { + prepared, err := prepareUndo(runDir, records, strict) + if err != nil { + res.ExitCode = ExitFixFailed + res.StrictError = err.Error() + return res, err + } + locks := NewLockManager(filepath.Join(repoRoot, ".doctor", "locks")) + for i := len(prepared) - 1; i >= 0; i-- { + if err := undoOne(repoRoot, prepared[i], locks, strict, dryRun, res); err != nil { res.ExitCode = ExitFixFailed res.StrictError = err.Error() return res, err @@ -703,59 +711,6 @@ func Undo(repoRoot, runID string, strict, dryRun bool) (*UndoResult, error) { return res, nil } -// undoOne reverses a single action record. -func undoOne(repoRoot, runDir string, rec ActionRecord, strict, dryRun bool, res *UndoResult) error { - target := filepath.Join(repoRoot, rec.Path) - if rec.Op == "Rename" && rec.RenameTo != "" { - // Reverse the move: rename the quarantined file back. - if dryRun { - fmt.Fprintf(os.Stderr, "[dry-run] would restore (un-rename) %s\n", target) - res.Skipped++ - return nil - } - if err := os.Rename(rec.RenameTo, target); err != nil { - if strict { - return fmt.Errorf("doctor: un-rename %s: %w", target, err) - } - res.Skipped++ - return nil - } - res.Restored++ - return nil - } - backup := filepath.Join(runDir, "backups", rec.Path) - if _, err := os.Stat(backup); err != nil { - if !rec.Existed { - // The file did not exist before; undo leaves it (created files are - // the user's to inspect; we never delete). - res.Skipped++ - return nil - } - if strict { - return fmt.Errorf("doctor: missing backup for %s", rec.Path) - } - res.Skipped++ - return nil - } - if dryRun { - fmt.Fprintf(os.Stderr, "[dry-run] would restore %s from backup\n", target) - res.Skipped++ - return nil - } - if err := copyVerbatim(backup, target); err != nil { - return fmt.Errorf("doctor: restore %s: %w", target, err) - } - restored, err := os.ReadFile(target) - if err != nil { - return fmt.Errorf("doctor: read restored %s: %w", target, err) - } - if strict && sha256Hex(restored) != rec.BeforeHash { - return fmt.Errorf("doctor: restored hash mismatch for %s", rec.Path) - } - res.Restored++ - return nil -} - // readActions reads and parses a run's actions.jsonl. func readActions(path string) ([]ActionRecord, error) { f, err := os.Open(path) diff --git a/cli/internal/doctor/engine_test.go b/cli/internal/doctor/engine_test.go index ee6effa19..e67aad7cc 100644 --- a/cli/internal/doctor/engine_test.go +++ b/cli/internal/doctor/engine_test.go @@ -1,11 +1,37 @@ package doctor import ( + "path/filepath" "strings" "testing" "time" ) +func TestFixHygieneReportsUnresolvedFindings(t *testing.T) { + for _, linked := range []bool{false, true} { + t.Run(map[bool]string{false: "manual-only", true: "mixed"}[linked], func(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + writeSkillsFile(t, filepath.Join(repo, "skills", "sample", "SKILL.md"), "---\nname: sample\ndescription: sample\n---\nBody.\n") + wantActions := 0 + if linked { + writeSkillsFile(t, filepath.Join(repo, "skills", "sample", "references", "detail.md"), "detail") + wantActions = 1 + } + report, err := Fix(Options{RepoRoot: repo, CWD: repo, HomeDir: home, Only: []string{"fm-skills-integrity-hygiene"}}) + if err != nil { + t.Fatal(err) + } + if report.OK || report.ExitCode != ExitFixPartial || report.ActionsTaken != wantActions { + t.Fatalf("Fix=%+v, want unresolved partial result with %d actions", report, wantActions) + } + post, err := skillsIntegrityHygieneDetector{}.Detect(&DetectEnv{RepoRoot: repo, HomeDir: home}) + if err != nil || len(post) != 1 { + t.Fatalf("Detect=%+v err=%v, want manual finding retained", post, err) + } + }) + } +} + // TestHealthLineReportsEverySeverityBucket guards novice edge 4b: the health // one-liner once printed only P0 and P2, silently dropping P1 — the worst // severity actually present. A persisted run whose only finding is the P1 diff --git a/cli/internal/doctor/fix_bridges_test.go b/cli/internal/doctor/fix_bridges_test.go index 57ff51f5e..49eb891f4 100644 --- a/cli/internal/doctor/fix_bridges_test.go +++ b/cli/internal/doctor/fix_bridges_test.go @@ -318,7 +318,7 @@ func TestBridgesSnapshotTornLatestFix(t *testing.T) { } // Backup of the torn latest.json exists and matches the pre-fix torn bytes. - backup := filepath.Join(ra.RunDir, "backups", openclaw.SnapshotDirRel, "latest.json") + backup := recordedBackup(t, ra, 0) backupBytes, err := os.ReadFile(backup) if err != nil { t.Fatalf("torn-latest backup missing: %v", err) diff --git a/cli/internal/doctor/fix_knowledge_test.go b/cli/internal/doctor/fix_knowledge_test.go index 97bb351ad..8d8b0537f 100644 --- a/cli/internal/doctor/fix_knowledge_test.go +++ b/cli/internal/doctor/fix_knowledge_test.go @@ -99,7 +99,7 @@ func TestKnowledgeCorruptIndexLines_DetectFixUndo(t *testing.T) { t.Fatalf("post-fix index = %q, want %q", got, want) } // Backup is byte-identical to the corrupt original. - backup := filepath.Join(ra.BackupsDir(), ".agents", "ao", "index", "search-index.jsonl") + backup := recordedBackup(t, ra, 0) bgot, err := os.ReadFile(backup) if err != nil { t.Fatalf("backup missing: %v", err) @@ -189,7 +189,7 @@ func TestKnowledgeTornAppendLine_DetectFixUndo(t *testing.T) { t.Fatalf("post-fix index = %q, want %q", got, want) } // Backup byte-identical to torn original. - backup := filepath.Join(ra.BackupsDir(), ".agents", "ao", "index", "search-index.jsonl") + backup := recordedBackup(t, ra, 0) bgot, err := os.ReadFile(backup) if err != nil { t.Fatalf("backup missing: %v", err) diff --git a/cli/internal/doctor/fix_skills.go b/cli/internal/doctor/fix_skills.go index 540f983c2..fbb278e1c 100644 --- a/cli/internal/doctor/fix_skills.go +++ b/cli/internal/doctor/fix_skills.go @@ -1093,7 +1093,7 @@ func (d skillsIntegrityHygieneDetector) Detect(env *DetectEnv) ([]Finding, error File: "skills", Query: "hygiene: " + strings.Join(kinds, ", "), }, - Remediation: remediation(d.ID(), true, unlinked), + Remediation: remediation(d.ID(), unlinked > 0, unlinked), }}, nil } @@ -1166,9 +1166,8 @@ func (f skillsIntegrityHygieneFixer) Fix(ctx *MutateContext, env *DetectEnv, _ [ } } if len(bySkill) == 0 { - // Nothing safely fixable; report-only findings remain. A successful - // run with nothing to fix, not a refusal. - res.Fixed = true + // The scan succeeded, but the report-only findings remain unresolved. + res.Fixed = false return res, nil } // 3/4. Append a references block to each affected SKILL.md. @@ -1213,13 +1212,19 @@ func (f skillsIntegrityHygieneFixer) Fix(ctx *MutateContext, env *DetectEnv, _ [ } // 5. Verify: no UNLINKED finding may remain. Report-only findings may. if !ctx.DryRun { - post, _, _ := scanSkillHygiene(env.RepoRoot) + post, _, err := scanSkillHygiene(env.RepoRoot) + if err != nil { + res.Err = fmt.Errorf("doctor: %s: verify hygiene: %w", f.ID(), err) + return res, res.Err + } for _, h := range post { if h.Kind == "UNLINKED" { res.Err = fmt.Errorf("doctor: %s: fix did not eliminate the unlinked-reference findings", f.ID()) return res, res.Err } } + res.Fixed = len(post) == 0 + return res, nil } res.Fixed = true return res, nil diff --git a/cli/internal/doctor/fix_skills_test.go b/cli/internal/doctor/fix_skills_test.go index daf5f8144..e013eaada 100644 --- a/cli/internal/doctor/fix_skills_test.go +++ b/cli/internal/doctor/fix_skills_test.go @@ -97,7 +97,7 @@ func TestSkillsStaleCommandRefsFixer(t *testing.T) { } // Backup exists, byte-identical to original. - backup := filepath.Join(ra.BackupsDir(), "skills", "sample", "SKILL.md") + backup := recordedBackupForPath(t, ra, "skills/sample/SKILL.md") bgot, err := os.ReadFile(backup) if err != nil { t.Fatalf("backup missing: %v", err) @@ -440,8 +440,8 @@ func TestSkillsIntegrityHygieneFixer(t *testing.T) { if err != nil { t.Fatalf("Fix: %v", err) } - if !res.Fixed || res.ActionsTaken != 1 { - t.Fatalf("Fix Fixed=%t ActionsTaken=%d, want true/1", res.Fixed, res.ActionsTaken) + if res.Fixed || res.ActionsTaken != 1 { + t.Fatalf("Fix Fixed=%t ActionsTaken=%d, want false/1 while manual findings remain", res.Fixed, res.ActionsTaken) } got, _ := os.ReadFile(skillMD) @@ -477,7 +477,6 @@ func TestSkillsIntegrityHygieneFixer(t *testing.T) { } } -// TestSkillsIntegrityHygieneReportOnlyNoMutate verifies that when the only // TestSkillsHygiene_PlaceholderLinkNotDeadRef pins the DEAD_REF placeholder // exclusion: a template link like [text](references/.md) in skill docs // (showing the link FORMAT, e.g. skill-builder's "move section bodies to @@ -524,8 +523,8 @@ func TestHasAnglePlaceholder(t *testing.T) { } } -// hygiene violations are report-only, the fixer takes no action and does not -// refuse (a clean run with nothing safely fixable). +// TestSkillsIntegrityHygieneReportOnlyNoMutate verifies that report-only +// hygiene findings remain unresolved without being mistaken for an error. func TestSkillsIntegrityHygieneReportOnlyNoMutate(t *testing.T) { repo := t.TempDir() home := t.TempDir() @@ -541,8 +540,8 @@ func TestSkillsIntegrityHygieneReportOnlyNoMutate(t *testing.T) { if err != nil { t.Fatalf("report-only run should not refuse: %v", err) } - if !res.Fixed || res.ActionsTaken != 0 { - t.Fatalf("report-only run: Fixed=%t ActionsTaken=%d, want true/0", res.Fixed, res.ActionsTaken) + if res.Fixed || res.ActionsTaken != 0 { + t.Fatalf("report-only run: Fixed=%t ActionsTaken=%d, want false/0", res.Fixed, res.ActionsTaken) } recs, _ := readActions(ra.ActionsPath()) if len(recs) != 0 { diff --git a/cli/internal/doctor/fix_workspace.go b/cli/internal/doctor/fix_workspace.go index 2cb31f2ce..30f2f7c0d 100644 --- a/cli/internal/doctor/fix_workspace.go +++ b/cli/internal/doctor/fix_workspace.go @@ -244,7 +244,7 @@ func workspaceGCTTL() time.Duration { // actions.jsonl record. // // A directory Rename needs no byte backup or content hash to be reversible: -// engine.undoOne reverses a Rename record with a bare rename-back and never +// undoOne reverses a Rename record under both endpoint locks and never // consults backups or hashes, and the renamed tree IS the preserved copy, // byte for byte. Hash fields record the empty-content hash for journal-shape // consistency (the same value Mutate records for an absent file). @@ -451,9 +451,8 @@ func workspaceAppendActionStaged(ctx *MutateContext, rec ActionRecord) (wrote bo // The action is journaled with the same record shape Mutate writes for a // Rename (Op "Rename", RenameTo=dest, before-hash of the source content, // empty after-hash at the vacated source path): it IS the move it performed, -// only executed differently. engine.undoOne replays a Rename record with a -// reverse os.Rename(RenameTo, Path), which works identically after -// link+remove — the source is gone and the destination exists, exactly as +// only executed differently. undoOne replays a Rename record with a reverse +// no-clobber move: the source is gone and the destination exists, exactly as // after a plain rename. func workspaceFileMoveNoClobber(ctx *MutateContext, path, dest string) (collided bool, err error) { op := Rename{To: dest} @@ -494,9 +493,11 @@ func workspaceFileMoveNoClobber(ctx *MutateContext, path, dest string) (collided } // Step 4 — verbatim backup (same as Mutate for an existing file). + backupPath := "" if !ctx.DryRun { - if err := workspaceWriteVerifiedBackup(ctx, path, beforeBytes, info); err != nil { - return false, err + backupPath, err = writeActionBackup(ctx, beforeBytes, info) + if err != nil { + return false, fmt.Errorf("doctor: backup %s: %w", path, err) } } @@ -517,23 +518,7 @@ func workspaceFileMoveNoClobber(ctx *MutateContext, path, dest string) (collided // Step 7/8 — fsync'd action record; same execute-before-journal parity and // crash exposure as workspaceDirRename (see the comment there), and the // same staged write/sync recovery split. - return false, workspaceJournalFileMove(ctx, root, pathRel, destRel, path, dest, info, op, beforeHash, startedNS) -} - -func workspaceWriteVerifiedBackup(ctx *MutateContext, path string, beforeBytes []byte, info os.FileInfo) error { - rel, relErr := filepath.Rel(ctx.RepoRoot, path) - if relErr != nil { - rel = filepath.Base(path) - } - backup := filepath.Join(ctx.RunDir, "backups", rel) - if err := writeWorkspaceBackup(backup, beforeBytes, info); err != nil { - return fmt.Errorf("doctor: backup %s: %w", path, err) - } - backupBytes, err := os.ReadFile(backup) - if err != nil || !bytes.Equal(beforeBytes, backupBytes) { - return fmt.Errorf("backup verify failed (cmp-strict mismatch for %s)", path) - } - return nil + return false, workspaceJournalFileMove(ctx, root, pathRel, destRel, path, dest, info, op, beforeHash, backupPath, startedNS) } func workspaceExecuteFileMoveNoClobber(root *os.Root, pathRel, destRel, path, dest string, info os.FileInfo) (bool, error) { @@ -585,7 +570,7 @@ func workspaceFileMoveIdentityChanged(root *os.Root, pathRel, destRel string, ex !os.SameFile(expected, destInfo) || !os.SameFile(expected, sourceInfo) } -func workspaceJournalFileMove(ctx *MutateContext, root *os.Root, pathRel, destRel, path, dest string, info os.FileInfo, op Rename, beforeHash string, startedNS int64) error { +func workspaceJournalFileMove(ctx *MutateContext, root *os.Root, pathRel, destRel, path, dest string, info os.FileInfo, op Rename, beforeHash, backupPath string, startedNS int64) error { rel, relErr := filepath.Rel(ctx.RepoRoot, path) if relErr != nil { rel = path @@ -596,6 +581,7 @@ func workspaceJournalFileMove(ctx *MutateContext, root *os.Root, pathRel, destRe BeforeHash: beforeHash, AfterHash: sha256Hex(nil), // the source path is empty after the move, matching Mutate's read-back BeforeMode: fmt.Sprintf("%o", info.Mode().Perm()), + BackupPath: backupPath, StartedAtNS: startedNS, FinishedAtNS: time.Since(ctx.start).Nanoseconds(), RunID: ctx.RunID, @@ -730,31 +716,6 @@ func readWorkspaceRootRegular(root *os.Root, name string) (os.FileInfo, []byte, return openedAfter, first, nil } -func writeWorkspaceBackup(path string, data []byte, info os.FileInfo) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { - return err - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - return err - } - if err := file.Sync(); err != nil { - _ = file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - if err := os.Chmod(path, info.Mode()); err != nil { - return err - } - return os.Chtimes(path, info.ModTime(), info.ModTime()) -} - // workspaceQuarantineDirByName validates name as a bare path element (a // structural-containment guard on top of the scope check), then renames // base/name to dest through workspaceDirRename. diff --git a/cli/internal/doctor/mutate.go b/cli/internal/doctor/mutate.go index 50567c942..b297a417d 100644 --- a/cli/internal/doctor/mutate.go +++ b/cli/internal/doctor/mutate.go @@ -3,7 +3,6 @@ package doctor import ( "encoding/json" "fmt" - "io" "os" "path/filepath" "sync" @@ -77,6 +76,7 @@ type ActionRecord struct { BeforeHash string `json:"before_hash"` AfterHash string `json:"after_hash"` BeforeMode string `json:"before_mode,omitempty"` + BackupPath string `json:"backup_path,omitempty"` // immutable, relative to the owning run StartedAtNS int64 `json:"started_at_ns"` FinishedAtNS int64 `json:"finished_at_ns"` RunID string `json:"run_id"` @@ -97,57 +97,6 @@ func readOrEmpty(path string) ([]byte, error) { return b, err } -// copyVerbatim copies src to dst preserving mode and mtime. -func copyVerbatim(src, dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } - info, err := os.Stat(src) - if err != nil { - return err - } - in, err := os.Open(src) - if err != nil { - return err - } - defer func() { _ = in.Close() }() - out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - if err := out.Sync(); err != nil { - _ = out.Close() - return err - } - if err := out.Close(); err != nil { - return err - } - if err := os.Chmod(dst, info.Mode()); err != nil { - return err - } - return os.Chtimes(dst, info.ModTime(), info.ModTime()) -} - -// cmpStrict verifies that two files are byte-identical. -func cmpStrict(a, b string) error { - ba, err := os.ReadFile(a) - if err != nil { - return err - } - bb, err := os.ReadFile(b) - if err != nil { - return err - } - if string(ba) != string(bb) { - return fmt.Errorf("backup verify failed (cmp-strict mismatch for %s)", a) - } - return nil -} - // Mutate is the single chokepoint through which every `fix`/`undo` disk write // flows. It implements the 8-step shape from MUTATE-CHOKEPOINT.md: per-path // lock, before_hash, in-scope precondition, verbatim backup (mode+mtime @@ -185,17 +134,16 @@ func Mutate(ctx *MutateContext, path string, op Op) (ActionResult, error) { return ActionResult{Err: err}, err } - // Step 4 — verbatim backup (skip in dry-run; skip if file absent). + // Step 4 — one immutable backup per action, contained within this run. + backupPath := "" if !ctx.DryRun && existed { - rel, relErr := filepath.Rel(ctx.RepoRoot, path) - if relErr != nil { - rel = filepath.Base(path) - } - backup := filepath.Join(ctx.RunDir, "backups", rel) - if err := copyVerbatim(path, backup); err != nil { + backupPath, err = writeActionBackup(ctx, beforeBytes, info) + if err != nil { return ActionResult{Err: err}, fmt.Errorf("doctor: backup %s: %w", path, err) } - if err := cmpStrict(path, backup); err != nil { + current, readErr := os.ReadFile(path) + if readErr != nil || sha256Hex(current) != beforeHash { + err := fmt.Errorf("doctor: source changed while backing up %s", path) return ActionResult{Err: err}, err } } @@ -228,6 +176,7 @@ func Mutate(ctx *MutateContext, path string, op Op) (ActionResult, error) { BeforeHash: beforeHash, AfterHash: afterHash, BeforeMode: beforeMode, + BackupPath: backupPath, StartedAtNS: startedNS, FinishedAtNS: time.Since(ctx.start).Nanoseconds(), RunID: ctx.RunID, diff --git a/cli/internal/doctor/recovery.go b/cli/internal/doctor/recovery.go new file mode 100644 index 000000000..46ade0d88 --- /dev/null +++ b/cli/internal/doctor/recovery.go @@ -0,0 +1,187 @@ +package doctor + +import ( + "bytes" + "crypto/rand" + "fmt" + "os" + "path/filepath" +) + +// writeActionBackup creates an exclusive per-action snapshot. Source paths +// never participate in its name, so home paths and repeated writes cannot +// escape the run or replace an earlier snapshot. The rooted handle also +// prevents backup-directory symlinks from escaping the owning run. +func writeActionBackup(ctx *MutateContext, data []byte, info os.FileInfo) (string, error) { + root, err := os.OpenRoot(ctx.RunDir) + if err != nil { + return "", err + } + defer func() { _ = root.Close() }() + name := filepath.Join("backups", rand.Text()) + if err := workspaceRootParentsReal(root, name); err != nil { + return "", err + } + if err := root.MkdirAll("backups", 0o700); err != nil { + return "", err + } + file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + if _, err := file.Write(data); err != nil { + return "", err + } + if err := file.Chmod(info.Mode()); err != nil { + return "", err + } + if err := file.Sync(); err != nil { + return "", err + } + if err := root.Chtimes(name, info.ModTime(), info.ModTime()); err != nil { + return "", err + } + _, stored, err := readWorkspaceRootRegular(root, name) + if err != nil { + return "", err + } + if !bytes.Equal(data, stored) { + return "", fmt.Errorf("backup verify failed (cmp-strict mismatch)") + } + return name, nil +} + +type undoAction struct { + record ActionRecord + data []byte + info os.FileInfo +} + +// prepareUndo validates all backup bytes before the first live mutation. This +// also protects older journals whose repeated writes shared one overwritten +// backup: a mismatch anywhere rejects the run without partially restoring it. +func prepareUndo(runDir string, records []ActionRecord, strict bool) ([]undoAction, error) { + root, err := os.OpenRoot(runDir) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + prepared := make([]undoAction, 0, len(records)) + for _, rec := range records { + action := undoAction{record: rec} + if rec.OK && !rec.RolledBack && rec.Existed && rec.Op != "Rename" { + action.info, action.data, err = readActionBackup(root, rec) + if err != nil && (strict || !os.IsNotExist(err)) { + return nil, err + } + } + prepared = append(prepared, action) + } + return prepared, nil +} + +func readActionBackup(root *os.Root, rec ActionRecord) (os.FileInfo, []byte, error) { + name := rec.BackupPath + if name == "" { + // Legacy action records remain readable only when their old layout is + // contained. Escaped home backups may have been shared across runs. + if !filepath.IsLocal(rec.Path) { + return nil, nil, fmt.Errorf("doctor: unsafe legacy backup path for %s", rec.Path) + } + name = filepath.Join("backups", rec.Path) + } + if !filepath.IsLocal(name) { + return nil, nil, fmt.Errorf("doctor: backup path escapes run: %s", name) + } + if err := workspaceRootParentsReal(root, name); err != nil { + return nil, nil, err + } + info, data, err := readWorkspaceRootRegular(root, name) + if err != nil { + return nil, nil, fmt.Errorf("doctor: read backup for %s: %w", rec.Path, err) + } + if sha256Hex(data) != rec.BeforeHash { + return nil, nil, fmt.Errorf("doctor: backup hash mismatch for %s", rec.Path) + } + return info, data, nil +} + +func undoOne(repoRoot string, action undoAction, locks *LockManager, strict, dryRun bool, res *UndoResult) error { + rec := action.record + if !rec.OK || rec.RolledBack || (rec.Op != "Rename" && action.info == nil) { + res.Skipped++ + return nil + } + target := filepath.Join(repoRoot, rec.Path) + if dryRun { + fmt.Fprintf(os.Stderr, "[dry-run] would restore %s\n", target) + res.Skipped++ + return nil + } + guard, err := locks.Acquire(target) + if err != nil { + return err + } + defer func() { _ = guard.Release() }() + if rec.Op == "Rename" { + if err := undoRename(target, rec.RenameTo, locks); err != nil { + if strict { + return fmt.Errorf("doctor: un-rename %s: %w", target, err) + } + res.Skipped++ + return nil + } + } else { + // Use the exact bytes validated during preflight, never re-open an + // untrusted backup after truncating or replacing the live target. + if err := atomicWrite(target, action.data, action.info.Mode()); err != nil { + return fmt.Errorf("doctor: restore %s: %w", target, err) + } + if err := os.Chtimes(target, action.info.ModTime(), action.info.ModTime()); err != nil { + return fmt.Errorf("doctor: restore mtime %s: %w", target, err) + } + restored, err := os.ReadFile(target) + if err != nil { + return fmt.Errorf("doctor: read restored %s: %w", target, err) + } + if strict && sha256Hex(restored) != rec.BeforeHash { + return fmt.Errorf("doctor: restored hash mismatch for %s", rec.Path) + } + } + res.Restored++ + return nil +} + +// undoRename holds both endpoints' advisory locks. Lstat treats a dangling +// symlink or empty directory as a conflict too. Non-directory moves additionally +// use link's atomic no-replace guarantee against uncoordinated file creators. +// Directory renames retain the workspace fixers' advisory-lock race boundary. +func undoRename(target, source string, locks *LockManager) error { + if source == "" || filepath.Clean(source) == filepath.Clean(target) { + return fmt.Errorf("invalid rename source %q", source) + } + guard, err := locks.Acquire(source) + if err != nil { + return err + } + defer func() { _ = guard.Release() }() + if _, err := os.Lstat(target); err == nil { + return fmt.Errorf("restore conflict: %s already exists", target) + } else if !os.IsNotExist(err) { + return err + } + info, err := os.Lstat(source) + if err != nil { + return err + } + if !info.IsDir() { + if err := os.Link(source, target); err != nil { + return err + } + // On a removal failure preserve both links; never delete a replacement + // at the target in an attempt to compensate. + return os.Remove(source) + } + return os.Rename(source, target) +} diff --git a/cli/internal/doctor/recovery_test.go b/cli/internal/doctor/recovery_test.go new file mode 100644 index 000000000..a3e689aa9 --- /dev/null +++ b/cli/internal/doctor/recovery_test.go @@ -0,0 +1,316 @@ +package doctor + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func recordedBackup(t *testing.T, ra *RunArtifact, index int) string { + t.Helper() + data, err := os.ReadFile(ra.ActionsPath()) + if err != nil { + t.Fatal(err) + } + var action struct { + Path string `json:"path"` + BackupPath string `json:"backup_path"` + } + if err := json.Unmarshal([]byte(strings.Split(strings.TrimSpace(string(data)), "\n")[index]), &action); err != nil { + t.Fatal(err) + } + if action.BackupPath == "" { + return filepath.Join(ra.BackupsDir(), action.Path) + } + return filepath.Join(ra.RunDir, action.BackupPath) +} + +func recordedBackupForPath(t *testing.T, ra *RunArtifact, path string) string { + t.Helper() + records, err := readActions(ra.ActionsPath()) + if err != nil { + t.Fatal(err) + } + for i, rec := range records { + if filepath.ToSlash(rec.Path) == path { + return recordedBackup(t, ra, i) + } + } + t.Fatalf("no action for %s", path) + return "" +} + +func requireFileContent(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("%s content=%q err=%v, want %q", path, got, err, want) + } +} + +func TestRecoveryTwoSkillFixersRestoreOriginal(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + path := filepath.Join(repo, "skills", "sample", "SKILL.md") + original := "---\nname: sample\ndescription: sample\ntier: core\n---\nUse `ao know forge`.\n" + writeSkillsFile(t, path, original) + writeSkillsFile(t, filepath.Join(repo, "skills", "sample", "references", "detail.md"), "detail\n") + report, err := Fix(Options{RepoRoot: repo, CWD: repo, HomeDir: home, Only: []string{"fm-skills-stale-command-refs", "fm-skills-integrity-hygiene"}}) + if err != nil || report.ActionsTaken != 2 { + t.Fatalf("Fix=%+v err=%v, want two mutations", report, err) + } + result, err := Undo(repo, filepath.Base(report.RunDir), true, false) + if err != nil || result.Restored != 2 { + t.Errorf("Undo=%+v err=%v, want two restorations", result, err) + } + requireFileContent(t, path, original) +} + +func TestRecoveryHomeBackupsStayInTheirRun(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(home, "dev", "repo") + path := filepath.Join(home, ".codex", ".agentops-codex-install.json") + writeSkillsFile(t, path, "original") + var runs []*RunArtifact + for i, content := range []string{"first", "second"} { + ra, err := NewRunArtifact(repo, "testsha", time.Unix(1_700_000_000+int64(i), 0)) + if err != nil { + t.Fatal(err) + } + af, err := ra.OpenActionsFile() + if err != nil { + t.Fatal(err) + } + ctx := NewMutateContext(ra, NewCapabilities("test"), home, NewLockManager(filepath.Join(repo, ".doctor", "locks")), af, false) + _, err = Mutate(ctx, path, WriteFile{Content: []byte(content), Mode: 0o600}) + if closeErr := af.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if err != nil { + t.Fatal(err) + } + runs = append(runs, ra) + backup := recordedBackup(t, ra, 0) + rel, err := filepath.Rel(ra.RunDir, backup) + if err != nil || !filepath.IsLocal(rel) { + t.Errorf("backup %s escapes run %s", backup, ra.RunDir) + } + } + if recordedBackup(t, runs[0], 0) == recordedBackup(t, runs[1], 0) { + t.Error("separate runs share a backup") + } + for i, want := range []string{"first", "original"} { + result, err := Undo(repo, runs[1-i].RunID, true, false) + if err != nil || result.Restored != 1 { + t.Errorf("Undo=%+v err=%v", result, err) + } + requireFileContent(t, path, want) + } +} + +func TestRecoveryRenamePreservesRecreatedPath(t *testing.T) { + for _, kind := range []string{"file", "directory", "symlink"} { + t.Run(kind, func(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + ctx, ra, closeRun := skillsTestCtx(t, repo, home) + defer closeRun() + path := filepath.Join(repo, ".agents", "ao", "record") + dest := filepath.Join(ra.RunDir, "quarantine", "record") + if kind == "directory" { + writeSkillsFile(t, filepath.Join(path, "original"), "original") + if err := workspaceDirRename(ctx, path, dest, nil); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + } else { + writeSkillsFile(t, path, "original") + if _, err := Mutate(ctx, path, Rename{To: dest}); err != nil { + t.Fatal(err) + } + if kind == "symlink" { + if err := os.Symlink("absent", path); err != nil { + t.Fatal(err) + } + } else { + writeSkillsFile(t, path, "new user content") + } + } + result, err := Undo(repo, ra.RunID, true, false) + if err == nil || result.ExitCode != ExitFixFailed || result.Restored != 0 { + t.Errorf("Undo=%+v err=%v, want conflict", result, err) + } + switch kind { + case "directory": + requireFileContent(t, filepath.Join(dest, "original"), "original") + entries, readErr := os.ReadDir(path) + if readErr != nil || len(entries) != 0 { + t.Fatalf("new empty directory changed: entries=%v err=%v", entries, readErr) + } + case "symlink": + link, readErr := os.Readlink(path) + if readErr != nil || link != "absent" { + t.Fatalf("new symlink changed: target=%s err=%v", link, readErr) + } + requireFileContent(t, dest, "original") + default: + requireFileContent(t, path, "new user content") + requireFileContent(t, dest, "original") + } + }) + } +} + +func TestRecoveryCorruptBackupDoesNotOverwriteLiveFile(t *testing.T) { + for _, strict := range []bool{true, false} { + t.Run(map[bool]string{true: "strict", false: "best-effort"}[strict], func(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + ctx, ra, closeRun := skillsTestCtx(t, repo, home) + defer closeRun() + path := filepath.Join(repo, ".agents", "ao", "record") + writeSkillsFile(t, path, "original") + if _, err := Mutate(ctx, path, WriteFile{Content: []byte("fixed")}); err != nil { + t.Fatal(err) + } + writeSkillsFile(t, recordedBackup(t, ra, 0), "corrupt") + result, err := Undo(repo, ra.RunID, strict, false) + if err == nil || result.Restored != 0 { + t.Errorf("Undo=%+v err=%v, want corrupt backup rejected", result, err) + } + requireFileContent(t, path, "fixed") + }) + } +} + +func TestRecoveryLegacyBackups(t *testing.T) { + for _, variant := range []string{"valid", "overwritten", "escaped", "symlink"} { + t.Run(variant, func(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + ctx, ra, closeRun := skillsTestCtx(t, repo, home) + path := filepath.Join(repo, ".agents", "ao", "record") + writeSkillsFile(t, path, "original") + if _, err := Mutate(ctx, path, WriteFile{Content: []byte("intermediate")}); err != nil { + t.Fatal(err) + } + if _, err := Mutate(ctx, path, WriteFile{Content: []byte("final")}); err != nil { + t.Fatal(err) + } + closeRun() + records, err := readActions(ra.ActionsPath()) + if err != nil { + t.Fatal(err) + } + // Derive the legacy on-disk shape from actual production actions. + // A single legacy action remains usable. Repeated legacy writes + // shared the last backup; the original hash then no longer matches. + if variant != "overwritten" { + records = records[:1] + } + legacy := filepath.Join(ra.BackupsDir(), records[0].Path) + writeSkillsFile(t, legacy, "original") + if variant == "overwritten" { + writeSkillsFile(t, legacy, "intermediate") + } + if variant == "escaped" { + records[0].Path = "../../outside" + } + if variant == "symlink" { + if err := os.Remove(legacy); err != nil { + t.Fatal(err) + } + if err := os.Symlink(recordedBackup(t, ra, 0), legacy); err != nil { + t.Fatal(err) + } + } + var journal []byte + for _, rec := range records { + rec.BackupPath = "" + line, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + journal = append(journal, append(line, '\n')...) + } + if err := os.WriteFile(ra.ActionsPath(), journal, 0o600); err != nil { + t.Fatal(err) + } + result, err := Undo(repo, ra.RunID, true, false) + if variant == "valid" { + if err != nil || result.Restored != 1 { + t.Fatalf("legacy Undo=%+v err=%v", result, err) + } + requireFileContent(t, path, "original") + } else { + if err == nil || result.Restored != 0 { + t.Fatalf("unsafe legacy Undo=%+v err=%v", result, err) + } + requireFileContent(t, path, "final") + } + }) + } +} + +func TestRecoveryPreservesSnapshotMetadata(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + ctx, ra, closeRun := skillsTestCtx(t, repo, home) + defer closeRun() + path := filepath.Join(repo, ".agents", "ao", "record") + writeSkillsFile(t, path, "original") + stamp := time.Unix(1_700_000_000, 0) + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + for _, content := range []string{"intermediate", "final"} { + if _, err := Mutate(ctx, path, WriteFile{Content: []byte(content), Mode: 0o644}); err != nil { + t.Fatal(err) + } + } + if recordedBackup(t, ra, 0) == recordedBackup(t, ra, 1) { + t.Fatal("two actions share a backup") + } + requireFileContent(t, recordedBackup(t, ra, 0), "original") + requireFileContent(t, recordedBackup(t, ra, 1), "intermediate") + if _, err := Undo(repo, ra.RunID, true, false); err != nil { + t.Fatal(err) + } + requireFileContent(t, path, "original") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 || !info.ModTime().Equal(stamp) { + t.Fatalf("restored mode=%o mtime=%s", info.Mode().Perm(), info.ModTime()) + } +} + +func TestRecoveryBackupDirectoryCannotRedirectMutation(t *testing.T) { + repo, home, outside := t.TempDir(), t.TempDir(), t.TempDir() + ctx, ra, closeRun := skillsTestCtx(t, repo, home) + defer closeRun() + path := filepath.Join(repo, ".agents", "ao", "record") + writeSkillsFile(t, path, "original") + if err := os.Remove(ra.BackupsDir()); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, ra.BackupsDir()); err != nil { + t.Fatal(err) + } + if _, err := Mutate(ctx, path, WriteFile{Content: []byte("fixed")}); err == nil { + t.Fatal("mutation accepted a redirected backup directory") + } + requireFileContent(t, path, "original") + entries, err := os.ReadDir(outside) + if err != nil || len(entries) != 0 { + t.Fatalf("outside entries=%v err=%v, want no escaped backup", entries, err) + } + records, err := readActions(ra.ActionsPath()) + if err != nil || len(records) != 0 { + t.Fatalf("actions=%v err=%v, want no mutation", records, err) + } +} diff --git a/cli/internal/gates/changedfiles.go b/cli/internal/gates/changedfiles.go index 0508fe167..dc1b0bd66 100644 --- a/cli/internal/gates/changedfiles.go +++ b/cli/internal/gates/changedfiles.go @@ -175,7 +175,7 @@ func (g *GitChangedFiles) Changed(ctx context.Context, scope Scope) ([]string, e } return nil, err } - return dedupeLines(out), nil + return dedupePaths(out), nil } // unbornHeadError translates a failed change-set computation into the friendly @@ -205,31 +205,32 @@ func (g *GitChangedFiles) unbornHeadError(ctx context.Context, scope Scope) erro func scopeArgs(scope Scope) ([]string, error) { switch scope { case ScopeHead: - return []string{"show", "--name-only", "--pretty=format:", "HEAD"}, nil + // Explicit merge diffs use the first parent; --root compares an initial + // commit with the empty tree instead of requiring HEAD^ to exist. + return []string{"diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "--diff-merges=first-parent", "-z", "HEAD"}, nil case ScopeStaged: - return []string{"diff", "--name-only", "--cached"}, nil + return []string{"diff", "--name-only", "-z", "--cached"}, nil case ScopeWorktree: - return []string{"diff", "--name-only", "HEAD"}, nil + return []string{"diff", "--name-only", "-z", "HEAD"}, nil case ScopeUpstream: - return []string{"diff", "--name-only", "@{upstream}...HEAD"}, nil + return []string{"diff", "--name-only", "-z", "@{upstream}...HEAD"}, nil default: if spec, ok := ScopeRange(scope); ok { if err := ValidateRangeSpec(spec); err != nil { return nil, err } - return []string{"diff", "--name-only", spec}, nil + return []string{"diff", "--name-only", "-z", spec}, nil } return nil, fmt.Errorf("gates: unknown scope %q", scope) } } -// dedupeLines splits git output into trimmed, non-empty, deduplicated lines, -// preserving first-seen order. -func dedupeLines(s string) []string { +// dedupePaths splits NUL-delimited Git paths without unquoting or trimming: +// whitespace, newlines and backslashes are all valid filename bytes. +func dedupePaths(s string) []string { var out []string seen := map[string]bool{} - for _, ln := range strings.Split(s, "\n") { - ln = strings.TrimSpace(ln) + for _, ln := range strings.Split(s, "\x00") { if ln == "" || seen[ln] { continue } diff --git a/cli/internal/gates/changedfiles_test.go b/cli/internal/gates/changedfiles_test.go index 0ac3c86f8..f09999833 100644 --- a/cli/internal/gates/changedfiles_test.go +++ b/cli/internal/gates/changedfiles_test.go @@ -66,7 +66,7 @@ func TestScopeArgs_Range(t *testing.T) { if err != nil { t.Fatalf("scopeArgs range: %v", err) } - want := []string{"diff", "--name-only", "origin/main..HEAD"} + want := []string{"diff", "--name-only", "-z", "origin/main..HEAD"} if !reflect.DeepEqual(args, want) { t.Fatalf("scopeArgs range = %v, want %v", args, want) } @@ -344,3 +344,92 @@ func equalSet(a, b []string) bool { } return true } + +func runDiscoveryGit(t *testing.T, root string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = root + cmd.Env = append(ScrubbedGitEnv(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func TestGitChangedFiles_MergeUsesFirstParent(t *testing.T) { + root, base, _, _ := gitCommits(t) + runDiscoveryGit(t, root, "checkout", "-q", "-b", "side", base) + if err := os.MkdirAll(filepath.Join(root, "scripts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "scripts/new.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + runDiscoveryGit(t, root, "add", "-A") + runDiscoveryGit(t, root, "commit", "-qm", "side script") + runDiscoveryGit(t, root, "checkout", "-q", "-b", "mainline", base) + if err := os.WriteFile(filepath.Join(root, "mainline.txt"), []byte("mainline only\n"), 0o644); err != nil { + t.Fatal(err) + } + runDiscoveryGit(t, root, "add", "-A") + runDiscoveryGit(t, root, "commit", "-qm", "mainline") + runDiscoveryGit(t, root, "merge", "--no-ff", "-m", "merge side", "side") + + got, err := NewGitChangedFiles(root).Changed(context.Background(), ScopeHead) + if err != nil { + t.Fatal(err) + } + if !equalSet(got, []string{"scripts/new.sh"}) { + t.Fatalf("merge head files = %q, want first-parent addition scripts/new.sh", got) + } + if !PathMatchesAny([]string{"**/*.sh"}, got[0]) { + t.Fatal("merge addition did not select the shell gate") + } +} + +func TestGitChangedFiles_RootCommit(t *testing.T) { + root, base, _, _ := gitCommits(t) + runDiscoveryGit(t, root, "checkout", "-q", base) + got, err := NewGitChangedFiles(root).Changed(context.Background(), ScopeHead) + if err != nil || !equalSet(got, []string{"README.md"}) { + t.Fatalf("root head files = %q, error = %v", got, err) + } +} + +func TestGitChangedFiles_PreservesExactPaths(t *testing.T) { + root, _, _, base := gitCommits(t) + want := []string{"scripts/café.sh", "scripts/new\nline.sh", "scripts/tab\tfile.sh", " leading.sh", "trailing.sh ", "scripts/quote\".sh", "scripts/back\\slash.sh"} + for _, rel := range want { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + } + runDiscoveryGit(t, root, "add", "-A") + assertScope := func(scope Scope) { + t.Helper() + got, err := NewGitChangedFiles(root).Changed(context.Background(), scope) + if err != nil || !equalSet(got, want) { + t.Fatalf("scope %s: files = %q, want %q; error = %v", scope, got, want, err) + } + for _, file := range got { + if !PathMatchesAny([]string{file}, file) { + t.Errorf("exact path %q failed routing", file) + } + } + } + assertScope(ScopeStaged) + assertScope(ScopeWorktree) + runDiscoveryGit(t, root, "commit", "-qm", "unusual paths") + assertScope(ScopeHead) + assertScope(Scope(ScopeRangePrefix + base + "..HEAD")) + runDiscoveryGit(t, root, "config", "remote.origin.url", root) + runDiscoveryGit(t, root, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") + runDiscoveryGit(t, root, "update-ref", "refs/remotes/origin/main", base) + runDiscoveryGit(t, root, "branch", "--set-upstream-to", "origin/main") + assertScope(ScopeUpstream) +} diff --git a/cli/internal/gates/checks/constraints.go b/cli/internal/gates/checks/constraints.go index 8a8b65ad0..642e2ba8c 100644 --- a/cli/internal/gates/checks/constraints.go +++ b/cli/internal/gates/checks/constraints.go @@ -49,6 +49,13 @@ func init() { Run: runConstraintEnforceGate, RepairHint: "ao constraint list — fix the change to satisfy the active constraint, or `ao constraint retire ` if the rule is wrong", }) + gates.Register(gates.Check{ + ID: "constraints.shadow", + Tiers: gates.Fast | gates.Full, + Blocking: false, + Run: runConstraintShadowGate, + RepairHint: "review the reported shadow detector observations before activating the constraint", + }) } // constraintSchemaVersion is the only index schema this gate knows how to @@ -62,12 +69,23 @@ const constraintSchemaVersion = 1 var errFailClosed = errors.New("constraint could not be evaluated") func runConstraintEnforceGate(_ context.Context, rc gates.RunContext) (ports.GateVerdict, error) { + return runConstraints(rc, "active"), nil +} + +func runConstraintShadowGate(_ context.Context, rc gates.RunContext) (ports.GateVerdict, error) { + return runConstraints(rc, "shadow"), nil +} + +// Active enforcement and shadow observation are separate registered results. +// The registry retains blocking semantics for malformed indexes, scan failures +// and active evaluation failures; shadow observations cannot stop fail-fast. +func runConstraints(rc gates.RunContext, status string) ports.GateVerdict { idx, missing, parseErr := loadConstraintIndexAt(rc.RepoRoot) if missing { return ports.GateVerdict{ Status: ports.GateStatusPass, Reason: "no constraint index — nothing to enforce", - }, nil + } } if parseErr != nil { // Parse error / unknown schema = gate fail, never skip (EM-ENF acceptance). @@ -75,7 +93,7 @@ func runConstraintEnforceGate(_ context.Context, rc gates.RunContext) (ports.Gat Status: ports.GateStatusFail, Reason: "constraint index malformed — failing closed", LogTail: fmt.Sprintf("%s: %v", constraintindex.ConstraintIndexPath(), parseErr), - }, nil + } } // Candidate file set. Fast mode routes to the changed files; full mode gets @@ -89,41 +107,36 @@ func runConstraintEnforceGate(_ context.Context, rc gates.RunContext) (ports.Gat Status: ports.GateStatusFail, Reason: "could not enumerate repo files for full-mode enforcement — failing closed", LogTail: err.Error(), - }, nil + } } files = walked } var ( - active int - shadow int - violations []string - broken []string - shadowHits []string - shadowBroken []string + evaluated int + violations []string + broken []string ) for i := range idx.Constraints { c := idx.Constraints[i] - if c.Status != "active" && c.Status != "shadow" { + if c.Status != status { continue } + evaluated++ hits, err := evalConstraint(c, rc.RepoRoot, files) - if c.Status == "shadow" { - shadow++ - if err != nil { - shadowBroken = append(shadowBroken, fmt.Sprintf("%s (%s): %v", c.ID, c.Title, err)) - } else { - shadowHits = append(shadowHits, hits...) - } - continue - } - active++ if err != nil { broken = append(broken, fmt.Sprintf("%s (%s): %v", c.ID, c.Title, err)) continue } violations = append(violations, hits...) } + if status == "shadow" && (len(violations) > 0 || len(broken) > 0) { + return ports.GateVerdict{ + Status: ports.GateStatusWarn, + Reason: fmt.Sprintf("%d shadow constraint(s) evaluated in warn-only mode", evaluated), + LogTail: strings.Join(append(broken, violations...), "\n"), + } + } // Fail-closed takes precedence: a constraint we cannot evaluate is a hole in // the membrane, reported even if no clean violation fired. @@ -132,26 +145,19 @@ func runConstraintEnforceGate(_ context.Context, rc gates.RunContext) (ports.Gat Status: ports.GateStatusFail, Reason: fmt.Sprintf("%d active constraint(s) could not be evaluated — failing closed", len(broken)), LogTail: strings.Join(append(broken, violations...), "\n"), - }, nil + } } if len(violations) > 0 { return ports.GateVerdict{ Status: ports.GateStatusFail, Reason: fmt.Sprintf("%d constraint violation(s) in changed files", len(violations)), LogTail: strings.Join(violations, "\n"), - }, nil - } - if len(shadowHits) > 0 || len(shadowBroken) > 0 { - return ports.GateVerdict{ - Status: ports.GateStatusWarn, - Reason: fmt.Sprintf("%d shadow constraint(s) evaluated in warn-only mode", shadow), - LogTail: strings.Join(append(shadowBroken, shadowHits...), "\n"), - }, nil + } } return ports.GateVerdict{ Status: ports.GateStatusPass, - Reason: fmt.Sprintf("%d active constraint(s) enforced and %d shadow constraint(s) observed, no violations", active, shadow), - }, nil + Reason: fmt.Sprintf("%d %s constraint(s) evaluated, no violations", evaluated, status), + } } // loadConstraintIndexAt reads the index relative to root. It separates the three diff --git a/cli/internal/gates/checks/constraints_test.go b/cli/internal/gates/checks/constraints_test.go index 254516177..9bbf47cfe 100644 --- a/cli/internal/gates/checks/constraints_test.go +++ b/cli/internal/gates/checks/constraints_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/boshu2/agentops/cli/internal/gates" @@ -382,3 +383,74 @@ func TestConstraintGate_RegisteredInDefault(t *testing.T) { t.Fatalf("constraints.enforce not registered in gates.Default") } } + +func TestConstraintGate_ShadowDoesNotBlockFailFast(t *testing.T) { + for _, broken := range []bool{false, true} { + index := strings.ReplaceAll(forbidConstraint, `"active"`, `"shadow"`) + if broken { + index = strings.ReplaceAll(index, `"regex"`, `"unsupported"`) + } + rc := writeConstraintFixture(t, index, map[string]string{"cli/a.go": "panic()"}, nil) + reg := gates.NewRegistry() + for _, c := range gates.Default.All() { + if strings.HasPrefix(c.ID, "constraints.") { + if err := reg.Add(c); err != nil { + t.Fatal(err) + } + } + } + continued := false + if err := reg.Add(gates.Check{ID: "zz.after-constraints", Tiers: gates.Full, Blocking: true, + Run: func(context.Context, gates.RunContext) (ports.GateVerdict, error) { + continued = true + return ports.GateVerdict{Status: ports.GateStatusPass}, nil + }}); err != nil { + t.Fatal(err) + } + report, err := gates.NewOrchestrator(reg, nil, nil, rc.RepoRoot).Run(context.Background(), gates.RunOptions{Mode: gates.Full, FailFast: true}) + if err != nil { + t.Fatal(err) + } + if report.ExitCode() != 0 || !continued { + t.Fatalf("shadow broken=%t: exit=%d continued=%t, want advisory success and continued execution", broken, report.ExitCode(), continued) + } + warned := false + for _, result := range report.Results { + if result.Verdict.Status == ports.GateStatusWarn && !result.Check.Blocking && result.Verdict.LogTail != "" { + warned = true + } + } + if !warned { + t.Fatal("shadow observation was lost instead of reported as advisory WARN") + } + } +} + +func TestConstraintGate_ActiveFailuresStillStopFailFast(t *testing.T) { + for name, index := range map[string]string{ + "violation": forbidConstraint, + "evaluation error": strings.ReplaceAll(forbidConstraint, `"regex"`, `"unsupported"`), + "malformed index": `{"schema_version":1,"constraints":null}`, + } { + t.Run(name, func(t *testing.T) { + rc := writeConstraintFixture(t, index, map[string]string{"cli/a.go": "panic()"}, nil) + reg := gates.NewRegistry() + for _, id := range []string{"constraints.enforce", "constraints.shadow"} { + check, ok := gates.Default.Get(id) + if !ok { + t.Fatalf("missing registered check %s", id) + } + if err := reg.Add(check); err != nil { + t.Fatal(err) + } + } + report, err := gates.NewOrchestrator(reg, nil, nil, rc.RepoRoot).Run(context.Background(), gates.RunOptions{Mode: gates.Full, FailFast: true}) + if err != nil { + t.Fatal(err) + } + if report.ExitCode() != 1 || len(report.Results) != 1 || report.Results[0].Check.ID != "constraints.enforce" { + t.Fatalf("active failure did not block and stop fail-fast: %+v", report) + } + }) + } +} diff --git a/cli/internal/gates/checks/native_inline.go b/cli/internal/gates/checks/native_inline.go index dc5f2b2f8..ab4540459 100644 --- a/cli/internal/gates/checks/native_inline.go +++ b/cli/internal/gates/checks/native_inline.go @@ -30,32 +30,19 @@ func init() { RepairHint: "give each reported learning file YAML frontmatter: a leading '---' line at the top"}) } -// changedFilesFor returns the routed change set, falling back to the diff vs -// origin/main when the orchestrator didn't route (Full mode). -// The orchestrator already filtered installed skill copies out of -// rc.ChangedFiles; the origin/main fallback below computes its own set, so it -// applies the same filter — otherwise a Full-mode run would shellcheck the -// installed copies the routed path deliberately excluded. -func changedFilesFor(ctx context.Context, rc gates.RunContext) []string { - if len(rc.ChangedFiles) > 0 { - return rc.ChangedFiles +// changedFilesFor preserves the routed fast scope, including a valid empty +// set. Full mode retains these native checks' origin/main...HEAD change scope +// and propagates discovery failures rather than mistaking them for an empty +// diff. Both modes exclude installed skill copies, which are not repo source. +func changedFilesFor(ctx context.Context, rc gates.RunContext) ([]string, error) { + if rc.Mode != gates.Full { + return gates.FilterInstalledSkillCopies(rc.ChangedFiles), nil } - cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", "origin/main...HEAD") - cmd.Dir = rc.RepoRoot - // Scrub git's hook-injected discovery env (GIT_DIR, ...) so a leaked GIT_DIR - // cannot route this fallback change set at the wrong repo (SECURITY-MED). - cmd.Env = gates.ScrubbedGitEnv() - out, err := cmd.Output() + files, err := gates.NewGitChangedFiles(rc.RepoRoot).Changed(ctx, gates.Scope(gates.ScopeRangePrefix+"origin/main...HEAD")) if err != nil { - return nil + return nil, fmt.Errorf("discover full-mode changed files: %w", err) } - var files []string - for _, l := range strings.Split(string(out), "\n") { - if l = strings.TrimSpace(l); l != "" { - files = append(files, l) - } - } - return gates.FilterInstalledSkillCopies(files) + return gates.FilterInstalledSkillCopies(files), nil } func runGoVet(ctx context.Context, rc gates.RunContext) (ports.GateVerdict, error) { @@ -96,7 +83,11 @@ func runShellcheckChanged(ctx context.Context, rc gates.RunContext) (ports.GateV } var failed []string var logs bytes.Buffer - for _, f := range changedFilesFor(ctx, rc) { + files, err := changedFilesFor(ctx, rc) + if err != nil { + return ports.GateVerdict{}, err + } + for _, f := range files { if !strings.HasSuffix(f, ".sh") { continue } @@ -140,7 +131,11 @@ func underLearningRoot(f string) bool { func runLearningCoherence(ctx context.Context, rc gates.RunContext) (ports.GateVerdict, error) { var missing []string - for _, f := range changedFilesFor(ctx, rc) { + files, err := changedFilesFor(ctx, rc) + if err != nil { + return ports.GateVerdict{}, err + } + for _, f := range files { if !underLearningRoot(f) || !strings.HasSuffix(f, ".md") { continue } diff --git a/cli/internal/gates/checks/native_inline_test.go b/cli/internal/gates/checks/native_inline_test.go index d039c4cff..2e981479a 100644 --- a/cli/internal/gates/checks/native_inline_test.go +++ b/cli/internal/gates/checks/native_inline_test.go @@ -28,6 +28,11 @@ func TestMain(m *testing.M) { // ref at the base commit and a HEAD commit that adds addFile, returning the // repo root. `git diff --name-only origin/main...HEAD` in it yields [addFile]. func initRepoWithOriginMain(t *testing.T, addFile string) string { + t.Helper() + return initRepoWithOriginMainFiles(t, map[string]string{addFile: "x\n"}) +} + +func initRepoWithOriginMainFiles(t *testing.T, files map[string]string) string { t.Helper() root := t.TempDir() run := func(args ...string) string { @@ -58,16 +63,17 @@ func initRepoWithOriginMain(t *testing.T, addFile string) string { run("commit", "-q", "-m", "base") base := run("rev-parse", "HEAD") run("update-ref", "refs/remotes/origin/main", base) - write(addFile, "x\n") + for rel, body := range files { + write(rel, body) + } run("add", "-A") - run("commit", "-q", "-m", "add "+addFile) + run("commit", "-q", "-m", "add files") return root } // TestChangedFilesFor_PollutedGitDirFallbackResolvesCorrectRepo is the -// acceptance for the SECURITY-MED fix on the changedFilesFor fallback path -// (the `git diff origin/main...HEAD` run when the orchestrator did not route): -// a leaked GIT_DIR must not route the fallback change set at the wrong repo. +// acceptance for the SECURITY-MED fix on full-mode change discovery: +// a leaked GIT_DIR must not route the fallback diff at the wrong repo. func TestChangedFilesFor_PollutedGitDirFallbackResolvesCorrectRepo(t *testing.T) { correct := initRepoWithOriginMain(t, "correct.sh") polluted := initRepoWithOriginMain(t, "wrong.sh") @@ -75,7 +81,10 @@ func TestChangedFilesFor_PollutedGitDirFallbackResolvesCorrectRepo(t *testing.T) // Simulate git's hook-injected discovery env pointing at a DIFFERENT repo. t.Setenv("GIT_DIR", filepath.Join(polluted, ".git")) - got := changedFilesFor(context.Background(), gates.RunContext{RepoRoot: correct}) + got, err := changedFilesFor(context.Background(), gates.RunContext{RepoRoot: correct, Mode: gates.Full}) + if err != nil { + t.Fatal(err) + } want := []string{"correct.sh"} if !equalSetChecks(got, want) { t.Fatalf("polluted GIT_DIR routed fallback change set to %v, want %v (from cmd.Dir repo)", got, want) @@ -337,3 +346,109 @@ func TestRunChangelogSync_ReadFailureFailsClosed(t *testing.T) { t.Fatalf("reason = %q, want missing evidence path", verdict.Reason) } } + +func installRecordingShellcheck(t *testing.T) string { + t.Helper() + dir := t.TempDir() + marker := filepath.Join(dir, "arguments") + t.Setenv("SHELLCHECK_TEST_ARGUMENTS", marker) + if err := os.WriteFile(filepath.Join(dir, "shellcheck"), []byte("#!/bin/sh\nprintf '%s\\0' \"$@\" >> \"$SHELLCHECK_TEST_ARGUMENTS\"\nexit 1\n"), 0o755); err != nil { // #nosec G306 -- executable test fixture. + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return marker +} + +func TestNativeChecks_FullModeEvaluatesExactChangedFiles(t *testing.T) { + marker := installRecordingShellcheck(t) + script := "scripts/café\n deploy.sh" + learning := ".agents/ao/learnings/café\n note.md" + root := initRepoWithOriginMainFiles(t, map[string]string{script: shellcheckTriggeringScript, learning: "missing frontmatter\n"}) + rc := gates.RunContext{RepoRoot: root, Mode: gates.Full} + for name, run := range map[string]gates.CheckFunc{"shellcheck": runShellcheckChanged, "learning": runLearningCoherence} { + v, err := run(context.Background(), rc) + if err != nil || v.Status != ports.GateStatusFail { + t.Errorf("%s full check = %+v, error = %v; want evaluated FAIL", name, v, err) + } + } + args, err := os.ReadFile(marker) + if err != nil || !strings.Contains(string(args), script+"\x00") { + t.Errorf("shellcheck did not receive exact filename %q: %q, error = %v", script, args, err) + } +} + +func TestNativeChecks_FullDiscoveryFailureCannotPass(t *testing.T) { + installRecordingShellcheck(t) + for fixture, root := range map[string]string{ + "non-repository": t.TempDir(), + "missing-origin-main": initRepoWithHeadCommit(t, map[string]string{"scripts/bad.sh": shellcheckTriggeringScript}), + } { + for name, run := range map[string]gates.CheckFunc{"shellcheck": runShellcheckChanged, "learning": runLearningCoherence} { + t.Run(fixture+"/"+name, func(t *testing.T) { + v, err := run(context.Background(), gates.RunContext{RepoRoot: root, Mode: gates.Full}) + if err == nil { + t.Fatalf("failed Git discovery must report an evaluation error: %+v", v) + } + report := gates.Report{Results: []gates.CheckResult{{Check: gates.Check{ID: name, Blocking: true}, Verdict: v, Err: err}}} + if report.ExitCode() != 1 { + t.Fatal("discovery failure did not block the run") + } + }) + } + } +} + +func TestNativeChecks_FullModeValidEmptyDiff(t *testing.T) { + marker := installRecordingShellcheck(t) + root := initRepoWithHeadCommit(t, map[string]string{ + "scripts/bad.sh": shellcheckTriggeringScript, + ".agents/ao/learnings/bad.md": "missing frontmatter\n", + }) + cmd := exec.Command("git", "update-ref", "refs/remotes/origin/main", "HEAD") + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("update-ref: %v: %s", err, out) + } + for name, run := range map[string]gates.CheckFunc{"shellcheck": runShellcheckChanged, "learning": runLearningCoherence} { + v, err := run(context.Background(), gates.RunContext{RepoRoot: root, Mode: gates.Full}) + if err != nil || v.Status != ports.GateStatusPass { + t.Errorf("%s valid empty diff = %+v, error = %v", name, v, err) + } + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("shellcheck ran outside the valid empty diff: %v", err) + } +} + +func TestNativeChecks_FastEmptyRoutingStaysEmpty(t *testing.T) { + marker := installRecordingShellcheck(t) + root := initRepoWithOriginMain(t, "scripts/bad.sh") + v, err := runShellcheckChanged(context.Background(), gates.RunContext{RepoRoot: root, Mode: gates.Fast}) + if err != nil || v.Status != ports.GateStatusPass { + t.Fatalf("empty fast scope = %+v, error = %v", v, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("shellcheck ran outside empty fast scope: %v", err) + } +} + +func TestNativeChecks_FullScopeFiltersInstalledCopies(t *testing.T) { + root := initRepoWithOriginMainFiles(t, map[string]string{ + ".gitignore": "ignored.sh\n", + "scripts/tracked.sh": "#!/bin/sh\n", + ".agents/skills/x.sh": shellcheckTriggeringScript, + ".codex/skills/x.sh": shellcheckTriggeringScript, + }) + for _, rel := range []string{"ignored.sh", " untracked\n script.sh"} { + if err := os.WriteFile(filepath.Join(root, rel), []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := changedFilesFor(context.Background(), gates.RunContext{ + RepoRoot: root, Mode: gates.Full, + }) + want := []string{".gitignore", "scripts/tracked.sh"} + if err != nil || !equalSetChecks(got, want) { + t.Fatalf("full scope = %q, want %q; error = %v", got, want, err) + } +} diff --git a/cli/internal/gates/routing_test.go b/cli/internal/gates/routing_test.go index bd0b44bb6..4d731d318 100644 --- a/cli/internal/gates/routing_test.go +++ b/cli/internal/gates/routing_test.go @@ -75,7 +75,7 @@ func TestGitChangedFiles_ScopeMappingAndDedupe(t *testing.T) { RepoRoot: "/repo", run: func(_ context.Context, _ string, args ...string) (string, error) { gotArgs = args - return "cli/a.go\n\ncli/a.go\nscripts/x.sh\n", nil + return "cli/a.go\x00\x00cli/a.go\x00scripts/x.sh\x00", nil }, } changed, err := g.Changed(context.Background(), ScopeUpstream) diff --git a/cli/internal/provenancegraph/decode.go b/cli/internal/provenancegraph/decode.go new file mode 100644 index 000000000..6627fc821 --- /dev/null +++ b/cli/internal/provenancegraph/decode.go @@ -0,0 +1,37 @@ +package provenancegraph + +import ( + "encoding/json" + "fmt" + + "github.com/boshu2/agentops/cli/internal/verdictcheck" +) + +// decodeEdge checks the exact wire fields before Go's struct decoder can hide +// duplicates, unknown keys, missing fields or nulls behind zero values. Legacy +// optional observations remain accepted without changing their payload hashes. +func decodeEdge(payload []byte) (Edge, error) { + raw, err := verdictcheck.DecodeObject(payload) + if err != nil { + return Edge{}, err + } + if err := verdictcheck.ExactFields(raw, []string{ + "schema_version", "from_id", "from_type", "to_id", "to_type", "relation", + "trust_tier", "ts", "prev_hash", "payload_hash", "hash", + }, []string{ + "evidence_ref", "bead_id", "merge_sha", "reviewer_family", "degraded", + "rounds", "duration_s", "tokens_est", "evidence_path", + }); err != nil { + return Edge{}, err + } + for key, value := range raw { + if value == nil { + return Edge{}, fmt.Errorf("field %q must not be null", key) + } + } + var edge Edge + if err := json.Unmarshal(payload, &edge); err != nil { + return Edge{}, err + } + return edge, nil +} diff --git a/cli/internal/provenancegraph/store.go b/cli/internal/provenancegraph/store.go index fd1f679ec..01210d2ac 100644 --- a/cli/internal/provenancegraph/store.go +++ b/cli/internal/provenancegraph/store.go @@ -56,8 +56,8 @@ func DecodeEdges(r io.Reader) ([]Edge, error) { if len(trimSpace(raw)) == 0 { continue } - var e Edge - if err := json.Unmarshal(raw, &e); err != nil { + e, err := decodeEdge(raw) + if err != nil { return nil, fmt.Errorf("ledger line %d: invalid JSON: %w", line, err) } edges = append(edges, e) @@ -206,7 +206,7 @@ func (s *Store) writeLine(e Edge) error { return fmt.Errorf("create ledger dir: %w", err) } } - f, err := os.OpenFile(s.Path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + f, err := os.OpenFile(s.Path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0o644) if err != nil { return fmt.Errorf("open ledger for append: %w", err) } @@ -217,6 +217,21 @@ func (s *Store) writeLine(e Edge) error { return fmt.Errorf("marshal edge: %w", err) } b = append(b, '\n') + info, err := f.Stat() + if err != nil { + return fmt.Errorf("inspect ledger ending: %w", err) + } + if info.Size() > 0 { + var last [1]byte + if _, err := f.ReadAt(last[:], info.Size()-1); err != nil { + return fmt.Errorf("read ledger ending: %w", err) + } + // Read accepts an unterminated final record. Preserve all prior bytes + // and append its missing separator in the same write as the new row. + if last[0] != '\n' { + b = append([]byte{'\n'}, b...) + } + } if _, err := f.Write(b); err != nil { return fmt.Errorf("write edge: %w", err) } diff --git a/cli/internal/provenancegraph/store_test.go b/cli/internal/provenancegraph/store_test.go index 191d91b64..8bfebf5b0 100644 --- a/cli/internal/provenancegraph/store_test.go +++ b/cli/internal/provenancegraph/store_test.go @@ -1,6 +1,7 @@ package provenancegraph import ( + "bytes" "encoding/json" "fmt" "os" @@ -10,6 +11,34 @@ import ( "testing" ) +func TestStore_AppendPreservesUnterminatedRecord(t *testing.T) { + store, path := seedVerifyLedger(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + before = bytes.TrimSuffix(before, []byte{'\n'}) + if err := os.WriteFile(path, before, 0o600); err != nil { + t.Fatal(err) + } + if result, err := store.VerifyFile(); err != nil || !result.Pass { + t.Fatalf("unterminated starting ledger: %+v, %v", result, err) + } + if _, err := store.Append(validEdge()); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(after, append(bytes.Clone(before), '\n')) { + t.Fatal("append changed prior bytes or omitted the record separator") + } + if result, err := store.VerifyFile(); err != nil || !result.Pass || result.RecordCount != 3 { + t.Fatalf("appended ledger: %+v, %v", result, err) + } +} + func TestStore_AppendThenReadRoundTrips(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "docs", "provenance", "ledger.jsonl") diff --git a/cli/internal/provenancegraph/verify.go b/cli/internal/provenancegraph/verify.go index a2600ccc2..cd0d97733 100644 --- a/cli/internal/provenancegraph/verify.go +++ b/cli/internal/provenancegraph/verify.go @@ -2,7 +2,6 @@ package provenancegraph import ( "bufio" - "encoding/json" "fmt" "os" ) @@ -58,8 +57,8 @@ func (s *Store) VerifyFile() (VerifyResult, error) { if len(trimSpace(raw)) == 0 { continue } - var e Edge - if err := json.Unmarshal(raw, &e); err != nil { + e, err := decodeEdge(raw) + if err != nil { return VerifyResult{ Pass: false, RecordCount: count, diff --git a/cli/internal/provenancegraph/verify_test.go b/cli/internal/provenancegraph/verify_test.go index 44026cce3..37f5a0993 100644 --- a/cli/internal/provenancegraph/verify_test.go +++ b/cli/internal/provenancegraph/verify_test.go @@ -46,6 +46,45 @@ func writeLines(t *testing.T, path string, lines []string) { } } +func TestLedgerReaders_RejectAmbiguousOrIncompleteRecords(t *testing.T) { + tests := []struct { + name string + mutate func(string) string + }{ + {"duplicate field", func(s string) string { return `{"from_id":"forged",` + s[1:] }}, + {"unknown field", func(s string) string { return `{"evidence":{"verdict":"PASS"},` + s[1:] }}, + {"wrong field case", func(s string) string { return strings.Replace(s, `"from_id":`, `"FROM_ID":`, 1) }}, + {"missing genesis prev_hash", func(s string) string { return strings.Replace(s, `"prev_hash":"",`, "", 1) }}, + {"null genesis prev_hash", func(s string) string { return strings.Replace(s, `"prev_hash":""`, `"prev_hash":null`, 1) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, path := seedVerifyLedger(t) + lines := readLines(t, path) + lines[0] = tt.mutate(lines[0]) + writeLines(t, path, lines) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result, err := store.VerifyFile() + if err != nil || result.Pass || result.FirstBrokenLine != 1 { + t.Errorf("VerifyFile accepted malformed first record: %+v, %v", result, err) + } + if _, err := DecodeEdges(strings.NewReader(string(before))); err == nil { + t.Error("shared decoder accepted malformed record") + } + if _, err := store.Append(validEdge()); err == nil { + t.Error("append accepted malformed existing record") + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatalf("rejected append changed ledger bytes: %v", err) + } + }) + } +} + // TestVerifyFile_IntactChainPasses is the GREEN baseline: two appended edges // form an intact chain and VerifyFile reports Pass with the right count. func TestVerifyFile_IntactChainPasses(t *testing.T) { diff --git a/cli/internal/scenarioresults/loader_test.go b/cli/internal/scenarioresults/loader_test.go index 77c745466..480cd5803 100644 --- a/cli/internal/scenarioresults/loader_test.go +++ b/cli/internal/scenarioresults/loader_test.go @@ -4,11 +4,38 @@ package scenarioresults import ( + "encoding/json" "os" "path/filepath" "testing" ) +func TestLoad_InvalidJudgedAtCannotReplaceFailure(t *testing.T) { + root := t.TempDir() + stageFixture(t, root, "has-failing.json") + loaded, err := Load(root, true) + if err != nil { + t.Fatal(err) + } + invalid := loaded.Artifact.Results[1] + if invalid.Verdict != VerdictFail { + t.Fatal("fixture must contain a failure") + } + invalid.JudgedAt, invalid.Verdict, invalid.Score = "not-a-time", VerdictPass, 1 + loaded.Artifact.Results = append(loaded.Artifact.Results, invalid) + payload, err := json.Marshal(loaded.Artifact) + if err != nil { + t.Fatal(err) + } + writeArtifactBytes(t, root, payload) + for _, strict := range []bool{false, true} { + result, err := Load(root, strict) + if (err != nil) != strict || result.Status != StatusMalformed || result.Artifact != nil || !result.IsSkip() { + t.Errorf("strict=%v: malformed duplicate supplied evidence: %+v, %v", strict, result, err) + } + } +} + // writeArtifactBytes stages raw JSON bytes at the canonical artifact path. func writeArtifactBytes(t *testing.T, root string, data []byte) { t.Helper() diff --git a/cli/internal/scenarioresults/scenarioresults.go b/cli/internal/scenarioresults/scenarioresults.go index 2771f812f..610201dda 100644 --- a/cli/internal/scenarioresults/scenarioresults.go +++ b/cli/internal/scenarioresults/scenarioresults.go @@ -8,6 +8,7 @@ package scenarioresults import ( "regexp" "strings" + "time" ) // SchemaVersion is the discriminator value for the scenario-results.v1 artifact. @@ -94,5 +95,8 @@ func validateResult(r ScenarioResult) string { if strings.TrimSpace(r.JudgedAt) == "" { return "missing judged_at" } + if _, err := time.Parse(time.RFC3339, r.JudgedAt); err != nil { + return "invalid judged_at: expected RFC3339 timestamp" + } return "" } diff --git a/cli/internal/scenarioresults/writer.go b/cli/internal/scenarioresults/writer.go index 733f77983..5acb8c83b 100644 --- a/cli/internal/scenarioresults/writer.go +++ b/cli/internal/scenarioresults/writer.go @@ -30,6 +30,11 @@ func (w Writer) nowUTC() time.Time { // the same directory are untouched. The merged Artifact is returned. func (w Writer) Append(projectRoot, runID string, iteration int, newResults []ScenarioResult) (*Artifact, error) { path := filepath.Join(projectRoot, filepath.FromSlash(ArtifactRelPath)) + for i, result := range newResults { + if defect := validateResult(result); defect != "" { + return nil, fmt.Errorf("scenario results %s: incoming results[%d] %s", path, i, defect) + } + } existing, err := w.readExisting(path) if err != nil { @@ -62,9 +67,9 @@ func (w Writer) readExisting(path string) ([]ScenarioResult, error) { } return nil, fmt.Errorf("read existing scenario results %s: %w", path, err) } - var prior Artifact - if err := json.Unmarshal(data, &prior); err != nil { - return nil, fmt.Errorf("parse existing scenario results %s: %w", path, err) + prior, err := parseAndValidate(path, data) + if err != nil { + return nil, err } return prior.Results, nil } @@ -99,13 +104,13 @@ func keepLatest(byID map[string]ScenarioResult, r ScenarioResult) { } } -// resultIsNewer reports whether candidate judged at-or-after current. Ties and -// unparseable timestamps favor the candidate (last write wins). +// resultIsNewer reports whether candidate judged at-or-after current. Callers +// validate timestamps before merging; unorderable values never displace a result. func resultIsNewer(candidate, current ScenarioResult) bool { ct, cerr := time.Parse(time.RFC3339, candidate.JudgedAt) pt, perr := time.Parse(time.RFC3339, current.JudgedAt) if cerr != nil || perr != nil { - return true + return false } return !ct.Before(pt) } diff --git a/cli/internal/scenarioresults/writer_test.go b/cli/internal/scenarioresults/writer_test.go new file mode 100644 index 000000000..704aa3220 --- /dev/null +++ b/cli/internal/scenarioresults/writer_test.go @@ -0,0 +1,79 @@ +package scenarioresults + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestWriter_RejectsInvalidJudgedAtWithoutWriting(t *testing.T) { + for _, source := range []string{"incoming", "existing"} { + t.Run(source, func(t *testing.T) { + root := t.TempDir() + stageFixture(t, root, "has-failing.json") + loaded, err := Load(root, true) + if err != nil { + t.Fatal(err) + } + invalid := loaded.Artifact.Results[1] + invalid.JudgedAt, invalid.Verdict, invalid.Score = "not-a-time", VerdictPass, 1 + incoming := []ScenarioResult{invalid} + if source == "existing" { + loaded.Artifact.Results = append(loaded.Artifact.Results, invalid) + payload, err := json.Marshal(loaded.Artifact) + if err != nil { + t.Fatal(err) + } + writeArtifactBytes(t, root, payload) + incoming = nil + } + path := filepath.Join(root, ArtifactRelPath) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := (Writer{}).Append(root, "next-run", 1, incoming); err == nil { + t.Error("writer accepted invalid judged_at") + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatalf("rejection changed the existing artifact: %v", err) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("rejection left a temporary artifact: %v", err) + } + }) + } +} + +func TestWriter_ValidJudgedAtOrdersOffsetsAndFractions(t *testing.T) { + root := t.TempDir() + stageFixture(t, root, "has-failing.json") + loaded, err := Load(root, true) + if err != nil { + t.Fatal(err) + } + failure := loaded.Artifact.Results[1] + failure.JudgedAt = "2026-09-09T12:00:00.5Z" + if _, err := (Writer{}).Append(root, "run", 1, []ScenarioResult{failure}); err != nil { + t.Fatal(err) + } + for _, candidate := range []struct{ timestamp, want string }{ + {"2026-09-09T08:00:00.4-04:00", VerdictFail}, + {"2026-09-09T08:00:00.6-04:00", VerdictPass}, + } { + pass := failure + pass.JudgedAt, pass.Verdict, pass.Score = candidate.timestamp, VerdictPass, 1 + if _, err := (Writer{}).Append(root, "run", 2, []ScenarioResult{pass}); err != nil { + t.Fatal(err) + } + loaded, err := Load(root, true) + if err != nil { + t.Fatal(err) + } + if got := loaded.Artifact.Results[1].Verdict; got != candidate.want { + t.Fatalf("timestamp %s: verdict = %s, want %s", candidate.timestamp, got, candidate.want) + } + } +} diff --git a/cli/internal/sessionapp/handoff_order_test.go b/cli/internal/sessionapp/handoff_order_test.go new file mode 100644 index 000000000..d37448c6f --- /dev/null +++ b/cli/internal/sessionapp/handoff_order_test.go @@ -0,0 +1,52 @@ +package sessionapp + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestRehydrateOrdersFractionalTimestamps(t *testing.T) { + for _, tc := range []struct { + name, firstFraction, secondFraction string + firstRoot, secondRoot, want int + }{ + {"same-root", "9", "99", 0, 0, 1}, + {"newer-legacy", "9", "99", 0, 1, 1}, + {"newer-canonical", "99", "9", 0, 1, 0}, + {"equal-canonical", "9", "90", 0, 1, 0}, + {"sub-nanosecond", "0000000001", "0000000002", 0, 0, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + roots := []string{filepath.Join(dir, ".agents", "ao", "handoff"), filepath.Join(dir, ".agents", "handoff")} + ids := []string{"handoff-20260909T120000." + tc.firstFraction + "Z", "handoff-20260909T120000." + tc.secondFraction + "Z"} + for i, index := range []int{tc.firstRoot, tc.secondRoot} { + if err := os.MkdirAll(roots[index], 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]any{"schema_version": 1, "id": ids[i], "created_at": "2026-09-09T12:00:00Z"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(roots[index], ids[i]+".json"), data, 0o600); err != nil { + t.Fatal(err) + } + } + var out, stderr bytes.Buffer + if err := Rehydrate(RehydrateOptions{JSON: true, Stdout: &out, Stderr: &stderr}); err != nil { + t.Fatal(err) + } + var result storedHandoff + if err := json.Unmarshal(out.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.ID == nil || *result.ID != ids[tc.want] { + t.Fatalf("selected %s, want %s", out.Bytes(), ids[tc.want]) + } + }) + } +} diff --git a/cli/internal/sessionapp/sessionapp.go b/cli/internal/sessionapp/sessionapp.go index 86a3f1ab5..d3acb606d 100644 --- a/cli/internal/sessionapp/sessionapp.go +++ b/cli/internal/sessionapp/sessionapp.go @@ -173,13 +173,14 @@ func Rehydrate(opts RehydrateOptions) error { return nil } -// pickLatestHandoff returns the newest handoff artifact by lexical name order. +// pickLatestHandoff returns the newest handoff by the timestamp in its ID. // Current writers use .agents/ao/handoff; the legacy directory remains a // read-only compatibility source so an upgrade does not strand existing // caller-authored evidence. If the same artifact name exists in both places, // the canonical directory wins. type handoffCandidate struct { name string + order string displayDir string components []string priority int @@ -206,7 +207,7 @@ func pickLatestHandoff(cwd string) (*handoffCandidate, error) { if candidate == nil { continue } - if latest == nil || candidate.name > latest.name || (candidate.name == latest.name && candidate.priority < latest.priority) { + if latest == nil || candidate.order > latest.order || (candidate.order == latest.order && candidate.priority < latest.priority) { if latest != nil { _ = latest.root.Close() } @@ -242,7 +243,7 @@ func pickLatestHandoffInRoot(cwd string, components []string, priority int) (*ha return nil, fmt.Errorf("close handoff root %s: %w", dir, closeErr) } - localName := "" + localName, localOrder := "", "" for _, entry := range entries { name := entry.Name() if !strings.HasPrefix(name, "handoff-") || !strings.HasSuffix(name, ".json") { @@ -258,8 +259,13 @@ func pickLatestHandoffInRoot(cwd string, components []string, priority int) (*ha _ = root.Close() return nil, fmt.Errorf("handoff artifact %s is not a real regular file", path) } - if name > localName { - localName = name + order, err := handoffOrder(name) + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("invalid handoff artifact name %s: %w", path, err) + } + if order > localOrder || (order == localOrder && name > localName) { + localName, localOrder = name, order } } if localName == "" { @@ -268,6 +274,7 @@ func pickLatestHandoffInRoot(cwd string, components []string, priority int) (*ha } return &handoffCandidate{ name: localName, + order: localOrder, displayDir: dir, components: append([]string(nil), components...), priority: priority, @@ -275,6 +282,22 @@ func pickLatestHandoffInRoot(cwd string, components []string, priority int) (*ha }, nil } +// handoffOrder normalizes the UTC ID timestamp for chronological comparison. +// Trimming fractional trailing zeros makes equivalent precisions equal without +// losing sub-nanosecond digits permitted by the handoff schema. +func handoffOrder(name string) (string, error) { + id := strings.TrimSuffix(name, ".json") + if !handoffIDPattern.MatchString(id) { + return "", fmt.Errorf("invalid handoff ID") + } + stamp := strings.TrimSuffix(strings.TrimPrefix(id, "handoff-"), "Z") + seconds, fraction, _ := strings.Cut(stamp, ".") + if _, err := time.Parse("20060102T150405Z", seconds+"Z"); err != nil { + return "", err + } + return seconds + "." + strings.TrimRight(fraction, "0"), nil +} + // requireRealHandoffRoot resolves one configured handoff root without allowing // a symlink or non-directory at any existing component. Component-wise Lstat is // required for the nested canonical root: checking only its leaf would follow diff --git a/docs/contracts/finding-compiler.md b/docs/contracts/finding-compiler.md index 6755d9ede..1c4c2d531 100644 --- a/docs/contracts/finding-compiler.md +++ b/docs/contracts/finding-compiler.md @@ -134,10 +134,13 @@ repository-owned checks instead. ## Applicability Inputs Constraint applicability is resolved from concrete repository files. The Go -`constraints.enforce` check evaluates changed files in fast mode and enumerates -repository files in full mode, then applies `applies_to.path_globs`. Shadow -detector hits or evaluation errors WARN. Active detector hits or evaluation -errors FAIL closed. +`constraints.enforce` and `constraints.shadow` checks evaluate changed files in +fast mode and enumerate repository files in full mode, then apply +`applies_to.path_globs`. Shadow detector hits or evaluation errors produce an +advisory WARN under `constraints.shadow`; they do not fail the run or stop +fail-fast execution. `constraints.enforce` remains blocking: active detector +hits, active evaluation errors, malformed indexes and file enumeration errors +FAIL closed. ## Supported Detector Kinds