cmux guards: run merge guard hooks from the feat-cmux-next tip

Merged
TE
16d ago
+203-6

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.sh from origin/feat-cmux-next into a temporary file and runs it. For a merge, it first fetches feat-cmux-next, bounded to 10 s. Commands that mention neither a merge nor update-branch exit before git runs.
  • scripts/ci/guards-from-base.sh extracts both hooks and their Python guards from that same commit into a temporary directory, with git 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.py refuses 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-next is 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.py and tests/test_format_post_edit.py all 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.json with bash.

Changelog

none

Agent session: huge-blue

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
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 runs scripts/ci/guards-from-base.sh from that commit, which executes both merge guards via git archive into 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.json still 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.