Skip to content

feat(merge_gate): refuse + quarantine on a write-root escape (#443)#444

Merged
hadamrd merged 1 commit into
trunkfrom
loop/443-fail-the-merge-gate-and-quarantine-on-a
Jun 9, 2026
Merged

feat(merge_gate): refuse + quarantine on a write-root escape (#443)#444
hadamrd merged 1 commit into
trunkfrom
loop/443-fail-the-merge-gate-and-quarantine-on-a

Conversation

@hadamrd

@hadamrd hadamrd commented Jun 8, 2026

Copy link
Copy Markdown
Owner

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 can 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).

What this adds (a sibling post-hoc gate)

  • Pure helper 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, mirroring worktree_sweep). Closed-by-default: empty write_roots ⇒ every changed path is a violation (conservative-on-uncertainty).
  • Gate merge_gate.apply_write_root_escape_gate — modeled on apply_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), emits merge_refused_write_root_escape carrying the offending paths, and flips status mergedopen. Empty violations ⇒ byte-for-byte pass-through. All gh/quarantine side effects are best-effort (contextlib.suppress) so the event + status flip always fire.
  • Injectable seam — changed-path collection goes through collect_changed_paths + a changed_paths callable, so tests drive it with a fake (no real git/subprocess).
  • Production wiringrunner/tick.py::_run_merge_gate calls _apply_write_root_escape_gate, resolving each outcome's leased CapabilityPolicy + worktree from the tick-scoped saga store (best-effort: a missing store ⇒ pass-through).

Acceptance criteria

  • Pure function returning the non-empty tuple of out-of-bounds paths; normalized; empty tuple for a clean diff.
  • Merge-gate enforcer following the existing apply_*_gate shape, feeding changed paths against the leased write_roots.
  • Non-empty violation ⇒ gate FAILS (auto-merge disabled, status mergedopen), worktree quarantined via existing primitives, comment posted, merge_refused_write_root_escape event with offending paths.
  • Empty violation ⇒ pass-through, merge behavior unchanged.
  • Injectable/testable (git-runner seam + lease resolver) — no real git in tests.
  • No new quarantine machinery; reuses 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 of collect_changed_paths.

All 33 new/related tests pass. ruff check src tests clean; pyright clean on all changed files.

Note on pyright src/forge_loop

pyright src/forge_loop reports 2 errors that pre-exist on trunk in 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.

…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 hadamrd added the critic:blocking Critic found blocking issues label Jun 8, 2026

@hadamrd hadamrd left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minimal path to green (must-fix to merge)

  1. Fix the production changed-path collector so it can actually observe an out-of-worktree write. collect_changed_paths runs git diff --name-only with 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 CURRENT git 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 = None saga lookup in _lease_for swallows broadly and silently. It degrades safely to pass-through and mirrors codebase convention, so it's non-blocking — but a logger.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 hadamrd left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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]:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@hadamrd hadamrd merged commit 4d195c7 into trunk Jun 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

critic:blocking Critic found blocking issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fail the merge gate and quarantine on a write-root escape

1 participant