feat(merge_gate): refuse + quarantine on a write-root escape (#443)#444
Conversation
…te-roots (#443) The merge gate refused PRs for closed source issues (#65) and weak mutation oracles (#381) but never checked whether the worker's diff stayed inside the filesystem sandbox it was leased. The lease scopes Write/Edit at the Claude settings layer, but `Bash(*)` cannot be path-scoped there, so a worker could `bash`-write a file outside its worktree and — on an otherwise-green run — sail through the gate into mergeable state. A Bash escape advancing into durable cognition. This adds a sibling post-hoc gate: - `sandbox.policy.write_root_violations(changed_paths, write_roots)`: a pure, normalized (abs / normpath / trailing-slash-insensitive, mirroring worktree_sweep) helper returning the changed paths outside every write root. Closed-by-default: an empty lease treats any changed path as a violation. - `merge_gate.apply_write_root_escape_gate`: modeled on `apply_mutation_survivor_gate`. On a non-empty violation tuple it disables auto-merge, posts a path-naming comment, quarantines the worktree by reusing `quarantine_if_blocking` + `mark_quarantined` (no new quarantine machinery), emits `merge_refused_write_root_escape` with the offending paths, and flips status merged->open. Empty violations => byte-for-byte pass-through. All gh / quarantine side effects are best-effort (contextlib.suppress) so the event + status flip always fire. - Changed-path collection goes through an injectable seam (`collect_changed_paths` + a `changed_paths` callable) so tests drive it with a fake — no real git. - Wired into `runner/tick.py::_run_merge_gate` via `_apply_write_root_escape_gate`, resolving each outcome's leased CapabilityPolicy + worktree from the tick-scoped saga store (best-effort: a missing store leaves the gate a pass-through). Tests: pure-function matrix (in/out of bounds, normalization, closed-by-default, dedup, property test), gate happy/adversarial/best-effort/skip paths, and both returncode branches of the git seam. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
hadamrd
left a comment
There was a problem hiding this comment.
Minimal path to green (must-fix to merge)
- Fix the production changed-path collector so it can actually observe an out-of-worktree write.
collect_changed_pathsrunsgit diff --name-onlywith cwd=worktree and joins results under the worktree, so it only ever yields in-bounds paths — the gate is a structural no-op against the issue's own concrete example (escape to/home/u/forge-loop/...while worktree is/tmp/forge-x/wt-loop-512). Make the seam surface the worker's real host-side writes (e.g. inspect the leased worktree's PARENT/main checkout, or compare against a pre-run snapshot), and add a gate/integration test where the fake collector returns a sibling-directory escape path that the CURRENTgit diff-in-worktree approach would miss.
Optional follow-ups (do NOT block merge)
- [sev3/style] src/forge_loop/runner/tick.py:1183 — The
except Exception: saga = Nonesaga lookup in_lease_forswallows broadly and silently. It degrades safely to pass-through and mirrors codebase convention, so it's non-blocking — but alogger.debug(...)on the swallowed exception would aid observability when the gate unexpectedly becomes a pass-through. - [sev3/docs] src/forge_loop/runner/merge_gate.py:272 — Docstrings here are essay-length (the module/function prose runs longer than the code it documents). Consider trimming to the contract; non-blocking.
hadamrd
left a comment
There was a problem hiding this comment.
[sev1/correctness] collect_changed_paths runs git diff --name-only origin/<base> with cwd=worktree and then os.path.join(worktree, name). A linked worktree's git diff only reports files under that worktree, so every collected path is always under write_roots (production lease = the worktree itself). The escape in the issue's concrete example writes to a DIFFERENT directory (/home/u/forge-loop/..., not the worktree), which this command cannot see — so in production the gate never fires on a real Bash escape. The pure helper and gate logic are correct, but the seam feeding them can't observe the threat, making the gate a no-op against its customer story. The tests pass only because they inject out-of-bounds paths the real collector would never produce. Rework the collection seam to surface writes outside the worktree.
| base_branch: str, | ||
| *, | ||
| run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, | ||
| ) -> list[str]: |
There was a problem hiding this comment.
[sev1/correctness] collect_changed_paths runs git diff --name-only origin/<base> with cwd=worktree and then os.path.join(worktree, name). A linked worktree's git diff only reports files under that worktree, so every collected path is always under write_roots (production lease = the worktree itself). The escape in the issue's concrete example writes to a DIFFERENT directory (/home/u/forge-loop/..., not the worktree), which this command cannot see — so in production the gate never fires on a real Bash escape. The pure helper and gate logic are correct, but the seam feeding them can't observe the threat, making the gate a no-op against its customer story. The tests pass only because they inject out-of-bounds paths the real collector would never produce. Rework the collection seam to surface writes outside the worktree.
Fixes #443
Problem
The merge gate refused PRs for closed source issues (#65) and weak mutation oracles (#381) but never checked whether the worker's diff stayed inside the filesystem sandbox it was leased. The lease scopes Write/Edit at the Claude settings layer, but
Bash(*)cannot be path-scoped there — so a worker canbash-write a file outside its worktree and, on an otherwise-green run, sail through the gate into mergeable state (a Bash escape advancing into durable cognition).What this adds (a sibling post-hoc gate)
sandbox.policy.write_root_violations(changed_paths, write_roots)— returns the changed paths that fall outside every write root (empty tuple = clean). Normalized (abs /normpath/ trailing-slash-insensitive, mirroringworktree_sweep). Closed-by-default: emptywrite_roots⇒ every changed path is a violation (conservative-on-uncertainty).merge_gate.apply_write_root_escape_gate— modeled onapply_mutation_survivor_gate. On a non-empty violation tuple: disables auto-merge, posts a path-naming comment, quarantines the worktree by reusing existing primitives (quarantine_if_blocking+mark_quarantined— no new machinery), emitsmerge_refused_write_root_escapecarrying the offending paths, and flips statusmerged→open. Empty violations ⇒ byte-for-byte pass-through. All gh/quarantine side effects are best-effort (contextlib.suppress) so the event + status flip always fire.collect_changed_paths+ achanged_pathscallable, so tests drive it with a fake (no real git/subprocess).runner/tick.py::_run_merge_gatecalls_apply_write_root_escape_gate, resolving each outcome's leasedCapabilityPolicy+ worktree from the tick-scoped saga store (best-effort: a missing store ⇒ pass-through).Acceptance criteria
apply_*_gateshape, feeding changed paths against the leasedwrite_roots.merged→open), worktree quarantined via existing primitives, comment posted,merge_refused_write_root_escapeevent with offending paths.quarantine_if_blocking/mark_quarantined.Tests
tests/test_sandbox_write_root_violations.py(pure function): in-bounds→empty, out-of-bounds→that path, trailing-slash + relative-vs-abs no-false-positive, closed-by-default empty lease, dedup, hypothesis property test.tests/test_runner_merge_gate.py(gate): happy pass-through, adversarial refuse+quarantine+event+comment+status-flip, best-effort side effects (raising gh/quarantine still fires event+flip), unresolvable-lease skip, no-PR skip, and both returncode branches ofcollect_changed_paths.All 33 new/related tests pass.
ruff check src testsclean;pyrightclean on all changed files.Note on
pyright src/forge_looppyright src/forge_loopreports 2 errors that pre-exist ontrunkin files this PR does not touch (runner/dispatch.py:985,runner/tick_checks.py:189). Every file changed by this PR is pyright-clean. These were left untouched per scope discipline (out of scope for #443).Out of scope (unchanged, per issue)
No runtime FS jail; no settings-rendering /
Bash(*)-grant changes; issue-closed (#65) and mutation-survivor (#381) gates untouched; write-roots only.