diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 01c2af7d..a1125198 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "metareview", "description": "Internal review harness, adversarial gates, and post-merge learning for coding agents", - "version": "0.13.2", + "version": "0.13.3", "source": "./", "author": { "name": "David Sifry" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 413a42aa..7c5707d4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "metareview", - "version": "0.13.2", + "version": "0.13.3", "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, task-done code review, acceptance evidence, PR readiness, and post-merge learning. Packaged releases use bin/metareview; source checkout mode requires Go 1.26+, plus FSM workflow runs (metareview fsm).", "author": { "name": "David Sifry" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index eac09f0d..369105b9 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "metareview", - "version": "0.13.2", + "version": "0.13.3", "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, task-done code review, acceptance evidence, PR readiness, and post-merge learning, plus FSM workflow runs (metareview fsm).", "author": { "name": "David Sifry" diff --git a/CHANGELOG.md b/CHANGELOG.md index 004e8763..47655ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.13.3 - 2026-09-26 + +### Security + +- **The `claude-cli/` and `codex/` judges no longer run inside the repository under review.** Each + judge attempt now starts the model CLI in a fresh, empty, private temp directory, removed after + the call. Before, the CLI inherited metareview's working directory, which is normally the + repository being reviewed, and in headless mode a CLI can still load that repository's + project-level configuration without the interactive trust prompt. Disabling tools does not + switch that configuration off. So reviewing an untrusted checkout, such as a fork's PR branch, + was not isolated from it. The Claude judge also passes `--setting-sources user + --strict-mcp-config`. The Codex judge passes `-c project_doc_max_bytes=0`, which also covers the + escalation path's materialized evidence tree (still honoured as an explicit work dir). If no + private directory can be made, the attempt fails as a transport error and nothing is spawned in + the caller's directory. The directory is created under the user's own cache directory + (`os.UserCacheDir`), not a shared temp dir, because Codex walks up from its working directory to + a `.git` root and loads repo-scoped skills it finds there. There is no temp-dir fallback: without + a usable cache directory the judge attempt fails (the CLI judges need `$HOME` for their OAuth + session anyway). Judge calls also no longer persist sessions: Claude gets + `--no-session-persistence` and Codex `--ephemeral`, so a judge's full prompt and diff are no + longer written into the user's session transcripts. Reported while reviewing #165; plan in #167 + (W0). + ## 0.13.2 - 2026-09-26 ### Fixed diff --git a/docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md b/docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md new file mode 100644 index 00000000..89b00d8d --- /dev/null +++ b/docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md @@ -0,0 +1,739 @@ +# metareview pr-ready context + +Run ID: `mrv-20260927-021621749506000-pr-ready-branch-10d735e5` + +## Git + +- Base: `53ee3962f57c1f972aa532252b0a24cf808f4ffa` +- Head: `1fbc77ca97b93bd71dffa53bd8962b5b1c4cbd6a` +- Branch: `fix/judge-cli-isolation` +- Reviewer input digest: `sha256:f354738402a1c7d90e5a95fa14fcf41da86fa435ff7b08d39bac3fcff248dc45` +- Gate effect: `gate` + +## Context Profile + +- Raw diff bytes: `22585` +- Filtered diff bytes: `22585` +- Risk level: `none` + +## Context Shard Plan + +Not sharded. + +## Review Manifest + +- Manifest verdict: `PASS` +- Source manifest hash: not sharded +- Runtime assessment: static-only; runtime not assessed + +### Source Paths +- CHANGELOG.md +- internal/fsm/cli/deps.go +- internal/fsm/judge/claude.go +- internal/fsm/judge/codex.go +- internal/fsm/judge/codex_test.go +- internal/fsm/judge/isolate.go +- internal/fsm/judge/isolation_test.go + +### Manifest Blockers +No manifest blockers. + +## Changed Files + +- CHANGELOG.md +- internal/fsm/cli/deps.go +- internal/fsm/judge/claude.go +- internal/fsm/judge/codex.go +- internal/fsm/judge/codex_test.go +- internal/fsm/judge/isolate.go +- internal/fsm/judge/isolation_test.go + +## Diff + +```diff +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 004e876..5881e87 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -1,5 +1,27 @@ + # Changelog + ++## Unreleased ++ ++### Security ++ ++- **The `claude-cli/` and `codex/` judges no longer run inside the repository under review.** Each ++ judge attempt now starts the model CLI in a fresh, empty, private temp directory, removed after ++ the call. Before, the CLI inherited metareview's working directory, which is normally the ++ repository being reviewed, and in headless mode a CLI can still load that repository's ++ project-level configuration without the interactive trust prompt. Disabling tools does not ++ switch that configuration off. So reviewing an untrusted checkout, such as a fork's PR branch, ++ was not isolated from it. The Claude judge also passes `--setting-sources user ++ --strict-mcp-config`. The Codex judge passes `-c project_doc_max_bytes=0`, which also covers the ++ escalation path's materialized evidence tree (still honoured as an explicit work dir). If no ++ private directory can be made, the attempt fails as a transport error and nothing is spawned in ++ the caller's directory. The directory is created under the user's own cache directory ++ (`os.UserCacheDir`), not a shared temp dir, because Codex walks up from its working directory to ++ a `.git` root and loads repo-scoped skills it finds there; the temp dir is only a fallback when ++ there is no cache directory. Judge calls also no longer persist sessions: Claude gets ++ `--no-session-persistence` and Codex `--ephemeral`, so a judge's full prompt and diff are no ++ longer written into the user's session transcripts. Reported while reviewing #165; plan in #167 ++ (W0). ++ + ## 0.13.2 - 2026-09-26 + + ### Fixed +diff --git a/internal/fsm/cli/deps.go b/internal/fsm/cli/deps.go +index 5bdc1ec..41ad14b 100644 +--- a/internal/fsm/cli/deps.go ++++ b/internal/fsm/cli/deps.go +@@ -134,9 +134,9 @@ func realClaudeExec(ctx context.Context, dir string, args []string, stdin string + // metareview never handles the token itself. + func realCodexExec(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { + cmd := exec.CommandContext(ctx, codexBin, args...) +- // Empty means inherit, which is metareview's own repository. A caller that has materialized +- // an evidence tree passes it here so that tree is what relative paths resolve against. This +- // does not stop the CLI reading elsewhere - cmd.Dir is a starting point, not a jail. ++ // The judge always passes a directory: a fresh isolated one (judge.isolatedDir), or a ++ // materialized evidence tree a caller confined it to. This does not stop the CLI reading ++ // elsewhere - cmd.Dir is a starting point, not a jail. + cmd.Dir = dir + cmd.Stdin = strings.NewReader(stdin) + var out bytes.Buffer +diff --git a/internal/fsm/judge/claude.go b/internal/fsm/judge/claude.go +index e20beaf..5627542 100644 +--- a/internal/fsm/judge/claude.go ++++ b/internal/fsm/judge/claude.go +@@ -79,6 +79,15 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error + // remains a denial rather than a hang in a headless run. + "--disallowed-tools", "*", + "--permission-mode", "dontAsk", ++ // Behind the isolated working directory (isolatedDir): load the user's settings only, ++ // never a project's or a local override, and no MCP server that is not passed explicitly ++ // (none is). --bare would also skip hooks but skips the keychain, and with it the OAuth ++ // session this transport exists to use. ++ "--setting-sources", "user", ++ "--strict-mcp-config", ++ // A judge call is not a session: nothing to resume, and with a fresh directory per ++ // attempt a saved transcript would leave one project directory behind per call. ++ "--no-session-persistence", + // The system prompt must always be passed. Without it `claude -p` can + // silently fall back to Haiku for the work turn even with --model set — + // the judge would then be a different model than the one recorded in the +@@ -105,9 +114,17 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error + case <-j.clock.After(backoff(classBackoff, attempt-1)): + } + } +- actx, cancel := context.WithTimeout(ctx, j.timeout()) +- stdout, code, execErr := j.exec(actx, "", args, user) +- cancel() ++ dir, cleanup, dirErr := isolatedDir() ++ if dirErr != nil { ++ lastErr = errs.E(CodeJudgeTransport, "claude could not be given an isolated working directory: "+dirErr.Error(), "provider", "claude-cli") ++ continue ++ } ++ stdout, code, execErr := func() ([]byte, int, error) { ++ defer cleanup() // deferred: a panic in the seam must not leave the directory behind ++ actx, cancel := context.WithTimeout(ctx, j.timeout()) ++ defer cancel() ++ return j.exec(actx, dir, args, user) ++ }() + + text, tokens, found, transient := parseClaudeResult(stdout) + v.Tokens = v.Tokens.Add(tokens) +diff --git a/internal/fsm/judge/codex.go b/internal/fsm/judge/codex.go +index a398d92..dd88fda 100644 +--- a/internal/fsm/judge/codex.go ++++ b/internal/fsm/judge/codex.go +@@ -38,7 +38,7 @@ type codexJudge struct { + exec CodexExec + nonce func() string + clock Clock +- workDir string // empty: inherit the caller's directory ++ workDir string // empty: a fresh isolated directory per attempt (isolatedDir) + attemptTimeout time.Duration // zero: the AttemptTimeout default + } + +@@ -66,11 +66,16 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) + + args := []string{ + "exec", "--json", ++ "--ephemeral", // a judge call is not a session: persist nothing (one fresh dir per attempt) + "--sandbox", "read-only", // a judge reads; it must never edit the tree it is judging + "--skip-git-repo-check", // judging is not tied to a repository + "--color", "never", + "-m", wireModel(r.Model), + "-c", "model_reasoning_effort=" + r.Effort, ++ // AGENTS.md is read from the working directory up and would be spliced into the judge's ++ // instructions. The default directory is isolated and empty, but an explicit work dir is a ++ // materialized copy of the change under review, which can carry one: project docs off. ++ "-c", "project_doc_max_bytes=0", + "-", // the prompt arrives on stdin, never as an argv the process table would show + } + // The same attempt ceiling, per-attempt deadline and backoff as the HTTP arm. +@@ -98,9 +103,20 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) + case <-j.clock.After(backoff(classBackoff, attempt-1)): + } + } +- actx, cancel := context.WithTimeout(ctx, j.timeout()) +- stdout, code, execErr := j.exec(actx, j.workDir, args, prompt) +- cancel() ++ dir, cleanup := j.workDir, func() {} ++ if dir == "" { ++ var dirErr error ++ if dir, cleanup, dirErr = isolatedDir(); dirErr != nil { ++ lastErr = errs.E(CodeJudgeTransport, "codex could not be given an isolated working directory: "+dirErr.Error(), "provider", "codex") ++ continue ++ } ++ } ++ stdout, code, execErr := func() ([]byte, int, error) { ++ defer cleanup() // deferred: a panic in the seam must not leave the directory behind ++ actx, cancel := context.WithTimeout(ctx, j.timeout()) ++ defer cancel() ++ return j.exec(actx, dir, args, prompt) ++ }() + + text, tokens, found := parseCodexEvents(stdout) + v.Tokens = v.Tokens.Add(tokens) +diff --git a/internal/fsm/judge/codex_test.go b/internal/fsm/judge/codex_test.go +index c99dd41..1396601 100644 +--- a/internal/fsm/judge/codex_test.go ++++ b/internal/fsm/judge/codex_test.go +@@ -440,8 +440,10 @@ func TestCodexJudgeRunsInItsConfiguredWorkDir(t *testing.T) { + } + } + +-// Unset means "inherit", which is today's behaviour: the seam must not invent a directory. +-func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { ++// Unset no longer means "inherit": the caller's directory is the repository under review, whose ++// AGENTS.md a headless codex would splice into the judge's instructions. Unset means a fresh, ++// empty, isolated directory (see isolation_test.go for the full contract). ++func TestCodexJudgeWithNoWorkDirIsIsolated(t *testing.T) { + var gotDir = "unset" + f := func(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { + gotDir = dir +@@ -451,8 +453,8 @@ func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { + if _, err := j.Call(context.Background(), codexRequest()); err != nil { + t.Fatalf("call: %v", err) + } +- if gotDir != "" { +- t.Errorf("exec dir = %q, want empty when unconfigured", gotDir) ++ if gotDir == "" || gotDir == "unset" || !strings.Contains(gotDir, "metareview-judge-") { ++ t.Errorf("exec dir = %q, want a fresh isolated directory when unconfigured", gotDir) + } + } + +@@ -481,8 +483,8 @@ func TestWithCodexWorkDirConfinesOnlyCodexJudges(t *testing.T) { + if _, err := base.Call(context.Background(), codexRequest()); err != nil { + t.Fatalf("base call: %v", err) + } +- if gotDir != "" { +- t.Errorf("confining one judge changed the original, which ran in %q", gotDir) ++ if gotDir == "/evidence" || !strings.Contains(gotDir, "metareview-judge-") { ++ t.Errorf("confining one judge changed the original, which ran in %q (want its own isolated dir)", gotDir) + } + + // a judge that is not the HTTP/codex router is returned as-is +diff --git a/internal/fsm/judge/isolate.go b/internal/fsm/judge/isolate.go +new file mode 100644 +index 0000000..1d39ebf +--- /dev/null ++++ b/internal/fsm/judge/isolate.go +@@ -0,0 +1,35 @@ ++package judge ++ ++import ( ++ "os" ++ "path/filepath" ++) ++ ++// userCacheDir is os.UserCacheDir, a seam so the fallback paths are testable. ++var userCacheDir = os.UserCacheDir ++ ++// isolatedDir makes the private, empty working directory a judge CLI runs in, and returns its ++// cleanup. A headless CLI still loads the project configuration of the directory it starts in ++// (Claude Code's .claude/settings.json hooks and CLAUDE.md; Codex's AGENTS.md), and neither ++// disabling tools nor a read-only sandbox switches that off. Starting inside the repository under ++// review would let that repository run code, or rewrite the judge's instructions, before the ++// judge reads a byte of the prompt. The prompt arrives on stdin, so the CLI needs no files of the ++// repository at all. A var so the failure path is testable. ++// ++// The directory lives under the user's own cache directory, not a shared temp dir: Codex walks up ++// from its working directory to a .git root and loads the repo-scoped skills it finds there, so ++// under a world-writable /tmp another local user could plant both. The temp dir is only the ++// fallback when there is no usable cache directory (no $HOME). ++var isolatedDir = func() (string, func(), error) { ++ base := os.TempDir() ++ if cache, err := userCacheDir(); err == nil { ++ if b := filepath.Join(cache, "metareview", "judge"); os.MkdirAll(b, 0o700) == nil { ++ base = b ++ } ++ } ++ dir, err := os.MkdirTemp(base, "metareview-judge-") ++ if err != nil { ++ return "", func() {}, err ++ } ++ return dir, func() { _ = os.RemoveAll(dir) }, nil ++} +diff --git a/internal/fsm/judge/isolation_test.go b/internal/fsm/judge/isolation_test.go +new file mode 100644 +index 0000000..59fa04a +--- /dev/null ++++ b/internal/fsm/judge/isolation_test.go +@@ -0,0 +1,285 @@ ++package judge ++ ++import ( ++ "context" ++ "errors" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ "github.com/dsifry/metareview/internal/fsm/errs" ++) ++ ++// dirProbe is a CLI exec seam that records the working directory each attempt was given and what ++// that directory held at the moment the CLI would have started. ++type dirProbe struct { ++ dirs []string ++ contents [][]string ++ stdout string ++ code int ++} ++ ++func (p *dirProbe) exec(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { ++ p.dirs = append(p.dirs, dir) ++ entries, err := os.ReadDir(dir) ++ names := []string{} ++ if err != nil { ++ names = append(names, "ERR: "+err.Error()) ++ } ++ for _, e := range entries { ++ names = append(names, e.Name()) ++ } ++ p.contents = append(p.contents, names) ++ return []byte(p.stdout), p.code, nil ++} ++ ++// The judge CLIs must never start inside the repository under review. A headless CLI still loads ++// that directory's project configuration (Claude Code's .claude/settings.json hooks and ++// CLAUDE.md, Codex's AGENTS.md), without the interactive trust prompt, and disabling tools does not ++// switch that off. So every attempt runs in a fresh, empty, private directory that is removed after. ++func assertIsolated(t *testing.T, p *dirProbe, wantAttempts int) { ++ t.Helper() ++ if len(p.dirs) != wantAttempts { ++ t.Fatalf("attempts: got %d want %d", len(p.dirs), wantAttempts) ++ } ++ cwd, err := os.Getwd() ++ if err != nil { ++ t.Fatal(err) ++ } ++ for i, dir := range p.dirs { ++ if dir == "" || !filepath.IsAbs(dir) { ++ t.Fatalf("attempt %d ran in %q: an empty dir inherits the caller's cwd (the reviewed repo)", i+1, dir) ++ } ++ if rel, err := filepath.Rel(cwd, dir); err == nil && !strings.HasPrefix(rel, "..") { ++ t.Fatalf("attempt %d ran inside the caller's tree: %s", i+1, dir) ++ } ++ if len(p.contents[i]) != 0 { ++ t.Fatalf("attempt %d's directory was not empty: %v", i+1, p.contents[i]) ++ } ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("attempt %d's directory %s was not removed (stat: %v)", i+1, dir, err) ++ } ++ } ++} ++ ++func TestClaudeJudgeRunsIsolatedFromTheReviewedRepo(t *testing.T) { ++ p := &dirProbe{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ f := &fakeClaude{stdout: p.stdout} ++ j := &claudeJudge{exec: func(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { ++ f.args = args ++ return p.exec(ctx, dir, args, stdin) ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), claudeRequest()); err != nil { ++ t.Fatal(err) ++ } ++ assertIsolated(t, p, 1) ++ joined := strings.Join(f.args, " ") ++ // Defence in depth behind the empty directory: user settings only (never a project's or a ++ // local override), and no MCP server that is not passed explicitly (none is). ++ for _, want := range []string{"--setting-sources user", "--strict-mcp-config"} { ++ if !strings.Contains(joined, want) { ++ t.Fatalf("args %q missing %q", joined, want) ++ } ++ } ++} ++ ++// Every retry gets its own fresh directory, and none is left behind. ++func TestClaudeJudgeIsolatesEveryAttempt(t *testing.T) { ++ p := &dirProbe{code: 1} ++ j := &claudeJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), claudeRequest()); err == nil { ++ t.Fatal("a CLI that always exits 1 must fail the call") ++ } ++ assertIsolated(t, p, MaxAttempts) ++ seen := map[string]bool{} ++ for _, d := range p.dirs { ++ if seen[d] { ++ t.Fatalf("attempts shared a directory: %s", d) ++ } ++ seen[d] = true ++ } ++} ++ ++func TestCodexJudgeRunsIsolatedByDefault(t *testing.T) { ++ var args []string ++ p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ j := &codexJudge{exec: func(ctx context.Context, dir string, a []string, stdin string) ([]byte, int, error) { ++ args = a ++ return p.exec(ctx, dir, a, stdin) ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ assertIsolated(t, p, 1) ++ // AGENTS.md is read from the working directory up, including from a materialized evidence tree, ++ // which can carry one from the change under review: project docs are off entirely. ++ if !strings.Contains(strings.Join(args, " "), "-c project_doc_max_bytes=0") { ++ t.Fatalf("args %q must disable project docs", args) ++ } ++} ++ ++// An explicit work dir (the escalation path's materialized evidence tree) is honoured as-is and ++// never deleted by the judge. ++func TestCodexJudgeHonoursAnExplicitWorkDir(t *testing.T) { ++ tree := t.TempDir() ++ if err := os.WriteFile(filepath.Join(tree, "a.go"), []byte("package a\n"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ j := &codexJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock(), workDir: tree} ++ if _, err := j.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if len(p.dirs) != 1 || p.dirs[0] != tree { ++ t.Fatalf("explicit work dir not used: %v", p.dirs) ++ } ++ if _, err := os.Stat(filepath.Join(tree, "a.go")); err != nil { ++ t.Fatalf("the judge must not remove a caller's tree: %v", err) ++ } ++} ++ ++// If no private directory can be made, nothing is spawned in the caller's directory as a fallback: ++// the call fails as a transport error, which the ladder retries and the run records. ++func TestJudgesRefuseToRunWithoutAnIsolatedDir(t *testing.T) { ++ saved := isolatedDir ++ t.Cleanup(func() { isolatedDir = saved }) ++ isolatedDir = func() (string, func(), error) { return "", func() {}, errors.New("disk full") } ++ ++ calls := 0 ++ spy := func(context.Context, string, []string, string) ([]byte, int, error) { ++ calls++ ++ return nil, 0, nil ++ } ++ cj := &claudeJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} ++ _, cerr := cj.Call(context.Background(), claudeRequest()) ++ xj := &codexJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} ++ _, xerr := xj.Call(context.Background(), codexRequest()) ++ for name, err := range map[string]error{"claude": cerr, "codex": xerr} { ++ var e *errs.Error ++ if !errors.As(err, &e) || e.Code != CodeJudgeTransport || !strings.Contains(e.Detail, "isolated") { ++ t.Fatalf("%s: want an isolated-dir transport error, got %v", name, err) ++ } ++ } ++ if calls != 0 { ++ t.Fatalf("a CLI was spawned without an isolated directory (%d calls)", calls) ++ } ++} ++ ++// The real directory maker: private (0700), empty, under the user's own cache directory (not a ++// shared temp dir, where another local user could plant a .git root and repo-scoped skills a CLI ++// walks up to), and removed by its cleanup. ++func TestIsolatedDir(t *testing.T) { ++ cache := t.TempDir() ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ userCacheDir = func() (string, error) { return cache, nil } ++ ++ dir, cleanup, err := isolatedDir() ++ if err != nil { ++ t.Fatal(err) ++ } ++ info, err := os.Stat(dir) ++ if err != nil || !info.IsDir() || info.Mode().Perm() != 0o700 { ++ t.Fatalf("stat %s: %v %v", dir, info, err) ++ } ++ if want := filepath.Join(cache, "metareview", "judge") + string(filepath.Separator); !strings.HasPrefix(dir, want) { ++ t.Fatalf("dir %s is not under the user cache base %s", dir, want) ++ } ++ base, err := os.Stat(filepath.Join(cache, "metareview", "judge")) ++ if err != nil || base.Mode().Perm() != 0o700 { ++ t.Fatalf("the base must be private: %v %v", base, err) ++ } ++ cleanup() ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("cleanup left %s", dir) ++ } ++} ++ ++// No user cache directory (HOME unset), or one that cannot hold the base: fall back to the temp ++// directory rather than refuse to judge. ++func TestIsolatedDirFallsBackToTheTempDir(t *testing.T) { ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ tmp := t.TempDir() ++ t.Setenv("TMPDIR", tmp) ++ blocked := filepath.Join(t.TempDir(), "file") ++ if err := os.WriteFile(blocked, nil, 0o600); err != nil { ++ t.Fatal(err) ++ } ++ for name, cacheDir := range map[string]func() (string, error){ ++ "no cache dir": func() (string, error) { return "", errors.New("$HOME is not defined") }, ++ "unusable cache": func() (string, error) { return blocked, nil }, // a file: MkdirAll fails ++ } { ++ userCacheDir = cacheDir ++ dir, cleanup, err := isolatedDir() ++ if err != nil || !strings.HasPrefix(dir, tmp) { ++ t.Fatalf("%s: want a dir under %s, got %q %v", name, tmp, dir, err) ++ } ++ cleanup() ++ } ++} ++ ++// With no usable directory anywhere the maker reports the error rather than returning a directory ++// the CLI would then be run in. ++func TestIsolatedDirReportsAnUnusableTempDir(t *testing.T) { ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ userCacheDir = func() (string, error) { return "", errors.New("no home") } ++ t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "does-not-exist")) ++ dir, cleanup, err := isolatedDir() ++ if err == nil || dir != "" { ++ t.Fatalf("want an error and no dir, got %q %v", dir, err) ++ } ++ cleanup() // a no-op, but always safe to call ++} ++ ++// A panic inside the CLI seam still removes the attempt's directory. ++func TestJudgesRemoveTheDirEvenOnPanic(t *testing.T) { ++ for name, call := range map[string]func(ClaudeExec){ ++ "claude": func(e ClaudeExec) { ++ _, _ = (&claudeJudge{exec: e, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()) ++ }, ++ "codex": func(e ClaudeExec) { ++ _, _ = (&codexJudge{exec: CodexExec(e), nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), codexRequest()) ++ }, ++ } { ++ var dir string ++ func() { ++ defer func() { _ = recover() }() ++ call(func(_ context.Context, d string, _ []string, _ string) ([]byte, int, error) { ++ dir = d ++ panic("boom") ++ }) ++ }() ++ if dir == "" { ++ t.Fatalf("%s: the seam was never called", name) ++ } ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("%s: a panic left %s behind", name, dir) ++ } ++ } ++} ++ ++// Judge calls are not sessions: nothing is persisted (and with a fresh directory per attempt, ++// persisting would leave one transcript directory behind per call). ++func TestJudgesPersistNoSession(t *testing.T) { ++ f := &fakeClaude{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ if _, err := (&claudeJudge{exec: f.exec, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(strings.Join(f.args, " "), "--no-session-persistence") { ++ t.Fatalf("claude args %q", f.args) ++ } ++ var args []string ++ x := &codexJudge{exec: func(_ context.Context, _ string, a []string, _ string) ([]byte, int, error) { ++ args = a ++ return []byte(codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)), 0, nil ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := x.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(strings.Join(args, " "), "--ephemeral") { ++ t.Fatalf("codex args %q", args) ++ } ++} + + + +``` + +## Review Logs + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-task-done-reviewhelp-test-b88768c5.md) +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-task-done-docs-0-6-a2ac175a.md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-task-done-docs-fsm-result-contract-cbf40d94.md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-task-done-t0-1-finding-identity-rewire-01ca0bf9.md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-task-done-t1-3-pins-proven-gate-pr-a-51440dbf.md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-task-done-quickstart-f3ea78a9.md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-task-done-2026-05-27-artifact-review-parallel-subagents-cf4f2168.md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-task-done-2026-05-27-fail-closed-artifact-review-gates-5243a194.md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-task-done-2026-07-05-issue-2-wu4-review-manifest-shard-agg-b924a5ff.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-task-done-2026-09-23-mutation-incremental-1c-proof-380f704d.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-task-done-2026-09-23-mutation-incremental-2a-gate-ed03c179.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-task-done-2026-09-23-mutation-incremental-2b-views-6f8a9d6c.md) +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-task-done-m0-fsm-run-persistence-8a9eddc9.md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-task-done-m1-m6-fsm-packages-a99c72f1.md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-task-done-m7-fork-record-export-9dc62119.md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-task-done-m8-cli-suite-docs-46a961bc.md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-task-done-pr-a-sharded-measurement-8215c1f2.md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-task-done-pr-b-shard-ingestion-f6a50afe.md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-task-done-pr-b-slice-3-e224e5b6.md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-task-done-pr-b-slices-1-2-ce580d87.md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-task-done-process-overrides-93d185e3.md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-task-done-fix-node-elicits-proofs-e988c555.md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-task-done-fix-pin-id-finding-consistency-b6a37c98.md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-task-done-fix-proof-binds-fix-diff-3ad76b46.md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-task-done-github-reviewdecision-prready-fix-3de6a2de.md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-task-done-gitcontext-9e14bb40.md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-task-done-githubcontext-37fa1398.md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-task-done-review-c809e786.md) +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-task-done-epicready-27fabbd2.md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-task-done-typescript-4dea666f.md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-task-done-issue-2-wu1-a2da3dbe.md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-task-done-issue-2-wu1-version-0-6-69ccbe27.md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-task-done-issue-2-wu2-eb5dad78.md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-task-done-issue-2-wu2-cursor-followup-fix-e87ecaa9.md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-task-done-issue-2-wu2-prready-filter-fix-d4005ab8.md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-task-done-issue-2-wu2-review-fix-05b5e486.md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-task-done-issue-2-wu3-2688b027.md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-task-done-mechanical-precision-lens-c79c1389.md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-task-done-multilang-pytest-conv-1f2195e7.md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-task-done-multilang-testconv-seam-01cc5b3d.md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-task-done-multilang-typescript-conv-6cdb50c2.md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-task-done-multilang-vitest-conv-56254690.md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-task-done-runchain-headsha-escalation-reset-37b22cb1.md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-task-done-runchain-reset-findings-reconciliation-6cafdbd1.md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-task-done-sdlc-loop-clean-510ba162.md) + +## Knowledge And Registries + +Service inventory: none + +No service inventory found. + +Knowledge facts: + +No Beads knowledge facts found. + +## Validation Evidence + +{"schemaVersion":1,"kind":"validation","command":["go","test","./..."],"cwd":".","exitCode":0,"startedAt":"2026-09-27T02:15:52.722131Z","finishedAt":"2026-09-27T02:16:21.494406Z","stdoutSha256":"a7d02b15bcf32c8718d19818059ae5211cec37586e181a74379066c5115ae83b","stderrSha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","summary":"go test ./... exited 0"} + +## GitHub Context + +GitHub context unavailable: pr-number-unavailable + + +## Suggested PR Evidence + +## metareview PR Evidence + +### Summary + +fix/judge-cli-isolation changes CHANGELOG.md, internal/fsm/cli/deps.go, internal/fsm/judge/claude.go, internal/fsm/judge/codex.go, internal/fsm/judge/codex_test.go, internal/fsm/judge/isolate.go, internal/fsm/judge/isolation_test.go + +### Validation + +- structured validation: go test ./... exited 0 (exit 0) + +### Task Review Evidence + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-ta[REDACTED].md) findings: mrvf-20260924-062613603963000-ta[REDACTED] +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-ta[REDACTED].md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-ta[REDACTED].md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-ta[REDACTED].md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-ta[REDACTED].md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-ta[REDACTED].md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-ta[REDACTED].md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-ta[REDACTED].md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-ta[REDACTED].md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-ta[REDACTED].md) findings: mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-ta[REDACTED].md) findings: mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-ta[REDACTED].md) findings: mrvf-20260924-051035853973000-ta[REDACTED] +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-ta[REDACTED].md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-ta[REDACTED].md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-ta[REDACTED].md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-ta[REDACTED].md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-ta[REDACTED].md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-ta[REDACTED].md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-ta[REDACTED].md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-ta[REDACTED].md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-ta[REDACTED].md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-ta[REDACTED].md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-ta[REDACTED].md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-ta[REDACTED].md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-ta[REDACTED].md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) findings: mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151538496642000-ta[REDACTED] +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-ta[REDACTED].md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-ta[REDACTED].md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-ta[REDACTED].md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-ta[REDACTED].md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-ta[REDACTED].md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-ta[REDACTED].md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-ta[REDACTED].md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-ta[REDACTED].md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-ta[REDACTED].md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-ta[REDACTED].md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-ta[REDACTED].md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-ta[REDACTED].md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-ta[REDACTED].md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-ta[REDACTED].md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-ta[REDACTED].md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-ta[REDACTED].md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-ta[REDACTED].md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-ta[REDACTED].md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-ta[REDACTED].md) + +### Epic Review Evidence + +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-ta[REDACTED].md) + +### Recorded-Evidence Blocker Status + +_Blockers carried forward from recorded review evidence; findings this gate run raised are under `## Blocking Findings` above._ + +No unresolved blockers in the recorded review evidence. + +### External GitHub Review Context + +GitHub context unavailable: pr-number-unavailable + diff --git a/docs/metareview/context/mrv-20260927-023857249886000-pr-ready-branch-10d735e5-context.md b/docs/metareview/context/mrv-20260927-023857249886000-pr-ready-branch-10d735e5-context.md new file mode 100644 index 00000000..83e32dbb --- /dev/null +++ b/docs/metareview/context/mrv-20260927-023857249886000-pr-ready-branch-10d735e5-context.md @@ -0,0 +1,812 @@ +# metareview pr-ready context + +Run ID: `mrv-20260927-023857249886000-pr-ready-branch-10d735e5` + +## Git + +- Base: `53ee3962f57c1f972aa532252b0a24cf808f4ffa` +- Head: `a53c0a2297c37862520414a4227a5dd6829cddc5` +- Branch: `fix/judge-cli-isolation` +- Reviewer input digest: `sha256:22598681355a4d6c01b56981c362491a673caa4c617d51213d59f8c012d405a0` +- Gate effect: `gate` + +## Context Profile + +- Raw diff bytes: `24891` +- Filtered diff bytes: `24891` +- Risk level: `none` +- Generated files excluded: docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md, docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md + +## Context Shard Plan + +Not sharded. + +## Review Manifest + +- Manifest verdict: `PASS` +- Source manifest hash: not sharded +- Runtime assessment: static-only; runtime not assessed + +### Source Paths +- .claude-plugin/marketplace.json +- .claude-plugin/plugin.json +- .codex-plugin/plugin.json +- CHANGELOG.md +- internal/fsm/cli/deps.go +- internal/fsm/judge/claude.go +- internal/fsm/judge/codex.go +- internal/fsm/judge/codex_test.go +- internal/fsm/judge/isolate.go +- internal/fsm/judge/isolation_test.go +- internal/version/version.go +- package.json + +### Path Dispositions +- docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md: generated (metareview generated review artifact excluded from source manifest) +- docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md: generated (metareview generated review artifact excluded from source manifest) + +### Manifest Blockers +No manifest blockers. + +## Changed Files + +- .claude-plugin/marketplace.json +- .claude-plugin/plugin.json +- .codex-plugin/plugin.json +- CHANGELOG.md +- internal/fsm/cli/deps.go +- internal/fsm/judge/claude.go +- internal/fsm/judge/codex.go +- internal/fsm/judge/codex_test.go +- internal/fsm/judge/isolate.go +- internal/fsm/judge/isolation_test.go +- internal/version/version.go +- package.json + +## Diff + +```diff +diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json +index 01c2af7..a112519 100644 +--- a/.claude-plugin/marketplace.json ++++ b/.claude-plugin/marketplace.json +@@ -8,7 +8,7 @@ + { + "name": "metareview", + "description": "Internal review harness, adversarial gates, and post-merge learning for coding agents", +- "version": "0.13.2", ++ "version": "0.13.3", + "source": "./", + "author": { + "name": "David Sifry" +diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json +index 413a42a..7c5707d 100644 +--- a/.claude-plugin/plugin.json ++++ b/.claude-plugin/plugin.json +@@ -1,6 +1,6 @@ + { + "name": "metareview", +- "version": "0.13.2", ++ "version": "0.13.3", + "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, task-done code review, acceptance evidence, PR readiness, and post-merge learning. Packaged releases use bin/metareview; source checkout mode requires Go 1.26+, plus FSM workflow runs (metareview fsm).", + "author": { + "name": "David Sifry" +diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json +index eac09f0..369105b 100644 +--- a/.codex-plugin/plugin.json ++++ b/.codex-plugin/plugin.json +@@ -1,6 +1,6 @@ + { + "name": "metareview", +- "version": "0.13.2", ++ "version": "0.13.3", + "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, task-done code review, acceptance evidence, PR readiness, and post-merge learning, plus FSM workflow runs (metareview fsm).", + "author": { + "name": "David Sifry" +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 004e876..dd981d0 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -1,5 +1,27 @@ + # Changelog + ++## 0.13.3 - 2026-09-26 ++ ++### Security ++ ++- **The `claude-cli/` and `codex/` judges no longer run inside the repository under review.** Each ++ judge attempt now starts the model CLI in a fresh, empty, private temp directory, removed after ++ the call. Before, the CLI inherited metareview's working directory, which is normally the ++ repository being reviewed, and in headless mode a CLI can still load that repository's ++ project-level configuration without the interactive trust prompt. Disabling tools does not ++ switch that configuration off. So reviewing an untrusted checkout, such as a fork's PR branch, ++ was not isolated from it. The Claude judge also passes `--setting-sources user ++ --strict-mcp-config`. The Codex judge passes `-c project_doc_max_bytes=0`, which also covers the ++ escalation path's materialized evidence tree (still honoured as an explicit work dir). If no ++ private directory can be made, the attempt fails as a transport error and nothing is spawned in ++ the caller's directory. The directory is created under the user's own cache directory ++ (`os.UserCacheDir`), not a shared temp dir, because Codex walks up from its working directory to ++ a `.git` root and loads repo-scoped skills it finds there; the temp dir is only a fallback when ++ there is no cache directory. Judge calls also no longer persist sessions: Claude gets ++ `--no-session-persistence` and Codex `--ephemeral`, so a judge's full prompt and diff are no ++ longer written into the user's session transcripts. Reported while reviewing #165; plan in #167 ++ (W0). ++ + ## 0.13.2 - 2026-09-26 + + ### Fixed +diff --git a/internal/fsm/cli/deps.go b/internal/fsm/cli/deps.go +index 5bdc1ec..41ad14b 100644 +--- a/internal/fsm/cli/deps.go ++++ b/internal/fsm/cli/deps.go +@@ -134,9 +134,9 @@ func realClaudeExec(ctx context.Context, dir string, args []string, stdin string + // metareview never handles the token itself. + func realCodexExec(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { + cmd := exec.CommandContext(ctx, codexBin, args...) +- // Empty means inherit, which is metareview's own repository. A caller that has materialized +- // an evidence tree passes it here so that tree is what relative paths resolve against. This +- // does not stop the CLI reading elsewhere - cmd.Dir is a starting point, not a jail. ++ // The judge always passes a directory: a fresh isolated one (judge.isolatedDir), or a ++ // materialized evidence tree a caller confined it to. This does not stop the CLI reading ++ // elsewhere - cmd.Dir is a starting point, not a jail. + cmd.Dir = dir + cmd.Stdin = strings.NewReader(stdin) + var out bytes.Buffer +diff --git a/internal/fsm/judge/claude.go b/internal/fsm/judge/claude.go +index e20beaf..5627542 100644 +--- a/internal/fsm/judge/claude.go ++++ b/internal/fsm/judge/claude.go +@@ -79,6 +79,15 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error + // remains a denial rather than a hang in a headless run. + "--disallowed-tools", "*", + "--permission-mode", "dontAsk", ++ // Behind the isolated working directory (isolatedDir): load the user's settings only, ++ // never a project's or a local override, and no MCP server that is not passed explicitly ++ // (none is). --bare would also skip hooks but skips the keychain, and with it the OAuth ++ // session this transport exists to use. ++ "--setting-sources", "user", ++ "--strict-mcp-config", ++ // A judge call is not a session: nothing to resume, and with a fresh directory per ++ // attempt a saved transcript would leave one project directory behind per call. ++ "--no-session-persistence", + // The system prompt must always be passed. Without it `claude -p` can + // silently fall back to Haiku for the work turn even with --model set — + // the judge would then be a different model than the one recorded in the +@@ -105,9 +114,17 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error + case <-j.clock.After(backoff(classBackoff, attempt-1)): + } + } +- actx, cancel := context.WithTimeout(ctx, j.timeout()) +- stdout, code, execErr := j.exec(actx, "", args, user) +- cancel() ++ dir, cleanup, dirErr := isolatedDir() ++ if dirErr != nil { ++ lastErr = errs.E(CodeJudgeTransport, "claude could not be given an isolated working directory: "+dirErr.Error(), "provider", "claude-cli") ++ continue ++ } ++ stdout, code, execErr := func() ([]byte, int, error) { ++ defer cleanup() // deferred: a panic in the seam must not leave the directory behind ++ actx, cancel := context.WithTimeout(ctx, j.timeout()) ++ defer cancel() ++ return j.exec(actx, dir, args, user) ++ }() + + text, tokens, found, transient := parseClaudeResult(stdout) + v.Tokens = v.Tokens.Add(tokens) +diff --git a/internal/fsm/judge/codex.go b/internal/fsm/judge/codex.go +index a398d92..dd88fda 100644 +--- a/internal/fsm/judge/codex.go ++++ b/internal/fsm/judge/codex.go +@@ -38,7 +38,7 @@ type codexJudge struct { + exec CodexExec + nonce func() string + clock Clock +- workDir string // empty: inherit the caller's directory ++ workDir string // empty: a fresh isolated directory per attempt (isolatedDir) + attemptTimeout time.Duration // zero: the AttemptTimeout default + } + +@@ -66,11 +66,16 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) + + args := []string{ + "exec", "--json", ++ "--ephemeral", // a judge call is not a session: persist nothing (one fresh dir per attempt) + "--sandbox", "read-only", // a judge reads; it must never edit the tree it is judging + "--skip-git-repo-check", // judging is not tied to a repository + "--color", "never", + "-m", wireModel(r.Model), + "-c", "model_reasoning_effort=" + r.Effort, ++ // AGENTS.md is read from the working directory up and would be spliced into the judge's ++ // instructions. The default directory is isolated and empty, but an explicit work dir is a ++ // materialized copy of the change under review, which can carry one: project docs off. ++ "-c", "project_doc_max_bytes=0", + "-", // the prompt arrives on stdin, never as an argv the process table would show + } + // The same attempt ceiling, per-attempt deadline and backoff as the HTTP arm. +@@ -98,9 +103,20 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) + case <-j.clock.After(backoff(classBackoff, attempt-1)): + } + } +- actx, cancel := context.WithTimeout(ctx, j.timeout()) +- stdout, code, execErr := j.exec(actx, j.workDir, args, prompt) +- cancel() ++ dir, cleanup := j.workDir, func() {} ++ if dir == "" { ++ var dirErr error ++ if dir, cleanup, dirErr = isolatedDir(); dirErr != nil { ++ lastErr = errs.E(CodeJudgeTransport, "codex could not be given an isolated working directory: "+dirErr.Error(), "provider", "codex") ++ continue ++ } ++ } ++ stdout, code, execErr := func() ([]byte, int, error) { ++ defer cleanup() // deferred: a panic in the seam must not leave the directory behind ++ actx, cancel := context.WithTimeout(ctx, j.timeout()) ++ defer cancel() ++ return j.exec(actx, dir, args, prompt) ++ }() + + text, tokens, found := parseCodexEvents(stdout) + v.Tokens = v.Tokens.Add(tokens) +diff --git a/internal/fsm/judge/codex_test.go b/internal/fsm/judge/codex_test.go +index c99dd41..1396601 100644 +--- a/internal/fsm/judge/codex_test.go ++++ b/internal/fsm/judge/codex_test.go +@@ -440,8 +440,10 @@ func TestCodexJudgeRunsInItsConfiguredWorkDir(t *testing.T) { + } + } + +-// Unset means "inherit", which is today's behaviour: the seam must not invent a directory. +-func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { ++// Unset no longer means "inherit": the caller's directory is the repository under review, whose ++// AGENTS.md a headless codex would splice into the judge's instructions. Unset means a fresh, ++// empty, isolated directory (see isolation_test.go for the full contract). ++func TestCodexJudgeWithNoWorkDirIsIsolated(t *testing.T) { + var gotDir = "unset" + f := func(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { + gotDir = dir +@@ -451,8 +453,8 @@ func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { + if _, err := j.Call(context.Background(), codexRequest()); err != nil { + t.Fatalf("call: %v", err) + } +- if gotDir != "" { +- t.Errorf("exec dir = %q, want empty when unconfigured", gotDir) ++ if gotDir == "" || gotDir == "unset" || !strings.Contains(gotDir, "metareview-judge-") { ++ t.Errorf("exec dir = %q, want a fresh isolated directory when unconfigured", gotDir) + } + } + +@@ -481,8 +483,8 @@ func TestWithCodexWorkDirConfinesOnlyCodexJudges(t *testing.T) { + if _, err := base.Call(context.Background(), codexRequest()); err != nil { + t.Fatalf("base call: %v", err) + } +- if gotDir != "" { +- t.Errorf("confining one judge changed the original, which ran in %q", gotDir) ++ if gotDir == "/evidence" || !strings.Contains(gotDir, "metareview-judge-") { ++ t.Errorf("confining one judge changed the original, which ran in %q (want its own isolated dir)", gotDir) + } + + // a judge that is not the HTTP/codex router is returned as-is +diff --git a/internal/fsm/judge/isolate.go b/internal/fsm/judge/isolate.go +new file mode 100644 +index 0000000..1d39ebf +--- /dev/null ++++ b/internal/fsm/judge/isolate.go +@@ -0,0 +1,35 @@ ++package judge ++ ++import ( ++ "os" ++ "path/filepath" ++) ++ ++// userCacheDir is os.UserCacheDir, a seam so the fallback paths are testable. ++var userCacheDir = os.UserCacheDir ++ ++// isolatedDir makes the private, empty working directory a judge CLI runs in, and returns its ++// cleanup. A headless CLI still loads the project configuration of the directory it starts in ++// (Claude Code's .claude/settings.json hooks and CLAUDE.md; Codex's AGENTS.md), and neither ++// disabling tools nor a read-only sandbox switches that off. Starting inside the repository under ++// review would let that repository run code, or rewrite the judge's instructions, before the ++// judge reads a byte of the prompt. The prompt arrives on stdin, so the CLI needs no files of the ++// repository at all. A var so the failure path is testable. ++// ++// The directory lives under the user's own cache directory, not a shared temp dir: Codex walks up ++// from its working directory to a .git root and loads the repo-scoped skills it finds there, so ++// under a world-writable /tmp another local user could plant both. The temp dir is only the ++// fallback when there is no usable cache directory (no $HOME). ++var isolatedDir = func() (string, func(), error) { ++ base := os.TempDir() ++ if cache, err := userCacheDir(); err == nil { ++ if b := filepath.Join(cache, "metareview", "judge"); os.MkdirAll(b, 0o700) == nil { ++ base = b ++ } ++ } ++ dir, err := os.MkdirTemp(base, "metareview-judge-") ++ if err != nil { ++ return "", func() {}, err ++ } ++ return dir, func() { _ = os.RemoveAll(dir) }, nil ++} +diff --git a/internal/fsm/judge/isolation_test.go b/internal/fsm/judge/isolation_test.go +new file mode 100644 +index 0000000..59fa04a +--- /dev/null ++++ b/internal/fsm/judge/isolation_test.go +@@ -0,0 +1,285 @@ ++package judge ++ ++import ( ++ "context" ++ "errors" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ "github.com/dsifry/metareview/internal/fsm/errs" ++) ++ ++// dirProbe is a CLI exec seam that records the working directory each attempt was given and what ++// that directory held at the moment the CLI would have started. ++type dirProbe struct { ++ dirs []string ++ contents [][]string ++ stdout string ++ code int ++} ++ ++func (p *dirProbe) exec(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { ++ p.dirs = append(p.dirs, dir) ++ entries, err := os.ReadDir(dir) ++ names := []string{} ++ if err != nil { ++ names = append(names, "ERR: "+err.Error()) ++ } ++ for _, e := range entries { ++ names = append(names, e.Name()) ++ } ++ p.contents = append(p.contents, names) ++ return []byte(p.stdout), p.code, nil ++} ++ ++// The judge CLIs must never start inside the repository under review. A headless CLI still loads ++// that directory's project configuration (Claude Code's .claude/settings.json hooks and ++// CLAUDE.md, Codex's AGENTS.md), without the interactive trust prompt, and disabling tools does not ++// switch that off. So every attempt runs in a fresh, empty, private directory that is removed after. ++func assertIsolated(t *testing.T, p *dirProbe, wantAttempts int) { ++ t.Helper() ++ if len(p.dirs) != wantAttempts { ++ t.Fatalf("attempts: got %d want %d", len(p.dirs), wantAttempts) ++ } ++ cwd, err := os.Getwd() ++ if err != nil { ++ t.Fatal(err) ++ } ++ for i, dir := range p.dirs { ++ if dir == "" || !filepath.IsAbs(dir) { ++ t.Fatalf("attempt %d ran in %q: an empty dir inherits the caller's cwd (the reviewed repo)", i+1, dir) ++ } ++ if rel, err := filepath.Rel(cwd, dir); err == nil && !strings.HasPrefix(rel, "..") { ++ t.Fatalf("attempt %d ran inside the caller's tree: %s", i+1, dir) ++ } ++ if len(p.contents[i]) != 0 { ++ t.Fatalf("attempt %d's directory was not empty: %v", i+1, p.contents[i]) ++ } ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("attempt %d's directory %s was not removed (stat: %v)", i+1, dir, err) ++ } ++ } ++} ++ ++func TestClaudeJudgeRunsIsolatedFromTheReviewedRepo(t *testing.T) { ++ p := &dirProbe{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ f := &fakeClaude{stdout: p.stdout} ++ j := &claudeJudge{exec: func(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { ++ f.args = args ++ return p.exec(ctx, dir, args, stdin) ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), claudeRequest()); err != nil { ++ t.Fatal(err) ++ } ++ assertIsolated(t, p, 1) ++ joined := strings.Join(f.args, " ") ++ // Defence in depth behind the empty directory: user settings only (never a project's or a ++ // local override), and no MCP server that is not passed explicitly (none is). ++ for _, want := range []string{"--setting-sources user", "--strict-mcp-config"} { ++ if !strings.Contains(joined, want) { ++ t.Fatalf("args %q missing %q", joined, want) ++ } ++ } ++} ++ ++// Every retry gets its own fresh directory, and none is left behind. ++func TestClaudeJudgeIsolatesEveryAttempt(t *testing.T) { ++ p := &dirProbe{code: 1} ++ j := &claudeJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), claudeRequest()); err == nil { ++ t.Fatal("a CLI that always exits 1 must fail the call") ++ } ++ assertIsolated(t, p, MaxAttempts) ++ seen := map[string]bool{} ++ for _, d := range p.dirs { ++ if seen[d] { ++ t.Fatalf("attempts shared a directory: %s", d) ++ } ++ seen[d] = true ++ } ++} ++ ++func TestCodexJudgeRunsIsolatedByDefault(t *testing.T) { ++ var args []string ++ p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ j := &codexJudge{exec: func(ctx context.Context, dir string, a []string, stdin string) ([]byte, int, error) { ++ args = a ++ return p.exec(ctx, dir, a, stdin) ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ assertIsolated(t, p, 1) ++ // AGENTS.md is read from the working directory up, including from a materialized evidence tree, ++ // which can carry one from the change under review: project docs are off entirely. ++ if !strings.Contains(strings.Join(args, " "), "-c project_doc_max_bytes=0") { ++ t.Fatalf("args %q must disable project docs", args) ++ } ++} ++ ++// An explicit work dir (the escalation path's materialized evidence tree) is honoured as-is and ++// never deleted by the judge. ++func TestCodexJudgeHonoursAnExplicitWorkDir(t *testing.T) { ++ tree := t.TempDir() ++ if err := os.WriteFile(filepath.Join(tree, "a.go"), []byte("package a\n"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ j := &codexJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock(), workDir: tree} ++ if _, err := j.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if len(p.dirs) != 1 || p.dirs[0] != tree { ++ t.Fatalf("explicit work dir not used: %v", p.dirs) ++ } ++ if _, err := os.Stat(filepath.Join(tree, "a.go")); err != nil { ++ t.Fatalf("the judge must not remove a caller's tree: %v", err) ++ } ++} ++ ++// If no private directory can be made, nothing is spawned in the caller's directory as a fallback: ++// the call fails as a transport error, which the ladder retries and the run records. ++func TestJudgesRefuseToRunWithoutAnIsolatedDir(t *testing.T) { ++ saved := isolatedDir ++ t.Cleanup(func() { isolatedDir = saved }) ++ isolatedDir = func() (string, func(), error) { return "", func() {}, errors.New("disk full") } ++ ++ calls := 0 ++ spy := func(context.Context, string, []string, string) ([]byte, int, error) { ++ calls++ ++ return nil, 0, nil ++ } ++ cj := &claudeJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} ++ _, cerr := cj.Call(context.Background(), claudeRequest()) ++ xj := &codexJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} ++ _, xerr := xj.Call(context.Background(), codexRequest()) ++ for name, err := range map[string]error{"claude": cerr, "codex": xerr} { ++ var e *errs.Error ++ if !errors.As(err, &e) || e.Code != CodeJudgeTransport || !strings.Contains(e.Detail, "isolated") { ++ t.Fatalf("%s: want an isolated-dir transport error, got %v", name, err) ++ } ++ } ++ if calls != 0 { ++ t.Fatalf("a CLI was spawned without an isolated directory (%d calls)", calls) ++ } ++} ++ ++// The real directory maker: private (0700), empty, under the user's own cache directory (not a ++// shared temp dir, where another local user could plant a .git root and repo-scoped skills a CLI ++// walks up to), and removed by its cleanup. ++func TestIsolatedDir(t *testing.T) { ++ cache := t.TempDir() ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ userCacheDir = func() (string, error) { return cache, nil } ++ ++ dir, cleanup, err := isolatedDir() ++ if err != nil { ++ t.Fatal(err) ++ } ++ info, err := os.Stat(dir) ++ if err != nil || !info.IsDir() || info.Mode().Perm() != 0o700 { ++ t.Fatalf("stat %s: %v %v", dir, info, err) ++ } ++ if want := filepath.Join(cache, "metareview", "judge") + string(filepath.Separator); !strings.HasPrefix(dir, want) { ++ t.Fatalf("dir %s is not under the user cache base %s", dir, want) ++ } ++ base, err := os.Stat(filepath.Join(cache, "metareview", "judge")) ++ if err != nil || base.Mode().Perm() != 0o700 { ++ t.Fatalf("the base must be private: %v %v", base, err) ++ } ++ cleanup() ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("cleanup left %s", dir) ++ } ++} ++ ++// No user cache directory (HOME unset), or one that cannot hold the base: fall back to the temp ++// directory rather than refuse to judge. ++func TestIsolatedDirFallsBackToTheTempDir(t *testing.T) { ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ tmp := t.TempDir() ++ t.Setenv("TMPDIR", tmp) ++ blocked := filepath.Join(t.TempDir(), "file") ++ if err := os.WriteFile(blocked, nil, 0o600); err != nil { ++ t.Fatal(err) ++ } ++ for name, cacheDir := range map[string]func() (string, error){ ++ "no cache dir": func() (string, error) { return "", errors.New("$HOME is not defined") }, ++ "unusable cache": func() (string, error) { return blocked, nil }, // a file: MkdirAll fails ++ } { ++ userCacheDir = cacheDir ++ dir, cleanup, err := isolatedDir() ++ if err != nil || !strings.HasPrefix(dir, tmp) { ++ t.Fatalf("%s: want a dir under %s, got %q %v", name, tmp, dir, err) ++ } ++ cleanup() ++ } ++} ++ ++// With no usable directory anywhere the maker reports the error rather than returning a directory ++// the CLI would then be run in. ++func TestIsolatedDirReportsAnUnusableTempDir(t *testing.T) { ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ userCacheDir = func() (string, error) { return "", errors.New("no home") } ++ t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "does-not-exist")) ++ dir, cleanup, err := isolatedDir() ++ if err == nil || dir != "" { ++ t.Fatalf("want an error and no dir, got %q %v", dir, err) ++ } ++ cleanup() // a no-op, but always safe to call ++} ++ ++// A panic inside the CLI seam still removes the attempt's directory. ++func TestJudgesRemoveTheDirEvenOnPanic(t *testing.T) { ++ for name, call := range map[string]func(ClaudeExec){ ++ "claude": func(e ClaudeExec) { ++ _, _ = (&claudeJudge{exec: e, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()) ++ }, ++ "codex": func(e ClaudeExec) { ++ _, _ = (&codexJudge{exec: CodexExec(e), nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), codexRequest()) ++ }, ++ } { ++ var dir string ++ func() { ++ defer func() { _ = recover() }() ++ call(func(_ context.Context, d string, _ []string, _ string) ([]byte, int, error) { ++ dir = d ++ panic("boom") ++ }) ++ }() ++ if dir == "" { ++ t.Fatalf("%s: the seam was never called", name) ++ } ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("%s: a panic left %s behind", name, dir) ++ } ++ } ++} ++ ++// Judge calls are not sessions: nothing is persisted (and with a fresh directory per attempt, ++// persisting would leave one transcript directory behind per call). ++func TestJudgesPersistNoSession(t *testing.T) { ++ f := &fakeClaude{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ if _, err := (&claudeJudge{exec: f.exec, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(strings.Join(f.args, " "), "--no-session-persistence") { ++ t.Fatalf("claude args %q", f.args) ++ } ++ var args []string ++ x := &codexJudge{exec: func(_ context.Context, _ string, a []string, _ string) ([]byte, int, error) { ++ args = a ++ return []byte(codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)), 0, nil ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := x.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(strings.Join(args, " "), "--ephemeral") { ++ t.Fatalf("codex args %q", args) ++ } ++} +diff --git a/internal/version/version.go b/internal/version/version.go +index 79c28ad..e768901 100644 +--- a/internal/version/version.go ++++ b/internal/version/version.go +@@ -1,3 +1,3 @@ + package version + +-const Version = "0.13.2" ++const Version = "0.13.3" +diff --git a/package.json b/package.json +index 9b34346..7ba81ec 100644 +--- a/package.json ++++ b/package.json +@@ -1,6 +1,6 @@ + { + "name": "metareview", +- "version": "0.13.2", ++ "version": "0.13.3", + "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, code, acceptance evidence, PR readiness, and post-merge learning", + "bin": { + "metareview": "cli/metareview.js" + + + +``` + +## Review Logs + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-task-done-reviewhelp-test-b88768c5.md) +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-task-done-docs-0-6-a2ac175a.md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-task-done-docs-fsm-result-contract-cbf40d94.md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-task-done-t0-1-finding-identity-rewire-01ca0bf9.md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-task-done-t1-3-pins-proven-gate-pr-a-51440dbf.md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-task-done-quickstart-f3ea78a9.md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-task-done-2026-05-27-artifact-review-parallel-subagents-cf4f2168.md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-task-done-2026-05-27-fail-closed-artifact-review-gates-5243a194.md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-task-done-2026-07-05-issue-2-wu4-review-manifest-shard-agg-b924a5ff.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-task-done-2026-09-23-mutation-incremental-1c-proof-380f704d.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-task-done-2026-09-23-mutation-incremental-2a-gate-ed03c179.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-task-done-2026-09-23-mutation-incremental-2b-views-6f8a9d6c.md) +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-task-done-m0-fsm-run-persistence-8a9eddc9.md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-task-done-m1-m6-fsm-packages-a99c72f1.md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-task-done-m7-fork-record-export-9dc62119.md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-task-done-m8-cli-suite-docs-46a961bc.md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-task-done-pr-a-sharded-measurement-8215c1f2.md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-task-done-pr-b-shard-ingestion-f6a50afe.md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-task-done-pr-b-slice-3-e224e5b6.md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-task-done-pr-b-slices-1-2-ce580d87.md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-task-done-process-overrides-93d185e3.md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-task-done-fix-node-elicits-proofs-e988c555.md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-task-done-fix-pin-id-finding-consistency-b6a37c98.md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-task-done-fix-proof-binds-fix-diff-3ad76b46.md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-task-done-github-reviewdecision-prready-fix-3de6a2de.md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-task-done-gitcontext-9e14bb40.md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-task-done-githubcontext-37fa1398.md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-task-done-review-c809e786.md) +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-task-done-epicready-27fabbd2.md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-task-done-typescript-4dea666f.md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-task-done-issue-2-wu1-a2da3dbe.md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-task-done-issue-2-wu1-version-0-6-69ccbe27.md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-task-done-issue-2-wu2-eb5dad78.md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-task-done-issue-2-wu2-cursor-followup-fix-e87ecaa9.md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-task-done-issue-2-wu2-prready-filter-fix-d4005ab8.md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-task-done-issue-2-wu2-review-fix-05b5e486.md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-task-done-issue-2-wu3-2688b027.md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-task-done-mechanical-precision-lens-c79c1389.md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-task-done-multilang-pytest-conv-1f2195e7.md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-task-done-multilang-testconv-seam-01cc5b3d.md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-task-done-multilang-typescript-conv-6cdb50c2.md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-task-done-multilang-vitest-conv-56254690.md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-task-done-runchain-headsha-escalation-reset-37b22cb1.md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-task-done-runchain-reset-findings-reconciliation-6cafdbd1.md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-task-done-sdlc-loop-clean-510ba162.md) + +## Knowledge And Registries + +Service inventory: none + +No service inventory found. + +Knowledge facts: + +No Beads knowledge facts found. + +## Validation Evidence + +{"schemaVersion":1,"kind":"validation","command":["go","test","./..."],"cwd":".","exitCode":0,"startedAt":"2026-09-27T02:38:20.290556Z","finishedAt":"2026-09-27T02:38:57.044289Z","stdoutSha256":"fd3b831078600f0ba7802628b9b0aaa15e2286d0d4eb48dd5fc20e85e8923082","stderrSha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","summary":"go test ./... exited 0"} + +## GitHub Context + +GitHub context unavailable: pr-number-unavailable + + +## Suggested PR Evidence + +## metareview PR Evidence + +### Summary + +fix/judge-cli-isolation changes .claude-plugin/marketplace.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json, CHANGELOG.md, internal/fsm/cli/deps.go, internal/fsm/judge/claude.go, internal/fsm/judge/codex.go, internal/fsm/judge/codex_test.go, internal/fsm/judge/isolate.go, internal/fsm/judge/isolation_test.go, internal/version/version.go, package.json + +### Validation + +- structured validation: go test ./... exited 0 (exit 0) + +### Task Review Evidence + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-ta[REDACTED].md) findings: mrvf-20260924-062613603963000-ta[REDACTED] +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-ta[REDACTED].md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-ta[REDACTED].md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-ta[REDACTED].md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-ta[REDACTED].md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-ta[REDACTED].md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-ta[REDACTED].md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-ta[REDACTED].md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-ta[REDACTED].md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-ta[REDACTED].md) findings: mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-ta[REDACTED].md) findings: mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-ta[REDACTED].md) findings: mrvf-20260924-051035853973000-ta[REDACTED] +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-ta[REDACTED].md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-ta[REDACTED].md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-ta[REDACTED].md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-ta[REDACTED].md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-ta[REDACTED].md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-ta[REDACTED].md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-ta[REDACTED].md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-ta[REDACTED].md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-ta[REDACTED].md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-ta[REDACTED].md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-ta[REDACTED].md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-ta[REDACTED].md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-ta[REDACTED].md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) findings: mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151538496642000-ta[REDACTED] +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-ta[REDACTED].md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-ta[REDACTED].md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-ta[REDACTED].md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-ta[REDACTED].md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-ta[REDACTED].md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-ta[REDACTED].md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-ta[REDACTED].md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-ta[REDACTED].md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-ta[REDACTED].md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-ta[REDACTED].md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-ta[REDACTED].md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-ta[REDACTED].md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-ta[REDACTED].md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-ta[REDACTED].md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-ta[REDACTED].md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-ta[REDACTED].md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-ta[REDACTED].md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-ta[REDACTED].md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-ta[REDACTED].md) + +### Epic Review Evidence + +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-ta[REDACTED].md) + +### Recorded-Evidence Blocker Status + +_Blockers carried forward from recorded review evidence; findings this gate run raised are under `## Blocking Findings` above._ + +No unresolved blockers in the recorded review evidence. + +### External GitHub Review Context + +GitHub context unavailable: pr-number-unavailable + diff --git a/docs/metareview/context/mrv-20260927-025518024767000-pr-ready-branch-10d735e5-context.md b/docs/metareview/context/mrv-20260927-025518024767000-pr-ready-branch-10d735e5-context.md new file mode 100644 index 00000000..e37f1f11 --- /dev/null +++ b/docs/metareview/context/mrv-20260927-025518024767000-pr-ready-branch-10d735e5-context.md @@ -0,0 +1,818 @@ +# metareview pr-ready context + +Run ID: `mrv-20260927-025518024767000-pr-ready-branch-10d735e5` + +## Git + +- Base: `53ee3962f57c1f972aa532252b0a24cf808f4ffa` +- Head: `868cd84808cdb1fa7645c0d8d6645a15a402f4d7` +- Branch: `fix/judge-cli-isolation` +- Reviewer input digest: `sha256:3b9fd3f762e4de0ad49adec1d044fd8c10364a0bd2a773e60a69ca536725b119` +- Gate effect: `gate` + +## Context Profile + +- Raw diff bytes: `25244` +- Filtered diff bytes: `25244` +- Risk level: `none` +- Generated files excluded: docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md, docs/metareview/context/mrv-20260927-023857249886000-pr-ready-branch-10d735e5-context.md, docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md, docs/metareview/reviews/mrv-20260927-023857249886000-pr-ready-branch-10d735e5.md + +## Context Shard Plan + +Not sharded. + +## Review Manifest + +- Manifest verdict: `PASS` +- Source manifest hash: not sharded +- Runtime assessment: static-only; runtime not assessed + +### Source Paths +- .claude-plugin/marketplace.json +- .claude-plugin/plugin.json +- .codex-plugin/plugin.json +- CHANGELOG.md +- internal/fsm/cli/deps.go +- internal/fsm/judge/claude.go +- internal/fsm/judge/codex.go +- internal/fsm/judge/codex_test.go +- internal/fsm/judge/isolate.go +- internal/fsm/judge/isolation_test.go +- internal/version/version.go +- package.json + +### Path Dispositions +- docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md: generated (metareview generated review artifact excluded from source manifest) +- docs/metareview/context/mrv-20260927-023857249886000-pr-ready-branch-10d735e5-context.md: generated (metareview generated review artifact excluded from source manifest) +- docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md: generated (metareview generated review artifact excluded from source manifest) +- docs/metareview/reviews/mrv-20260927-023857249886000-pr-ready-branch-10d735e5.md: generated (metareview generated review artifact excluded from source manifest) + +### Manifest Blockers +No manifest blockers. + +## Changed Files + +- .claude-plugin/marketplace.json +- .claude-plugin/plugin.json +- .codex-plugin/plugin.json +- CHANGELOG.md +- internal/fsm/cli/deps.go +- internal/fsm/judge/claude.go +- internal/fsm/judge/codex.go +- internal/fsm/judge/codex_test.go +- internal/fsm/judge/isolate.go +- internal/fsm/judge/isolation_test.go +- internal/version/version.go +- package.json + +## Diff + +```diff +diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json +index 01c2af7..a112519 100644 +--- a/.claude-plugin/marketplace.json ++++ b/.claude-plugin/marketplace.json +@@ -8,7 +8,7 @@ + { + "name": "metareview", + "description": "Internal review harness, adversarial gates, and post-merge learning for coding agents", +- "version": "0.13.2", ++ "version": "0.13.3", + "source": "./", + "author": { + "name": "David Sifry" +diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json +index 413a42a..7c5707d 100644 +--- a/.claude-plugin/plugin.json ++++ b/.claude-plugin/plugin.json +@@ -1,6 +1,6 @@ + { + "name": "metareview", +- "version": "0.13.2", ++ "version": "0.13.3", + "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, task-done code review, acceptance evidence, PR readiness, and post-merge learning. Packaged releases use bin/metareview; source checkout mode requires Go 1.26+, plus FSM workflow runs (metareview fsm).", + "author": { + "name": "David Sifry" +diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json +index eac09f0..369105b 100644 +--- a/.codex-plugin/plugin.json ++++ b/.codex-plugin/plugin.json +@@ -1,6 +1,6 @@ + { + "name": "metareview", +- "version": "0.13.2", ++ "version": "0.13.3", + "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, task-done code review, acceptance evidence, PR readiness, and post-merge learning, plus FSM workflow runs (metareview fsm).", + "author": { + "name": "David Sifry" +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 004e876..47655ce 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -1,5 +1,28 @@ + # Changelog + ++## 0.13.3 - 2026-09-26 ++ ++### Security ++ ++- **The `claude-cli/` and `codex/` judges no longer run inside the repository under review.** Each ++ judge attempt now starts the model CLI in a fresh, empty, private temp directory, removed after ++ the call. Before, the CLI inherited metareview's working directory, which is normally the ++ repository being reviewed, and in headless mode a CLI can still load that repository's ++ project-level configuration without the interactive trust prompt. Disabling tools does not ++ switch that configuration off. So reviewing an untrusted checkout, such as a fork's PR branch, ++ was not isolated from it. The Claude judge also passes `--setting-sources user ++ --strict-mcp-config`. The Codex judge passes `-c project_doc_max_bytes=0`, which also covers the ++ escalation path's materialized evidence tree (still honoured as an explicit work dir). If no ++ private directory can be made, the attempt fails as a transport error and nothing is spawned in ++ the caller's directory. The directory is created under the user's own cache directory ++ (`os.UserCacheDir`), not a shared temp dir, because Codex walks up from its working directory to ++ a `.git` root and loads repo-scoped skills it finds there. There is no temp-dir fallback: without ++ a usable cache directory the judge attempt fails (the CLI judges need `$HOME` for their OAuth ++ session anyway). Judge calls also no longer persist sessions: Claude gets ++ `--no-session-persistence` and Codex `--ephemeral`, so a judge's full prompt and diff are no ++ longer written into the user's session transcripts. Reported while reviewing #165; plan in #167 ++ (W0). ++ + ## 0.13.2 - 2026-09-26 + + ### Fixed +diff --git a/internal/fsm/cli/deps.go b/internal/fsm/cli/deps.go +index 5bdc1ec..41ad14b 100644 +--- a/internal/fsm/cli/deps.go ++++ b/internal/fsm/cli/deps.go +@@ -134,9 +134,9 @@ func realClaudeExec(ctx context.Context, dir string, args []string, stdin string + // metareview never handles the token itself. + func realCodexExec(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { + cmd := exec.CommandContext(ctx, codexBin, args...) +- // Empty means inherit, which is metareview's own repository. A caller that has materialized +- // an evidence tree passes it here so that tree is what relative paths resolve against. This +- // does not stop the CLI reading elsewhere - cmd.Dir is a starting point, not a jail. ++ // The judge always passes a directory: a fresh isolated one (judge.isolatedDir), or a ++ // materialized evidence tree a caller confined it to. This does not stop the CLI reading ++ // elsewhere - cmd.Dir is a starting point, not a jail. + cmd.Dir = dir + cmd.Stdin = strings.NewReader(stdin) + var out bytes.Buffer +diff --git a/internal/fsm/judge/claude.go b/internal/fsm/judge/claude.go +index e20beaf..5627542 100644 +--- a/internal/fsm/judge/claude.go ++++ b/internal/fsm/judge/claude.go +@@ -79,6 +79,15 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error + // remains a denial rather than a hang in a headless run. + "--disallowed-tools", "*", + "--permission-mode", "dontAsk", ++ // Behind the isolated working directory (isolatedDir): load the user's settings only, ++ // never a project's or a local override, and no MCP server that is not passed explicitly ++ // (none is). --bare would also skip hooks but skips the keychain, and with it the OAuth ++ // session this transport exists to use. ++ "--setting-sources", "user", ++ "--strict-mcp-config", ++ // A judge call is not a session: nothing to resume, and with a fresh directory per ++ // attempt a saved transcript would leave one project directory behind per call. ++ "--no-session-persistence", + // The system prompt must always be passed. Without it `claude -p` can + // silently fall back to Haiku for the work turn even with --model set — + // the judge would then be a different model than the one recorded in the +@@ -105,9 +114,17 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error + case <-j.clock.After(backoff(classBackoff, attempt-1)): + } + } +- actx, cancel := context.WithTimeout(ctx, j.timeout()) +- stdout, code, execErr := j.exec(actx, "", args, user) +- cancel() ++ dir, cleanup, dirErr := isolatedDir() ++ if dirErr != nil { ++ lastErr = errs.E(CodeJudgeTransport, "claude could not be given an isolated working directory: "+dirErr.Error(), "provider", "claude-cli") ++ continue ++ } ++ stdout, code, execErr := func() ([]byte, int, error) { ++ defer cleanup() // deferred: a panic in the seam must not leave the directory behind ++ actx, cancel := context.WithTimeout(ctx, j.timeout()) ++ defer cancel() ++ return j.exec(actx, dir, args, user) ++ }() + + text, tokens, found, transient := parseClaudeResult(stdout) + v.Tokens = v.Tokens.Add(tokens) +diff --git a/internal/fsm/judge/codex.go b/internal/fsm/judge/codex.go +index a398d92..dd88fda 100644 +--- a/internal/fsm/judge/codex.go ++++ b/internal/fsm/judge/codex.go +@@ -38,7 +38,7 @@ type codexJudge struct { + exec CodexExec + nonce func() string + clock Clock +- workDir string // empty: inherit the caller's directory ++ workDir string // empty: a fresh isolated directory per attempt (isolatedDir) + attemptTimeout time.Duration // zero: the AttemptTimeout default + } + +@@ -66,11 +66,16 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) + + args := []string{ + "exec", "--json", ++ "--ephemeral", // a judge call is not a session: persist nothing (one fresh dir per attempt) + "--sandbox", "read-only", // a judge reads; it must never edit the tree it is judging + "--skip-git-repo-check", // judging is not tied to a repository + "--color", "never", + "-m", wireModel(r.Model), + "-c", "model_reasoning_effort=" + r.Effort, ++ // AGENTS.md is read from the working directory up and would be spliced into the judge's ++ // instructions. The default directory is isolated and empty, but an explicit work dir is a ++ // materialized copy of the change under review, which can carry one: project docs off. ++ "-c", "project_doc_max_bytes=0", + "-", // the prompt arrives on stdin, never as an argv the process table would show + } + // The same attempt ceiling, per-attempt deadline and backoff as the HTTP arm. +@@ -98,9 +103,20 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) + case <-j.clock.After(backoff(classBackoff, attempt-1)): + } + } +- actx, cancel := context.WithTimeout(ctx, j.timeout()) +- stdout, code, execErr := j.exec(actx, j.workDir, args, prompt) +- cancel() ++ dir, cleanup := j.workDir, func() {} ++ if dir == "" { ++ var dirErr error ++ if dir, cleanup, dirErr = isolatedDir(); dirErr != nil { ++ lastErr = errs.E(CodeJudgeTransport, "codex could not be given an isolated working directory: "+dirErr.Error(), "provider", "codex") ++ continue ++ } ++ } ++ stdout, code, execErr := func() ([]byte, int, error) { ++ defer cleanup() // deferred: a panic in the seam must not leave the directory behind ++ actx, cancel := context.WithTimeout(ctx, j.timeout()) ++ defer cancel() ++ return j.exec(actx, dir, args, prompt) ++ }() + + text, tokens, found := parseCodexEvents(stdout) + v.Tokens = v.Tokens.Add(tokens) +diff --git a/internal/fsm/judge/codex_test.go b/internal/fsm/judge/codex_test.go +index c99dd41..1396601 100644 +--- a/internal/fsm/judge/codex_test.go ++++ b/internal/fsm/judge/codex_test.go +@@ -440,8 +440,10 @@ func TestCodexJudgeRunsInItsConfiguredWorkDir(t *testing.T) { + } + } + +-// Unset means "inherit", which is today's behaviour: the seam must not invent a directory. +-func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { ++// Unset no longer means "inherit": the caller's directory is the repository under review, whose ++// AGENTS.md a headless codex would splice into the judge's instructions. Unset means a fresh, ++// empty, isolated directory (see isolation_test.go for the full contract). ++func TestCodexJudgeWithNoWorkDirIsIsolated(t *testing.T) { + var gotDir = "unset" + f := func(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { + gotDir = dir +@@ -451,8 +453,8 @@ func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { + if _, err := j.Call(context.Background(), codexRequest()); err != nil { + t.Fatalf("call: %v", err) + } +- if gotDir != "" { +- t.Errorf("exec dir = %q, want empty when unconfigured", gotDir) ++ if gotDir == "" || gotDir == "unset" || !strings.Contains(gotDir, "metareview-judge-") { ++ t.Errorf("exec dir = %q, want a fresh isolated directory when unconfigured", gotDir) + } + } + +@@ -481,8 +483,8 @@ func TestWithCodexWorkDirConfinesOnlyCodexJudges(t *testing.T) { + if _, err := base.Call(context.Background(), codexRequest()); err != nil { + t.Fatalf("base call: %v", err) + } +- if gotDir != "" { +- t.Errorf("confining one judge changed the original, which ran in %q", gotDir) ++ if gotDir == "/evidence" || !strings.Contains(gotDir, "metareview-judge-") { ++ t.Errorf("confining one judge changed the original, which ran in %q (want its own isolated dir)", gotDir) + } + + // a judge that is not the HTTP/codex router is returned as-is +diff --git a/internal/fsm/judge/isolate.go b/internal/fsm/judge/isolate.go +new file mode 100644 +index 0000000..4910a9d +--- /dev/null ++++ b/internal/fsm/judge/isolate.go +@@ -0,0 +1,39 @@ ++package judge ++ ++import ( ++ "os" ++ "path/filepath" ++) ++ ++// userCacheDir is os.UserCacheDir, a seam so the fallback paths are testable. ++var userCacheDir = os.UserCacheDir ++ ++// isolatedDir makes the private, empty working directory a judge CLI runs in, and returns its ++// cleanup. A headless CLI still loads the project configuration of the directory it starts in ++// (Claude Code's .claude/settings.json hooks and CLAUDE.md; Codex's AGENTS.md), and neither ++// disabling tools nor a read-only sandbox switches that off. Starting inside the repository under ++// review would let that repository run code, or rewrite the judge's instructions, before the ++// judge reads a byte of the prompt. The prompt arrives on stdin, so the CLI needs no files of the ++// repository at all. A var so the failure path is testable. ++// ++// The directory lives under the user's own cache directory and nowhere else: Codex walks up from ++// its working directory to a project root and loads the repo-scoped skills it finds on the way ++// (project_doc_max_bytes=0 does not switch those off), so under a shared temp dir another local ++// user could plant a .git root and skills in /tmp. There is deliberately no temp-dir fallback: ++// without a usable cache directory the attempt fails, and the CLI judges need $HOME for their ++// OAuth session anyway. ++var isolatedDir = func() (string, func(), error) { ++ cache, err := userCacheDir() ++ if err != nil { ++ return "", func() {}, err ++ } ++ base := filepath.Join(cache, "metareview", "judge") ++ if err := os.MkdirAll(base, 0o700); err != nil { ++ return "", func() {}, err ++ } ++ dir, err := os.MkdirTemp(base, "metareview-judge-") ++ if err != nil { ++ return "", func() {}, err ++ } ++ return dir, func() { _ = os.RemoveAll(dir) }, nil ++} +diff --git a/internal/fsm/judge/isolation_test.go b/internal/fsm/judge/isolation_test.go +new file mode 100644 +index 0000000..8427a40 +--- /dev/null ++++ b/internal/fsm/judge/isolation_test.go +@@ -0,0 +1,284 @@ ++package judge ++ ++import ( ++ "context" ++ "errors" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ "github.com/dsifry/metareview/internal/fsm/errs" ++) ++ ++// dirProbe is a CLI exec seam that records the working directory each attempt was given and what ++// that directory held at the moment the CLI would have started. ++type dirProbe struct { ++ dirs []string ++ contents [][]string ++ stdout string ++ code int ++} ++ ++func (p *dirProbe) exec(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { ++ p.dirs = append(p.dirs, dir) ++ entries, err := os.ReadDir(dir) ++ names := []string{} ++ if err != nil { ++ names = append(names, "ERR: "+err.Error()) ++ } ++ for _, e := range entries { ++ names = append(names, e.Name()) ++ } ++ p.contents = append(p.contents, names) ++ return []byte(p.stdout), p.code, nil ++} ++ ++// The judge CLIs must never start inside the repository under review. A headless CLI still loads ++// that directory's project configuration (Claude Code's .claude/settings.json hooks and ++// CLAUDE.md, Codex's AGENTS.md), without the interactive trust prompt, and disabling tools does not ++// switch that off. So every attempt runs in a fresh, empty, private directory that is removed after. ++func assertIsolated(t *testing.T, p *dirProbe, wantAttempts int) { ++ t.Helper() ++ if len(p.dirs) != wantAttempts { ++ t.Fatalf("attempts: got %d want %d", len(p.dirs), wantAttempts) ++ } ++ cwd, err := os.Getwd() ++ if err != nil { ++ t.Fatal(err) ++ } ++ for i, dir := range p.dirs { ++ if dir == "" || !filepath.IsAbs(dir) { ++ t.Fatalf("attempt %d ran in %q: an empty dir inherits the caller's cwd (the reviewed repo)", i+1, dir) ++ } ++ if rel, err := filepath.Rel(cwd, dir); err == nil && !strings.HasPrefix(rel, "..") { ++ t.Fatalf("attempt %d ran inside the caller's tree: %s", i+1, dir) ++ } ++ if len(p.contents[i]) != 0 { ++ t.Fatalf("attempt %d's directory was not empty: %v", i+1, p.contents[i]) ++ } ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("attempt %d's directory %s was not removed (stat: %v)", i+1, dir, err) ++ } ++ } ++} ++ ++func TestClaudeJudgeRunsIsolatedFromTheReviewedRepo(t *testing.T) { ++ p := &dirProbe{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ f := &fakeClaude{stdout: p.stdout} ++ j := &claudeJudge{exec: func(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { ++ f.args = args ++ return p.exec(ctx, dir, args, stdin) ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), claudeRequest()); err != nil { ++ t.Fatal(err) ++ } ++ assertIsolated(t, p, 1) ++ joined := strings.Join(f.args, " ") ++ // Defence in depth behind the empty directory: user settings only (never a project's or a ++ // local override), and no MCP server that is not passed explicitly (none is). ++ for _, want := range []string{"--setting-sources user", "--strict-mcp-config"} { ++ if !strings.Contains(joined, want) { ++ t.Fatalf("args %q missing %q", joined, want) ++ } ++ } ++} ++ ++// Every retry gets its own fresh directory, and none is left behind. ++func TestClaudeJudgeIsolatesEveryAttempt(t *testing.T) { ++ p := &dirProbe{code: 1} ++ j := &claudeJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), claudeRequest()); err == nil { ++ t.Fatal("a CLI that always exits 1 must fail the call") ++ } ++ assertIsolated(t, p, MaxAttempts) ++ seen := map[string]bool{} ++ for _, d := range p.dirs { ++ if seen[d] { ++ t.Fatalf("attempts shared a directory: %s", d) ++ } ++ seen[d] = true ++ } ++} ++ ++func TestCodexJudgeRunsIsolatedByDefault(t *testing.T) { ++ var args []string ++ p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ j := &codexJudge{exec: func(ctx context.Context, dir string, a []string, stdin string) ([]byte, int, error) { ++ args = a ++ return p.exec(ctx, dir, a, stdin) ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := j.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ assertIsolated(t, p, 1) ++ // AGENTS.md is read from the working directory up, including from a materialized evidence tree, ++ // which can carry one from the change under review: project docs are off entirely. ++ if !strings.Contains(strings.Join(args, " "), "-c project_doc_max_bytes=0") { ++ t.Fatalf("args %q must disable project docs", args) ++ } ++} ++ ++// An explicit work dir (the escalation path's materialized evidence tree) is honoured as-is and ++// never deleted by the judge. ++func TestCodexJudgeHonoursAnExplicitWorkDir(t *testing.T) { ++ tree := t.TempDir() ++ if err := os.WriteFile(filepath.Join(tree, "a.go"), []byte("package a\n"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ j := &codexJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock(), workDir: tree} ++ if _, err := j.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if len(p.dirs) != 1 || p.dirs[0] != tree { ++ t.Fatalf("explicit work dir not used: %v", p.dirs) ++ } ++ if _, err := os.Stat(filepath.Join(tree, "a.go")); err != nil { ++ t.Fatalf("the judge must not remove a caller's tree: %v", err) ++ } ++} ++ ++// If no private directory can be made, nothing is spawned in the caller's directory as a fallback: ++// the call fails as a transport error, which the ladder retries and the run records. ++func TestJudgesRefuseToRunWithoutAnIsolatedDir(t *testing.T) { ++ saved := isolatedDir ++ t.Cleanup(func() { isolatedDir = saved }) ++ isolatedDir = func() (string, func(), error) { return "", func() {}, errors.New("disk full") } ++ ++ calls := 0 ++ spy := func(context.Context, string, []string, string) ([]byte, int, error) { ++ calls++ ++ return nil, 0, nil ++ } ++ cj := &claudeJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} ++ _, cerr := cj.Call(context.Background(), claudeRequest()) ++ xj := &codexJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} ++ _, xerr := xj.Call(context.Background(), codexRequest()) ++ for name, err := range map[string]error{"claude": cerr, "codex": xerr} { ++ var e *errs.Error ++ if !errors.As(err, &e) || e.Code != CodeJudgeTransport || !strings.Contains(e.Detail, "isolated") { ++ t.Fatalf("%s: want an isolated-dir transport error, got %v", name, err) ++ } ++ } ++ if calls != 0 { ++ t.Fatalf("a CLI was spawned without an isolated directory (%d calls)", calls) ++ } ++} ++ ++// The real directory maker: private (0700), empty, under the user's own cache directory (not a ++// shared temp dir, where another local user could plant a .git root and repo-scoped skills a CLI ++// walks up to), and removed by its cleanup. ++func TestIsolatedDir(t *testing.T) { ++ cache := t.TempDir() ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ userCacheDir = func() (string, error) { return cache, nil } ++ ++ dir, cleanup, err := isolatedDir() ++ if err != nil { ++ t.Fatal(err) ++ } ++ info, err := os.Stat(dir) ++ if err != nil || !info.IsDir() || info.Mode().Perm() != 0o700 { ++ t.Fatalf("stat %s: %v %v", dir, info, err) ++ } ++ if want := filepath.Join(cache, "metareview", "judge") + string(filepath.Separator); !strings.HasPrefix(dir, want) { ++ t.Fatalf("dir %s is not under the user cache base %s", dir, want) ++ } ++ base, err := os.Stat(filepath.Join(cache, "metareview", "judge")) ++ if err != nil || base.Mode().Perm() != 0o700 { ++ t.Fatalf("the base must be private: %v %v", base, err) ++ } ++ cleanup() ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("cleanup left %s", dir) ++ } ++} ++ ++// No usable user cache directory: refuse, never fall back to a shared temp dir. Under /tmp another ++// local user could plant a .git root and repo-scoped skills that Codex walks up to and loads ++// (project_doc_max_bytes=0 does not switch skills off). The CLI judges need $HOME for their OAuth ++// session anyway, so refusing costs nothing a working setup has. ++func TestIsolatedDirFailsClosedWithoutAUserCacheDir(t *testing.T) { ++ saved := userCacheDir ++ t.Cleanup(func() { userCacheDir = saved }) ++ blocked := filepath.Join(t.TempDir(), "file") ++ if err := os.WriteFile(blocked, nil, 0o600); err != nil { ++ t.Fatal(err) ++ } ++ readOnly := t.TempDir() ++ base := filepath.Join(readOnly, "metareview", "judge") ++ if err := os.MkdirAll(base, 0o700); err != nil { ++ t.Fatal(err) ++ } ++ if err := os.Chmod(base, 0o500); err != nil { ++ t.Fatal(err) ++ } ++ t.Cleanup(func() { _ = os.Chmod(base, 0o700) }) ++ for name, cacheDir := range map[string]func() (string, error){ ++ "no cache dir": func() (string, error) { return "", errors.New("$HOME is not defined") }, ++ "unusable cache": func() (string, error) { return blocked, nil }, // a file: MkdirAll fails ++ "unwritable base": func() (string, error) { return readOnly, nil }, // MkdirTemp fails ++ } { ++ if name == "unwritable base" && os.Geteuid() == 0 { ++ continue // root ignores the mode ++ } ++ userCacheDir = cacheDir ++ dir, cleanup, err := isolatedDir() ++ if err == nil || dir != "" { ++ t.Fatalf("%s: want an error and no dir, got %q %v", name, dir, err) ++ } ++ cleanup() // a no-op, but always safe to call ++ } ++} ++ ++// A panic inside the CLI seam still removes the attempt's directory. ++func TestJudgesRemoveTheDirEvenOnPanic(t *testing.T) { ++ for name, call := range map[string]func(ClaudeExec){ ++ "claude": func(e ClaudeExec) { ++ _, _ = (&claudeJudge{exec: e, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()) ++ }, ++ "codex": func(e ClaudeExec) { ++ _, _ = (&codexJudge{exec: CodexExec(e), nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), codexRequest()) ++ }, ++ } { ++ var dir string ++ func() { ++ defer func() { _ = recover() }() ++ call(func(_ context.Context, d string, _ []string, _ string) ([]byte, int, error) { ++ dir = d ++ panic("boom") ++ }) ++ }() ++ if dir == "" { ++ t.Fatalf("%s: the seam was never called", name) ++ } ++ if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("%s: a panic left %s behind", name, dir) ++ } ++ } ++} ++ ++// Judge calls are not sessions: nothing is persisted (and with a fresh directory per attempt, ++// persisting would leave one transcript directory behind per call). ++func TestJudgesPersistNoSession(t *testing.T) { ++ f := &fakeClaude{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} ++ if _, err := (&claudeJudge{exec: f.exec, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(strings.Join(f.args, " "), "--no-session-persistence") { ++ t.Fatalf("claude args %q", f.args) ++ } ++ var args []string ++ x := &codexJudge{exec: func(_ context.Context, _ string, a []string, _ string) ([]byte, int, error) { ++ args = a ++ return []byte(codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)), 0, nil ++ }, nonce: func() string { return "n0" }, clock: codexClock()} ++ if _, err := x.Call(context.Background(), codexRequest()); err != nil { ++ t.Fatal(err) ++ } ++ if !strings.Contains(strings.Join(args, " "), "--ephemeral") { ++ t.Fatalf("codex args %q", args) ++ } ++} +diff --git a/internal/version/version.go b/internal/version/version.go +index 79c28ad..e768901 100644 +--- a/internal/version/version.go ++++ b/internal/version/version.go +@@ -1,3 +1,3 @@ + package version + +-const Version = "0.13.2" ++const Version = "0.13.3" +diff --git a/package.json b/package.json +index 9b34346..7ba81ec 100644 +--- a/package.json ++++ b/package.json +@@ -1,6 +1,6 @@ + { + "name": "metareview", +- "version": "0.13.2", ++ "version": "0.13.3", + "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, code, acceptance evidence, PR readiness, and post-merge learning", + "bin": { + "metareview": "cli/metareview.js" + + + +``` + +## Review Logs + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-task-done-reviewhelp-test-b88768c5.md) +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-task-done-docs-0-6-a2ac175a.md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-task-done-docs-fsm-result-contract-cbf40d94.md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-task-done-t0-1-finding-identity-rewire-01ca0bf9.md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-task-done-t1-3-pins-proven-gate-pr-a-51440dbf.md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-task-done-quickstart-f3ea78a9.md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-task-done-2026-05-27-artifact-review-parallel-subagents-cf4f2168.md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-task-done-2026-05-27-fail-closed-artifact-review-gates-5243a194.md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-task-done-2026-07-05-issue-2-wu4-review-manifest-shard-agg-b924a5ff.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-task-done-2026-09-23-mutation-incremental-1c-proof-380f704d.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-task-done-2026-09-23-mutation-incremental-2a-gate-ed03c179.md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-task-done-2026-09-23-mutation-incremental-2b-views-6f8a9d6c.md) +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-task-done-m0-fsm-run-persistence-8a9eddc9.md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-task-done-m1-m6-fsm-packages-a99c72f1.md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-task-done-m7-fork-record-export-9dc62119.md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-task-done-m8-cli-suite-docs-46a961bc.md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-task-done-pr-a-sharded-measurement-8215c1f2.md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-task-done-pr-b-shard-ingestion-f6a50afe.md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-task-done-pr-b-slice-3-e224e5b6.md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-task-done-pr-b-slices-1-2-ce580d87.md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-task-done-process-overrides-93d185e3.md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-task-done-fix-node-elicits-proofs-e988c555.md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-task-done-fix-pin-id-finding-consistency-b6a37c98.md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-task-done-fix-proof-binds-fix-diff-3ad76b46.md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-task-done-github-reviewdecision-prready-fix-3de6a2de.md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-task-done-gitcontext-9e14bb40.md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-task-done-githubcontext-37fa1398.md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-task-done-review-c809e786.md) +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-task-done-epicready-27fabbd2.md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-task-done-typescript-4dea666f.md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-task-done-issue-2-wu1-a2da3dbe.md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-task-done-issue-2-wu1-version-0-6-69ccbe27.md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-task-done-issue-2-wu2-eb5dad78.md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-task-done-issue-2-wu2-cursor-followup-fix-e87ecaa9.md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-task-done-issue-2-wu2-prready-filter-fix-d4005ab8.md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-task-done-issue-2-wu2-review-fix-05b5e486.md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-task-done-issue-2-wu3-2688b027.md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-task-done-mechanical-precision-lens-c79c1389.md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-task-done-multilang-pytest-conv-1f2195e7.md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-task-done-multilang-testconv-seam-01cc5b3d.md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-task-done-multilang-typescript-conv-6cdb50c2.md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-task-done-multilang-vitest-conv-56254690.md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-task-done-runchain-headsha-escalation-reset-37b22cb1.md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-task-done-runchain-reset-findings-reconciliation-6cafdbd1.md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-task-done-sdlc-loop-clean-510ba162.md) + +## Knowledge And Registries + +Service inventory: none + +No service inventory found. + +Knowledge facts: + +No Beads knowledge facts found. + +## Validation Evidence + +{"schemaVersion":1,"kind":"validation","command":["go","test","./..."],"cwd":".","exitCode":0,"startedAt":"2026-09-27T02:54:58.107085Z","finishedAt":"2026-09-27T02:55:17.598128Z","stdoutSha256":"cadfe12c3b58350e99202c5c66d06d8b27f9fb298edf0ecb05c9a816d5c4bbb6","stderrSha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","summary":"go test ./... exited 0"} + +## GitHub Context + +GitHub context unavailable: pr-number-unavailable + + +## Suggested PR Evidence + +## metareview PR Evidence + +### Summary + +fix/judge-cli-isolation changes .claude-plugin/marketplace.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json, CHANGELOG.md, internal/fsm/cli/deps.go, internal/fsm/judge/claude.go, internal/fsm/judge/codex.go, internal/fsm/judge/codex_test.go, internal/fsm/judge/isolate.go, internal/fsm/judge/isolation_test.go, internal/version/version.go, package.json + +### Validation + +- structured validation: go test ./... exited 0 (exit 0) + +### Task Review Evidence + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-ta[REDACTED].md) findings: mrvf-20260924-062613603963000-ta[REDACTED] +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-ta[REDACTED].md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-ta[REDACTED].md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-ta[REDACTED].md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-ta[REDACTED].md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-ta[REDACTED].md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-ta[REDACTED].md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-ta[REDACTED].md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-ta[REDACTED].md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-ta[REDACTED].md) findings: mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-ta[REDACTED].md) findings: mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-ta[REDACTED].md) findings: mrvf-20260924-051035853973000-ta[REDACTED] +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-ta[REDACTED].md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-ta[REDACTED].md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-ta[REDACTED].md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-ta[REDACTED].md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-ta[REDACTED].md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-ta[REDACTED].md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-ta[REDACTED].md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-ta[REDACTED].md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-ta[REDACTED].md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-ta[REDACTED].md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-ta[REDACTED].md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-ta[REDACTED].md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-ta[REDACTED].md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) findings: mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151538496642000-ta[REDACTED] +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-ta[REDACTED].md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-ta[REDACTED].md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-ta[REDACTED].md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-ta[REDACTED].md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-ta[REDACTED].md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-ta[REDACTED].md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-ta[REDACTED].md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-ta[REDACTED].md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-ta[REDACTED].md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-ta[REDACTED].md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-ta[REDACTED].md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-ta[REDACTED].md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-ta[REDACTED].md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-ta[REDACTED].md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-ta[REDACTED].md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-ta[REDACTED].md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-ta[REDACTED].md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-ta[REDACTED].md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-ta[REDACTED].md) + +### Epic Review Evidence + +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-ta[REDACTED].md) + +### Recorded-Evidence Blocker Status + +_Blockers carried forward from recorded review evidence; findings this gate run raised are under `## Blocking Findings` above._ + +No unresolved blockers in the recorded review evidence. + +### External GitHub Review Context + +GitHub context unavailable: pr-number-unavailable + diff --git a/docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md b/docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md new file mode 100644 index 00000000..482c145d --- /dev/null +++ b/docs/metareview/reviews/mrv-20260927-021621749506000-pr-ready-branch-10d735e5.md @@ -0,0 +1,149 @@ +# metareview: pr-ready review + +Run ID: `mrv-20260927-021621749506000-pr-ready-branch-10d735e5` + +Target: `current branch` + +Context pack: `docs/metareview/context/mrv-20260927-021621749506000-pr-ready-branch-10d735e5-context.md` + +Execution mode: `deterministic-local` + +Gate effect: `gate` + +Previous run: `none` + +Attempt: `1/3` + +Reviewer input digest: `sha256:f354738402a1c7d90e5a95fa14fcf41da86fa435ff7b08d39bac3fcff248dc45` + +Covered paths: `["CHANGELOG.md","internal/fsm/cli/deps.go","internal/fsm/judge/claude.go","internal/fsm/judge/codex.go","internal/fsm/judge/codex_test.go","internal/fsm/judge/isolate.go","internal/fsm/judge/isolation_test.go"]` + +## Verdict + +PASS_ADVISORY + +## Reviewer Results + +| Reviewer | Verdict | Blocking | Notes | +| --- | --- | ---: | --- | +| pr-readiness-reviewer | PASS | 0 | No blocking findings. | +| validation-reviewer | PASS | 0 | No blocking findings. | +| security-reviewer | PASS | 0 | No blocking findings. | +| code-quality-reviewer | PASS | 0 | No blocking findings. | +| architecture-reviewer | PASS | 0 | No blocking findings. | +| external-reviewer | PASS | 0 | No blocking findings. | + +## Blocking Findings + +No findings in this class. + + +## Advisory Findings + +### mrvf-20260927-021621749506000-pr-ready-branch-10d735e5-001: Adversarial review was in-session-emulated + +- Reviewer: adversarial-review-reviewer +- Severity: low +- Classification: advisory +- Finding: The adjudicated review for this head was recorded as in-session-emulated (no independent subagents), which is weaker, non-independent evidence. +- Expected: An independent subagent-adjudicated review where delegation is available. +- Found: executionMode = in-session-emulated. +- Recommendation: When subagents are available, prefer `metareview fsm --workflow review-loop` for an independent review. + + +## Follow-up Findings + +No findings in this class. + + +## Warnings + +No findings in this class. + + +## Suggested PR Evidence + +## metareview PR Evidence + +### Summary + +fix/judge-cli-isolation changes CHANGELOG.md, internal/fsm/cli/deps.go, internal/fsm/judge/claude.go, internal/fsm/judge/codex.go, internal/fsm/judge/codex_test.go, internal/fsm/judge/isolate.go, internal/fsm/judge/isolation_test.go + +### Validation + +- structured validation: go test ./... exited 0 (exit 0) + +### Task Review Evidence + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-ta[REDACTED].md) findings: mrvf-20260924-062613603963000-ta[REDACTED] +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-ta[REDACTED].md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-ta[REDACTED].md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-ta[REDACTED].md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-ta[REDACTED].md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-ta[REDACTED].md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-ta[REDACTED].md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-ta[REDACTED].md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-ta[REDACTED].md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-ta[REDACTED].md) findings: mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-ta[REDACTED].md) findings: mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-ta[REDACTED].md) findings: mrvf-20260924-051035853973000-ta[REDACTED] +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-ta[REDACTED].md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-ta[REDACTED].md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-ta[REDACTED].md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-ta[REDACTED].md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-ta[REDACTED].md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-ta[REDACTED].md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-ta[REDACTED].md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-ta[REDACTED].md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-ta[REDACTED].md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-ta[REDACTED].md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-ta[REDACTED].md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-ta[REDACTED].md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-ta[REDACTED].md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) findings: mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151538496642000-ta[REDACTED] +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-ta[REDACTED].md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-ta[REDACTED].md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-ta[REDACTED].md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-ta[REDACTED].md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-ta[REDACTED].md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-ta[REDACTED].md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-ta[REDACTED].md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-ta[REDACTED].md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-ta[REDACTED].md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-ta[REDACTED].md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-ta[REDACTED].md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-ta[REDACTED].md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-ta[REDACTED].md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-ta[REDACTED].md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-ta[REDACTED].md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-ta[REDACTED].md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-ta[REDACTED].md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-ta[REDACTED].md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-ta[REDACTED].md) + +### Epic Review Evidence + +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-ta[REDACTED].md) + +### Recorded-Evidence Blocker Status + +_Blockers carried forward from recorded review evidence; findings this gate run raised are under `## Blocking Findings` above._ + +No unresolved blockers in the recorded review evidence. + +### External GitHub Review Context + +GitHub context unavailable: pr-number-unavailable + diff --git a/docs/metareview/reviews/mrv-20260927-023857249886000-pr-ready-branch-10d735e5.md b/docs/metareview/reviews/mrv-20260927-023857249886000-pr-ready-branch-10d735e5.md new file mode 100644 index 00000000..15b0f929 --- /dev/null +++ b/docs/metareview/reviews/mrv-20260927-023857249886000-pr-ready-branch-10d735e5.md @@ -0,0 +1,149 @@ +# metareview: pr-ready review + +Run ID: `mrv-20260927-023857249886000-pr-ready-branch-10d735e5` + +Target: `current branch` + +Context pack: `docs/metareview/context/mrv-20260927-023857249886000-pr-ready-branch-10d735e5-context.md` + +Execution mode: `deterministic-local` + +Gate effect: `gate` + +Previous run: `mrv-20260927-021621749506000-pr-ready-branch-10d735e5` + +Attempt: `2/3` + +Reviewer input digest: `sha256:22598681355a4d6c01b56981c362491a673caa4c617d51213d59f8c012d405a0` + +Covered paths: `[".claude-plugin/marketplace.json",".claude-plugin/plugin.json",".codex-plugin/plugin.json","CHANGELOG.md","internal/fsm/cli/deps.go","internal/fsm/judge/claude.go","internal/fsm/judge/codex.go","internal/fsm/judge/codex_test.go","internal/fsm/judge/isolate.go","internal/fsm/judge/isolation_test.go","internal/version/version.go","package.json"]` + +## Verdict + +PASS_ADVISORY + +## Reviewer Results + +| Reviewer | Verdict | Blocking | Notes | +| --- | --- | ---: | --- | +| pr-readiness-reviewer | PASS | 0 | No blocking findings. | +| validation-reviewer | PASS | 0 | No blocking findings. | +| security-reviewer | PASS | 0 | No blocking findings. | +| code-quality-reviewer | PASS | 0 | No blocking findings. | +| architecture-reviewer | PASS | 0 | No blocking findings. | +| external-reviewer | PASS | 0 | No blocking findings. | + +## Blocking Findings + +No findings in this class. + + +## Advisory Findings + +### mrvf-20260927-021621749506000-pr-ready-branch-10d735e5-001: Adversarial review was in-session-emulated + +- Reviewer: adversarial-review-reviewer +- Severity: low +- Classification: advisory +- Finding: The adjudicated review for this head was recorded as in-session-emulated (no independent subagents), which is weaker, non-independent evidence. +- Expected: An independent subagent-adjudicated review where delegation is available. +- Found: executionMode = in-session-emulated. +- Recommendation: When subagents are available, prefer `metareview fsm --workflow review-loop` for an independent review. + + +## Follow-up Findings + +No findings in this class. + + +## Warnings + +No findings in this class. + + +## Suggested PR Evidence + +## metareview PR Evidence + +### Summary + +fix/judge-cli-isolation changes .claude-plugin/marketplace.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json, CHANGELOG.md, internal/fsm/cli/deps.go, internal/fsm/judge/claude.go, internal/fsm/judge/codex.go, internal/fsm/judge/codex_test.go, internal/fsm/judge/isolate.go, internal/fsm/judge/isolation_test.go, internal/version/version.go, package.json + +### Validation + +- structured validation: go test ./... exited 0 (exit 0) + +### Task Review Evidence + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-ta[REDACTED].md) findings: mrvf-20260924-062613603963000-ta[REDACTED] +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-ta[REDACTED].md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-ta[REDACTED].md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-ta[REDACTED].md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-ta[REDACTED].md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-ta[REDACTED].md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-ta[REDACTED].md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-ta[REDACTED].md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-ta[REDACTED].md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-ta[REDACTED].md) findings: mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-ta[REDACTED].md) findings: mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-ta[REDACTED].md) findings: mrvf-20260924-051035853973000-ta[REDACTED] +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-ta[REDACTED].md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-ta[REDACTED].md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-ta[REDACTED].md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-ta[REDACTED].md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-ta[REDACTED].md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-ta[REDACTED].md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-ta[REDACTED].md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-ta[REDACTED].md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-ta[REDACTED].md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-ta[REDACTED].md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-ta[REDACTED].md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-ta[REDACTED].md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-ta[REDACTED].md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) findings: mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151538496642000-ta[REDACTED] +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-ta[REDACTED].md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-ta[REDACTED].md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-ta[REDACTED].md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-ta[REDACTED].md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-ta[REDACTED].md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-ta[REDACTED].md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-ta[REDACTED].md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-ta[REDACTED].md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-ta[REDACTED].md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-ta[REDACTED].md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-ta[REDACTED].md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-ta[REDACTED].md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-ta[REDACTED].md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-ta[REDACTED].md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-ta[REDACTED].md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-ta[REDACTED].md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-ta[REDACTED].md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-ta[REDACTED].md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-ta[REDACTED].md) + +### Epic Review Evidence + +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-ta[REDACTED].md) + +### Recorded-Evidence Blocker Status + +_Blockers carried forward from recorded review evidence; findings this gate run raised are under `## Blocking Findings` above._ + +No unresolved blockers in the recorded review evidence. + +### External GitHub Review Context + +GitHub context unavailable: pr-number-unavailable + diff --git a/docs/metareview/reviews/mrv-20260927-025518024767000-pr-ready-branch-10d735e5.md b/docs/metareview/reviews/mrv-20260927-025518024767000-pr-ready-branch-10d735e5.md new file mode 100644 index 00000000..c43ac4fd --- /dev/null +++ b/docs/metareview/reviews/mrv-20260927-025518024767000-pr-ready-branch-10d735e5.md @@ -0,0 +1,149 @@ +# metareview: pr-ready review + +Run ID: `mrv-20260927-025518024767000-pr-ready-branch-10d735e5` + +Target: `current branch` + +Context pack: `docs/metareview/context/mrv-20260927-025518024767000-pr-ready-branch-10d735e5-context.md` + +Execution mode: `deterministic-local` + +Gate effect: `gate` + +Previous run: `mrv-20260927-023857249886000-pr-ready-branch-10d735e5` + +Attempt: `3/3` + +Reviewer input digest: `sha256:3b9fd3f762e4de0ad49adec1d044fd8c10364a0bd2a773e60a69ca536725b119` + +Covered paths: `[".claude-plugin/marketplace.json",".claude-plugin/plugin.json",".codex-plugin/plugin.json","CHANGELOG.md","internal/fsm/cli/deps.go","internal/fsm/judge/claude.go","internal/fsm/judge/codex.go","internal/fsm/judge/codex_test.go","internal/fsm/judge/isolate.go","internal/fsm/judge/isolation_test.go","internal/version/version.go","package.json"]` + +## Verdict + +PASS_ADVISORY + +## Reviewer Results + +| Reviewer | Verdict | Blocking | Notes | +| --- | --- | ---: | --- | +| pr-readiness-reviewer | PASS | 0 | No blocking findings. | +| validation-reviewer | PASS | 0 | No blocking findings. | +| security-reviewer | PASS | 0 | No blocking findings. | +| code-quality-reviewer | PASS | 0 | No blocking findings. | +| architecture-reviewer | PASS | 0 | No blocking findings. | +| external-reviewer | PASS | 0 | No blocking findings. | + +## Blocking Findings + +No findings in this class. + + +## Advisory Findings + +### mrvf-20260927-021621749506000-pr-ready-branch-10d735e5-001: Adversarial review was in-session-emulated + +- Reviewer: adversarial-review-reviewer +- Severity: low +- Classification: advisory +- Finding: The adjudicated review for this head was recorded as in-session-emulated (no independent subagents), which is weaker, non-independent evidence. +- Expected: An independent subagent-adjudicated review where delegation is available. +- Found: executionMode = in-session-emulated. +- Recommendation: When subagents are available, prefer `metareview fsm --workflow review-loop` for an independent review. + + +## Follow-up Findings + +No findings in this class. + + +## Warnings + +No findings in this class. + + +## Suggested PR Evidence + +## metareview PR Evidence + +### Summary + +fix/judge-cli-isolation changes .claude-plugin/marketplace.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json, CHANGELOG.md, internal/fsm/cli/deps.go, internal/fsm/judge/claude.go, internal/fsm/judge/codex.go, internal/fsm/judge/codex_test.go, internal/fsm/judge/isolate.go, internal/fsm/judge/isolation_test.go, internal/version/version.go, package.json + +### Validation + +- structured validation: go test ./... exited 0 (exit 0) + +### Task Review Evidence + +- --help: PASS (docs/metareview/reviews/mrv-20260822-220108922308000-task-done-help-9a8265a5.md) +- CHANGELOG.md: PASS (docs/metareview/reviews/mrv-20260908-073527271951000-artifact-changelog-ab09011f.md) +- cmd/metareview/reviewhelp_test.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-062613603963000-ta[REDACTED].md) findings: mrvf-20260924-062613603963000-ta[REDACTED] +- docs-0.6.0-documentation: PASS (docs/metareview/reviews/mrv-20260705-161047045358000-ta[REDACTED].md) +- docs-fsm-result-contract: PASS (docs/metareview/reviews/mrv-20260705-215610270004000-ta[REDACTED].md) +- docs/metareview/evidence/t0.1-finding-identity-rewire.md: PASS (docs/metareview/reviews/mrv-20260901-042205337128000-ta[REDACTED].md) +- docs/metareview/evidence/t1.3-pins-proven-gate-pr-a.md: PASS (docs/metareview/reviews/mrv-20260901-051912009394000-ta[REDACTED].md) +- docs/quickstart.md: PASS (docs/metareview/reviews/mrv-20260528-222809220757000-ta[REDACTED].md) +- docs/specs/2026-05-27-artifact-review-parallel-subagents.md: PASS (docs/metareview/reviews/mrv-20260527-193044946912000-ta[REDACTED].md) +- docs/specs/2026-05-27-fail-closed-artifact-review-gates.md: PASS (docs/metareview/reviews/mrv-20260527-182255753628000-ta[REDACTED].md) +- docs/superpowers/plans/2026-07-05-issue-2-wu4-review-manifest-shard-aggregation.md: PASS (docs/metareview/reviews/mrv-20260705-055835877369000-ta[REDACTED].md) +- docs/superpowers/plans/2026-09-23-mutation-incremental-1c-proof.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-034929320260000-ta[REDACTED].md) findings: mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED], mrvf-20260924-034929320260000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2a-gate.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-045211619641000-ta[REDACTED].md) findings: mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED], mrvf-20260924-045211619641000-ta[REDACTED] +- docs/superpowers/plans/2026-09-23-mutation-incremental-2b-views.md: PASS_ADVISORY (docs/metareview/reviews/mrv-20260924-051035853973000-ta[REDACTED].md) findings: mrvf-20260924-051035853973000-ta[REDACTED] +- docs/tasks/m0-fsm-run-persistence.md: PASS (docs/metareview/reviews/mrv-20260827-130422417348000-ta[REDACTED].md) +- docs/tasks/m1-m6-fsm-packages.md: PASS (docs/metareview/reviews/mrv-20260827-090548364954000-ta[REDACTED].md) +- docs/tasks/m7-fork-record-export.md: PASS (docs/metareview/reviews/mrv-20260827-104833919009000-ta[REDACTED].md) +- docs/tasks/m8-cli-suite-docs.md: PASS (docs/metareview/reviews/mrv-20260827-114104823011000-ta[REDACTED].md) +- docs/tasks/pr-a-sharded-measurement.md: PASS (docs/metareview/reviews/mrv-20260827-164005027099000-ta[REDACTED].md) +- docs/tasks/pr-b-shard-ingestion.md: PASS (docs/metareview/reviews/mrv-20260827-181704826892000-ta[REDACTED].md) +- docs/tasks/pr-b-slice-3.md: PASS (docs/metareview/reviews/mrv-20260827-181553019412000-ta[REDACTED].md) +- docs/tasks/pr-b-slices-1-2.md: PASS (docs/metareview/reviews/mrv-20260827-181426808610000-ta[REDACTED].md) +- docs/tasks/process-overrides.md: PASS (docs/metareview/reviews/mrv-20260827-180059866454000-ta[REDACTED].md) +- fix-node-elicits-proofs: PASS (docs/metareview/reviews/mrv-20260901-183938772835000-ta[REDACTED].md) +- fix-pin-id-finding-consistency: PASS (docs/metareview/reviews/mrv-20260901-191438733281000-ta[REDACTED].md) +- fix-proof-binds-fix-diff: PASS (docs/metareview/reviews/mrv-20260901-204852362514000-ta[REDACTED].md) +- github-reviewdecision-prready-fix: PASS (docs/metareview/reviews/mrv-20260705-221742306514000-ta[REDACTED].md) +- internal/fsm/gate/gates.go: PASS (docs/metareview/reviews/mrv-20260902-040527894922000-task-done-gates-bca330f8.md) +- internal/fsm/judge/judge.go: PASS (docs/metareview/reviews/mrv-20260902-045920838914000-task-done-judge-b7060768.md) +- internal/fsm/kind/kind.go: PASS_ADVISORY (docs/metareview/reviews/mrv-20260910-153756405005000-task-done-kind-b7bb121c.md) findings: mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151423657039000-ta[REDACTED], mrvf-20260910-151538496642000-ta[REDACTED] +- internal/gitcontext/gitcontext.go: PASS (docs/metareview/reviews/mrv-20260902-060726814084000-ta[REDACTED].md) +- internal/githubcontext/githubcontext.go: PASS (docs/metareview/reviews/mrv-20260902-034259118558000-ta[REDACTED].md) +- internal/prready/review.go: PASS (docs/metareview/reviews/mrv-20260902-040416607878000-ta[REDACTED].md) +- internal/testconv/typescript.go: PASS (docs/metareview/reviews/mrv-20260902-051859934731000-ta[REDACTED].md) +- issue-2-wu1: PASS (docs/metareview/reviews/mrv-20260705-012852131814000-ta[REDACTED].md) +- issue-2-wu1-version-0.6.0: PASS (docs/metareview/reviews/mrv-20260705-030845991374000-ta[REDACTED].md) +- issue-2-wu2: PASS (docs/metareview/reviews/mrv-20260705-034317422615000-ta[REDACTED].md) +- issue-2-wu2-cursor-followup-fix: PASS (docs/metareview/reviews/mrv-20260705-040500203996000-ta[REDACTED].md) +- issue-2-wu2-prready-filter-fix: PASS (docs/metareview/reviews/mrv-20260705-035954884586000-ta[REDACTED].md) +- issue-2-wu2-review-fix: PASS (docs/metareview/reviews/mrv-20260705-035540088635000-ta[REDACTED].md) +- issue-2-wu3: PASS (docs/metareview/reviews/mrv-20260705-050759694908000-ta[REDACTED].md) +- mechanical-precision-lens: PASS (docs/metareview/reviews/mrv-20260831-183207933768000-ta[REDACTED].md) +- multilang-pytest-conv: PASS (docs/metareview/reviews/mrv-20260901-190331877901000-ta[REDACTED].md) +- multilang-testconv-seam: PASS (docs/metareview/reviews/mrv-20260901-165623149954000-ta[REDACTED].md) +- multilang-typescript-conv: PASS (docs/metareview/reviews/mrv-20260901-172216437441000-ta[REDACTED].md) +- multilang-vitest-conv: PASS (docs/metareview/reviews/mrv-20260901-174949441189000-ta[REDACTED].md) +- runchain-headsha-escalation-reset: PASS (docs/metareview/reviews/mrv-20260705-222055675782000-ta[REDACTED].md) +- runchain-reset-findings-reconciliation: PASS (docs/metareview/reviews/mrv-20260705-223346154492000-ta[REDACTED].md) +- t1.1-differentialproof-model: PASS (docs/metareview/reviews/mrv-20260901-001859561454000-task-done-t1-aa37529c.md) +- t1.2-agentedit-reduce-seam: PASS (docs/metareview/reviews/mrv-20260901-002517703556000-task-done-t1-b5a41665.md) +- t1.3-reproduction-engine-pr-b: PASS (docs/metareview/reviews/mrv-20260901-080559565942000-task-done-t1-8af2654f.md) +- t1.4-right-reason-reviewer: PASS (docs/metareview/reviews/mrv-20260901-100944138141000-task-done-t1-ab64ea55.md) +- t1.5-deletion-provable-fix: PASS (docs/metareview/reviews/mrv-20260901-105248210036000-task-done-t1-4ad043b2.md) +- t1.6-trivial-pin-prescreen: PASS (docs/metareview/reviews/mrv-20260901-115747009329000-task-done-t1-b353673e.md) +- t2.1-test-deletion-gate: PASS (docs/metareview/reviews/mrv-20260901-145243731066000-task-done-t2-0fe76ae4.md) +- t2.2-trajectory-monitor: PASS (docs/metareview/reviews/mrv-20260901-154004114739000-task-done-t2-d4fcf27a.md) +- workflows/sdlc-loop-clean.yaml: PASS (docs/metareview/reviews/mrv-20260902-082859947245000-ta[REDACTED].md) + +### Epic Review Evidence + +- internal/reviewers/epicready.go: PASS (docs/metareview/reviews/mrv-20260902-035232185408000-ta[REDACTED].md) + +### Recorded-Evidence Blocker Status + +_Blockers carried forward from recorded review evidence; findings this gate run raised are under `## Blocking Findings` above._ + +No unresolved blockers in the recorded review evidence. + +### External GitHub Review Context + +GitHub context unavailable: pr-number-unavailable + diff --git a/internal/fsm/cli/deps.go b/internal/fsm/cli/deps.go index 5bdc1ec1..41ad14b6 100644 --- a/internal/fsm/cli/deps.go +++ b/internal/fsm/cli/deps.go @@ -134,9 +134,9 @@ func realClaudeExec(ctx context.Context, dir string, args []string, stdin string // metareview never handles the token itself. func realCodexExec(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { cmd := exec.CommandContext(ctx, codexBin, args...) - // Empty means inherit, which is metareview's own repository. A caller that has materialized - // an evidence tree passes it here so that tree is what relative paths resolve against. This - // does not stop the CLI reading elsewhere - cmd.Dir is a starting point, not a jail. + // The judge always passes a directory: a fresh isolated one (judge.isolatedDir), or a + // materialized evidence tree a caller confined it to. This does not stop the CLI reading + // elsewhere - cmd.Dir is a starting point, not a jail. cmd.Dir = dir cmd.Stdin = strings.NewReader(stdin) var out bytes.Buffer diff --git a/internal/fsm/judge/claude.go b/internal/fsm/judge/claude.go index e20beaf2..5627542a 100644 --- a/internal/fsm/judge/claude.go +++ b/internal/fsm/judge/claude.go @@ -79,6 +79,15 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error // remains a denial rather than a hang in a headless run. "--disallowed-tools", "*", "--permission-mode", "dontAsk", + // Behind the isolated working directory (isolatedDir): load the user's settings only, + // never a project's or a local override, and no MCP server that is not passed explicitly + // (none is). --bare would also skip hooks but skips the keychain, and with it the OAuth + // session this transport exists to use. + "--setting-sources", "user", + "--strict-mcp-config", + // A judge call is not a session: nothing to resume, and with a fresh directory per + // attempt a saved transcript would leave one project directory behind per call. + "--no-session-persistence", // The system prompt must always be passed. Without it `claude -p` can // silently fall back to Haiku for the work turn even with --model set — // the judge would then be a different model than the one recorded in the @@ -105,9 +114,17 @@ func (j *claudeJudge) Call(ctx context.Context, r Request) (v Verdict, err error case <-j.clock.After(backoff(classBackoff, attempt-1)): } } - actx, cancel := context.WithTimeout(ctx, j.timeout()) - stdout, code, execErr := j.exec(actx, "", args, user) - cancel() + dir, cleanup, dirErr := isolatedDir() + if dirErr != nil { + lastErr = errs.E(CodeJudgeTransport, "claude could not be given an isolated working directory: "+dirErr.Error(), "provider", "claude-cli") + continue + } + stdout, code, execErr := func() ([]byte, int, error) { + defer cleanup() // deferred: a panic in the seam must not leave the directory behind + actx, cancel := context.WithTimeout(ctx, j.timeout()) + defer cancel() + return j.exec(actx, dir, args, user) + }() text, tokens, found, transient := parseClaudeResult(stdout) v.Tokens = v.Tokens.Add(tokens) diff --git a/internal/fsm/judge/codex.go b/internal/fsm/judge/codex.go index a398d92c..dd88fdab 100644 --- a/internal/fsm/judge/codex.go +++ b/internal/fsm/judge/codex.go @@ -38,7 +38,7 @@ type codexJudge struct { exec CodexExec nonce func() string clock Clock - workDir string // empty: inherit the caller's directory + workDir string // empty: a fresh isolated directory per attempt (isolatedDir) attemptTimeout time.Duration // zero: the AttemptTimeout default } @@ -66,11 +66,16 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) args := []string{ "exec", "--json", + "--ephemeral", // a judge call is not a session: persist nothing (one fresh dir per attempt) "--sandbox", "read-only", // a judge reads; it must never edit the tree it is judging "--skip-git-repo-check", // judging is not tied to a repository "--color", "never", "-m", wireModel(r.Model), "-c", "model_reasoning_effort=" + r.Effort, + // AGENTS.md is read from the working directory up and would be spliced into the judge's + // instructions. The default directory is isolated and empty, but an explicit work dir is a + // materialized copy of the change under review, which can carry one: project docs off. + "-c", "project_doc_max_bytes=0", "-", // the prompt arrives on stdin, never as an argv the process table would show } // The same attempt ceiling, per-attempt deadline and backoff as the HTTP arm. @@ -98,9 +103,20 @@ func (j *codexJudge) Call(ctx context.Context, r Request) (v Verdict, err error) case <-j.clock.After(backoff(classBackoff, attempt-1)): } } - actx, cancel := context.WithTimeout(ctx, j.timeout()) - stdout, code, execErr := j.exec(actx, j.workDir, args, prompt) - cancel() + dir, cleanup := j.workDir, func() {} + if dir == "" { + var dirErr error + if dir, cleanup, dirErr = isolatedDir(); dirErr != nil { + lastErr = errs.E(CodeJudgeTransport, "codex could not be given an isolated working directory: "+dirErr.Error(), "provider", "codex") + continue + } + } + stdout, code, execErr := func() ([]byte, int, error) { + defer cleanup() // deferred: a panic in the seam must not leave the directory behind + actx, cancel := context.WithTimeout(ctx, j.timeout()) + defer cancel() + return j.exec(actx, dir, args, prompt) + }() text, tokens, found := parseCodexEvents(stdout) v.Tokens = v.Tokens.Add(tokens) diff --git a/internal/fsm/judge/codex_test.go b/internal/fsm/judge/codex_test.go index c99dd41c..13966010 100644 --- a/internal/fsm/judge/codex_test.go +++ b/internal/fsm/judge/codex_test.go @@ -440,8 +440,10 @@ func TestCodexJudgeRunsInItsConfiguredWorkDir(t *testing.T) { } } -// Unset means "inherit", which is today's behaviour: the seam must not invent a directory. -func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { +// Unset no longer means "inherit": the caller's directory is the repository under review, whose +// AGENTS.md a headless codex would splice into the judge's instructions. Unset means a fresh, +// empty, isolated directory (see isolation_test.go for the full contract). +func TestCodexJudgeWithNoWorkDirIsIsolated(t *testing.T) { var gotDir = "unset" f := func(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { gotDir = dir @@ -451,8 +453,8 @@ func TestCodexJudgeWithNoWorkDirPassesEmpty(t *testing.T) { if _, err := j.Call(context.Background(), codexRequest()); err != nil { t.Fatalf("call: %v", err) } - if gotDir != "" { - t.Errorf("exec dir = %q, want empty when unconfigured", gotDir) + if gotDir == "" || gotDir == "unset" || !strings.Contains(gotDir, "metareview-judge-") { + t.Errorf("exec dir = %q, want a fresh isolated directory when unconfigured", gotDir) } } @@ -481,8 +483,8 @@ func TestWithCodexWorkDirConfinesOnlyCodexJudges(t *testing.T) { if _, err := base.Call(context.Background(), codexRequest()); err != nil { t.Fatalf("base call: %v", err) } - if gotDir != "" { - t.Errorf("confining one judge changed the original, which ran in %q", gotDir) + if gotDir == "/evidence" || !strings.Contains(gotDir, "metareview-judge-") { + t.Errorf("confining one judge changed the original, which ran in %q (want its own isolated dir)", gotDir) } // a judge that is not the HTTP/codex router is returned as-is diff --git a/internal/fsm/judge/isolate.go b/internal/fsm/judge/isolate.go new file mode 100644 index 00000000..4910a9db --- /dev/null +++ b/internal/fsm/judge/isolate.go @@ -0,0 +1,39 @@ +package judge + +import ( + "os" + "path/filepath" +) + +// userCacheDir is os.UserCacheDir, a seam so the fallback paths are testable. +var userCacheDir = os.UserCacheDir + +// isolatedDir makes the private, empty working directory a judge CLI runs in, and returns its +// cleanup. A headless CLI still loads the project configuration of the directory it starts in +// (Claude Code's .claude/settings.json hooks and CLAUDE.md; Codex's AGENTS.md), and neither +// disabling tools nor a read-only sandbox switches that off. Starting inside the repository under +// review would let that repository run code, or rewrite the judge's instructions, before the +// judge reads a byte of the prompt. The prompt arrives on stdin, so the CLI needs no files of the +// repository at all. A var so the failure path is testable. +// +// The directory lives under the user's own cache directory and nowhere else: Codex walks up from +// its working directory to a project root and loads the repo-scoped skills it finds on the way +// (project_doc_max_bytes=0 does not switch those off), so under a shared temp dir another local +// user could plant a .git root and skills in /tmp. There is deliberately no temp-dir fallback: +// without a usable cache directory the attempt fails, and the CLI judges need $HOME for their +// OAuth session anyway. +var isolatedDir = func() (string, func(), error) { + cache, err := userCacheDir() + if err != nil { + return "", func() {}, err + } + base := filepath.Join(cache, "metareview", "judge") + if err := os.MkdirAll(base, 0o700); err != nil { + return "", func() {}, err + } + dir, err := os.MkdirTemp(base, "metareview-judge-") + if err != nil { + return "", func() {}, err + } + return dir, func() { _ = os.RemoveAll(dir) }, nil +} diff --git a/internal/fsm/judge/isolation_test.go b/internal/fsm/judge/isolation_test.go new file mode 100644 index 00000000..8427a40d --- /dev/null +++ b/internal/fsm/judge/isolation_test.go @@ -0,0 +1,284 @@ +package judge + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dsifry/metareview/internal/fsm/errs" +) + +// dirProbe is a CLI exec seam that records the working directory each attempt was given and what +// that directory held at the moment the CLI would have started. +type dirProbe struct { + dirs []string + contents [][]string + stdout string + code int +} + +func (p *dirProbe) exec(_ context.Context, dir string, _ []string, _ string) ([]byte, int, error) { + p.dirs = append(p.dirs, dir) + entries, err := os.ReadDir(dir) + names := []string{} + if err != nil { + names = append(names, "ERR: "+err.Error()) + } + for _, e := range entries { + names = append(names, e.Name()) + } + p.contents = append(p.contents, names) + return []byte(p.stdout), p.code, nil +} + +// The judge CLIs must never start inside the repository under review. A headless CLI still loads +// that directory's project configuration (Claude Code's .claude/settings.json hooks and +// CLAUDE.md, Codex's AGENTS.md), without the interactive trust prompt, and disabling tools does not +// switch that off. So every attempt runs in a fresh, empty, private directory that is removed after. +func assertIsolated(t *testing.T, p *dirProbe, wantAttempts int) { + t.Helper() + if len(p.dirs) != wantAttempts { + t.Fatalf("attempts: got %d want %d", len(p.dirs), wantAttempts) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i, dir := range p.dirs { + if dir == "" || !filepath.IsAbs(dir) { + t.Fatalf("attempt %d ran in %q: an empty dir inherits the caller's cwd (the reviewed repo)", i+1, dir) + } + if rel, err := filepath.Rel(cwd, dir); err == nil && !strings.HasPrefix(rel, "..") { + t.Fatalf("attempt %d ran inside the caller's tree: %s", i+1, dir) + } + if len(p.contents[i]) != 0 { + t.Fatalf("attempt %d's directory was not empty: %v", i+1, p.contents[i]) + } + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("attempt %d's directory %s was not removed (stat: %v)", i+1, dir, err) + } + } +} + +func TestClaudeJudgeRunsIsolatedFromTheReviewedRepo(t *testing.T) { + p := &dirProbe{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} + f := &fakeClaude{stdout: p.stdout} + j := &claudeJudge{exec: func(ctx context.Context, dir string, args []string, stdin string) ([]byte, int, error) { + f.args = args + return p.exec(ctx, dir, args, stdin) + }, nonce: func() string { return "n0" }, clock: codexClock()} + if _, err := j.Call(context.Background(), claudeRequest()); err != nil { + t.Fatal(err) + } + assertIsolated(t, p, 1) + joined := strings.Join(f.args, " ") + // Defence in depth behind the empty directory: user settings only (never a project's or a + // local override), and no MCP server that is not passed explicitly (none is). + for _, want := range []string{"--setting-sources user", "--strict-mcp-config"} { + if !strings.Contains(joined, want) { + t.Fatalf("args %q missing %q", joined, want) + } + } +} + +// Every retry gets its own fresh directory, and none is left behind. +func TestClaudeJudgeIsolatesEveryAttempt(t *testing.T) { + p := &dirProbe{code: 1} + j := &claudeJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock()} + if _, err := j.Call(context.Background(), claudeRequest()); err == nil { + t.Fatal("a CLI that always exits 1 must fail the call") + } + assertIsolated(t, p, MaxAttempts) + seen := map[string]bool{} + for _, d := range p.dirs { + if seen[d] { + t.Fatalf("attempts shared a directory: %s", d) + } + seen[d] = true + } +} + +func TestCodexJudgeRunsIsolatedByDefault(t *testing.T) { + var args []string + p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} + j := &codexJudge{exec: func(ctx context.Context, dir string, a []string, stdin string) ([]byte, int, error) { + args = a + return p.exec(ctx, dir, a, stdin) + }, nonce: func() string { return "n0" }, clock: codexClock()} + if _, err := j.Call(context.Background(), codexRequest()); err != nil { + t.Fatal(err) + } + assertIsolated(t, p, 1) + // AGENTS.md is read from the working directory up, including from a materialized evidence tree, + // which can carry one from the change under review: project docs are off entirely. + if !strings.Contains(strings.Join(args, " "), "-c project_doc_max_bytes=0") { + t.Fatalf("args %q must disable project docs", args) + } +} + +// An explicit work dir (the escalation path's materialized evidence tree) is honoured as-is and +// never deleted by the judge. +func TestCodexJudgeHonoursAnExplicitWorkDir(t *testing.T) { + tree := t.TempDir() + if err := os.WriteFile(filepath.Join(tree, "a.go"), []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + p := &dirProbe{stdout: codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} + j := &codexJudge{exec: p.exec, nonce: func() string { return "n0" }, clock: codexClock(), workDir: tree} + if _, err := j.Call(context.Background(), codexRequest()); err != nil { + t.Fatal(err) + } + if len(p.dirs) != 1 || p.dirs[0] != tree { + t.Fatalf("explicit work dir not used: %v", p.dirs) + } + if _, err := os.Stat(filepath.Join(tree, "a.go")); err != nil { + t.Fatalf("the judge must not remove a caller's tree: %v", err) + } +} + +// If no private directory can be made, nothing is spawned in the caller's directory as a fallback: +// the call fails as a transport error, which the ladder retries and the run records. +func TestJudgesRefuseToRunWithoutAnIsolatedDir(t *testing.T) { + saved := isolatedDir + t.Cleanup(func() { isolatedDir = saved }) + isolatedDir = func() (string, func(), error) { return "", func() {}, errors.New("disk full") } + + calls := 0 + spy := func(context.Context, string, []string, string) ([]byte, int, error) { + calls++ + return nil, 0, nil + } + cj := &claudeJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} + _, cerr := cj.Call(context.Background(), claudeRequest()) + xj := &codexJudge{exec: spy, nonce: func() string { return "n0" }, clock: codexClock()} + _, xerr := xj.Call(context.Background(), codexRequest()) + for name, err := range map[string]error{"claude": cerr, "codex": xerr} { + var e *errs.Error + if !errors.As(err, &e) || e.Code != CodeJudgeTransport || !strings.Contains(e.Detail, "isolated") { + t.Fatalf("%s: want an isolated-dir transport error, got %v", name, err) + } + } + if calls != 0 { + t.Fatalf("a CLI was spawned without an isolated directory (%d calls)", calls) + } +} + +// The real directory maker: private (0700), empty, under the user's own cache directory (not a +// shared temp dir, where another local user could plant a .git root and repo-scoped skills a CLI +// walks up to), and removed by its cleanup. +func TestIsolatedDir(t *testing.T) { + cache := t.TempDir() + saved := userCacheDir + t.Cleanup(func() { userCacheDir = saved }) + userCacheDir = func() (string, error) { return cache, nil } + + dir, cleanup, err := isolatedDir() + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0o700 { + t.Fatalf("stat %s: %v %v", dir, info, err) + } + if want := filepath.Join(cache, "metareview", "judge") + string(filepath.Separator); !strings.HasPrefix(dir, want) { + t.Fatalf("dir %s is not under the user cache base %s", dir, want) + } + base, err := os.Stat(filepath.Join(cache, "metareview", "judge")) + if err != nil || base.Mode().Perm() != 0o700 { + t.Fatalf("the base must be private: %v %v", base, err) + } + cleanup() + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cleanup left %s", dir) + } +} + +// No usable user cache directory: refuse, never fall back to a shared temp dir. Under /tmp another +// local user could plant a .git root and repo-scoped skills that Codex walks up to and loads +// (project_doc_max_bytes=0 does not switch skills off). The CLI judges need $HOME for their OAuth +// session anyway, so refusing costs nothing a working setup has. +func TestIsolatedDirFailsClosedWithoutAUserCacheDir(t *testing.T) { + saved := userCacheDir + t.Cleanup(func() { userCacheDir = saved }) + blocked := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blocked, nil, 0o600); err != nil { + t.Fatal(err) + } + readOnly := t.TempDir() + base := filepath.Join(readOnly, "metareview", "judge") + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(base, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(base, 0o700) }) + for name, cacheDir := range map[string]func() (string, error){ + "no cache dir": func() (string, error) { return "", errors.New("$HOME is not defined") }, + "unusable cache": func() (string, error) { return blocked, nil }, // a file: MkdirAll fails + "unwritable base": func() (string, error) { return readOnly, nil }, // MkdirTemp fails + } { + if name == "unwritable base" && os.Geteuid() == 0 { + continue // root ignores the mode + } + userCacheDir = cacheDir + dir, cleanup, err := isolatedDir() + if err == nil || dir != "" { + t.Fatalf("%s: want an error and no dir, got %q %v", name, dir, err) + } + cleanup() // a no-op, but always safe to call + } +} + +// A panic inside the CLI seam still removes the attempt's directory. +func TestJudgesRemoveTheDirEvenOnPanic(t *testing.T) { + for name, call := range map[string]func(ClaudeExec){ + "claude": func(e ClaudeExec) { + _, _ = (&claudeJudge{exec: e, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()) + }, + "codex": func(e ClaudeExec) { + _, _ = (&codexJudge{exec: CodexExec(e), nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), codexRequest()) + }, + } { + var dir string + func() { + defer func() { _ = recover() }() + call(func(_ context.Context, d string, _ []string, _ string) ([]byte, int, error) { + dir = d + panic("boom") + }) + }() + if dir == "" { + t.Fatalf("%s: the seam was never called", name) + } + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("%s: a panic left %s behind", name, dir) + } + } +} + +// Judge calls are not sessions: nothing is persisted (and with a fresh directory per attempt, +// persisting would leave one transcript directory behind per call). +func TestJudgesPersistNoSession(t *testing.T) { + f := &fakeClaude{stdout: claudeJSON(`{"reasoning":"r","is_real":true,"confidence":0.9}`)} + if _, err := (&claudeJudge{exec: f.exec, nonce: func() string { return "n0" }, clock: codexClock()}).Call(context.Background(), claudeRequest()); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(f.args, " "), "--no-session-persistence") { + t.Fatalf("claude args %q", f.args) + } + var args []string + x := &codexJudge{exec: func(_ context.Context, _ string, a []string, _ string) ([]byte, int, error) { + args = a + return []byte(codexStream(`{"reasoning":"r","is_real":true,"confidence":0.9}`)), 0, nil + }, nonce: func() string { return "n0" }, clock: codexClock()} + if _, err := x.Call(context.Background(), codexRequest()); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(args, " "), "--ephemeral") { + t.Fatalf("codex args %q", args) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 79c28adb..e768901f 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "0.13.2" +const Version = "0.13.3" diff --git a/package.json b/package.json index 9b34346c..7ba81ecb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metareview", - "version": "0.13.2", + "version": "0.13.3", "description": "Go-based metaswarm-compatible internal review harness for plans, specs, decompositions, code, acceptance evidence, PR readiness, and post-merge learning", "bin": { "metareview": "cli/metareview.js"