cmux guards: run merge guard hooks from the feat-cmux-next tip
The merge guards now run from the feat-cmux-next tip, never from the session's checkout. palette-audit found that .claude/settings.json ran "$CLAUDE_PROJECT_DIR"/scripts/ci/merge-compile-guard-hook.sh and base-merge-warning-hook.sh from the session's checkout. A session in a pull request's worktree therefore ran that branch's copy of the guard: a branch made before a guard fix kept the old guard, and a pull request could edit the guard that judges its own merge.
Fix
- The one PreToolUse(Bash) hook reads
scripts/ci/guards-from-base.shfromorigin/feat-cmux-nextinto a temporary file and runs it. For a merge, it first fetchesfeat-cmux-next, bounded to 10 s. Commands that mention neither a merge nor update-branch exit before git runs. scripts/ci/guards-from-base.shextracts both hooks and their Python guards from that same commit into a temporary directory, withgit archive, and runs them. The first answer, a refusal or a note, is the answer.- A merge whose guards can't be read from the base is refused, as
merge_compile_guard.pyrefuses one it can't read. The update-branch warning only warns.
Known limit: .claude/settings.json itself still loads from the session's checkout, so a branch cut before this change keeps the old hooks until it merges the base. A PR could also delete the hook.
Tests: tests/test_guard_hooks_from_base.py is new and wired into cmux-next.yml. It builds a local remote and a PR worktree whose branch rewrites both guards and their hook to let everything through. The test checks:
- the base copy still judges that PR's merge;
- a guard fix pushed to the base later judges a branch made before it;
- the update-branch warning also comes from the base;
- unrelated commands pass;
- a stale
origin/feat-cmux-nextis fetched before a merge; - a merge whose guards can't be read is denied, while update-branch is not blocked;
- no PreToolUse hook names a script under
$CLAUDE_PROJECT_DIR.
Red: python3 tests/test_guard_hooks_from_base.py on 284ed32daab (base code) failed with "the pull request's own copy of the guard judged its merge", and on 648e6f76cb8 two cases failed (the stale origin and unreadable guards both failed open). Green on the head: 8 OK.
Observed / Not run
- Observed:
- The red and green runs above.
tests/test_merge_compile_guard.py,tests/test_base_merge_warning.pyandtests/test_format_post_edit.pyall pass (1 skip: webviews packages are not installed).- An independent review subagent passed it (SHIP at b73989b973a, after a BLOCK at 648e6f76cb8 for the fail-open fixed above).
- Not run: a live Claude Code session loading the new settings. The test runs the exact hook command from
settings.jsonwith bash.
Changelog
none
Agent session: huge-blue
🤖 Generated with Claude Code
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Merge guards now run from the feat-cmux-next tip instead of the session's checkout, so a pull request's branch can no longer run its own edited copy of the guard that judges its merge.
- The PreToolUse hook fetches
origin/feat-cmux-next(bounded to 10 s) and runsscripts/ci/guards-from-base.shfrom that commit, which executes both merge guards viagit archiveinto a temporary directory. - Commands that mention neither a merge nor update-branch exit before git runs, so unrelated commands are unaffected.
- A merge whose guards can't be read from the base is denied; the update-branch warning only warns.
- Known limit:
.claude/settings.jsonstill loads from the session's checkout, so a branch cut before this change keeps the old hooks until it merges the base.
Tests
- New
tests/test_guard_hooks_from_base.py(wired into cmux-next.yml) covers base-copy judging of merges, guard fixes on the base applying to older branches, stale origin fetching, fail-closed merges, and no hook running a script from the checkout.