The pre-push review gate (.claude/hooks/agent-bash-gate.sh + .claude/hooks/review-marker.sh) can attest to code it never reviewed. Two independent holes, both found while pushing #440's fix. Neither requires malice — I hit both by working normally.
The gate's whole value is that an agent can't fake a marker (AGENTS.md §"Don't bypass the gates" — hooks run at Claude Code privilege level precisely so the orchestrator can't write one itself). Both bugs below let a marker exist for something unreviewed without anyone hand-writing it, which is the failure mode the design is meant to exclude.
1. A marker can be inherited by a commit the reviewer never saw
The more serious of the two, because it fires silently during ordinary work.
review-marker.sh resolves the SHA at the moment the subagent stops:
head_sha=$(git rev-parse HEAD 2>/dev/null || echo "")
A reviewer takes 4–9 minutes. If HEAD moves during that window — the orchestrator commits an unrelated fix, addresses another reviewer's finding, or (as happened here) lands a doc tweak — the ship_it verdict for the old HEAD is written as a marker for the new one.
Observed
Docs reviewer read 40da1924 and returned ship_it. I committed f4e64d5b while it was still running. The marker it wrote:
tmp/docs-reviewer-passed-f4e64d5bd3095f0c3369b6fb448be9bae08fcb4d
f4e64d5b was never reviewed by anything. Nothing in the output flags this — the marker looks identical to a legitimate one, and the push gate would have accepted it.
In my case the delta was one documentation sentence and the same reviewer had already considered the point, so nothing unreviewed would actually have shipped. That was luck, not a property of the system: the same sequence with a code commit lands unreviewed code behind a green gate.
Note this is more likely the better an agent behaves. Running reviewers in parallel and continuing useful work while they run — exactly what /prepush step 3 asks for — is what opens the window.
Suggested fix
Capture HEAD when the subagent starts and write the marker only if it still matches at stop; otherwise log loudly and write nothing. If SubagentStop can't see start-time state, review-marker.sh could instead record the SHA the reviewer reported in its own output and cross-check.
Cheaper interim mitigation: have /prepush state explicitly that HEAD must not move while reviewers are in flight.
2. The gate inspects the session's worktree, not the branch being pushed
Both hooks begin:
cd "${CLAUDE_PROJECT_DIR:-.}"
CLAUDE_PROJECT_DIR is the session's project directory. In a multi-worktree session — the standard parallel-agent workflow this repo endorses via wt switch --create in .config/wt.toml — an agent can cd into a sibling worktree and git push a branch the gate never examines.
Observed
From .worktrees/sdk-fetch-override (branch sdk-fetch-override, HEAD 154cd451):
$ git push --dry-run origin sdk-fetch-override
🛑 Claude PR discipline gate: missing pre-push review marker(s) for HEAD (a2b960a3) on branch 'local-ci-fail':
a2b960a3 / local-ci-fail are the other worktree's HEAD and branch. The gate blocked, but for the wrong reason and against the wrong branch. The corollary is the problem: once the session's own branch has its markers, a push of any sibling branch sails through unreviewed, because the gate keeps checking the branch that already passed.
The same cd also means a reviewer run "for" a sibling branch writes its marker into the session worktree's tmp/, keyed to the session branch's HEAD — so you can't satisfy the gate for the sibling branch even if you want to.
Suggested fix
Resolve the repo from the push command's own working directory rather than CLAUDE_PROJECT_DIR. Or, more conservatively, refuse the push outright when its cwd resolves to a different worktree than CLAUDE_PROJECT_DIR — that enforces one-session-per-branch, which the marker mechanism already assumes.
Not affected
tmp/ci-passed-tree-<TREE> markers are keyed to the tree hash and written by scripts/ci-marker.sh from inside the worktree that ran make ci, so they are per-worktree and content-addressed. Both bugs are specific to the HEAD-keyed review markers.
Repro
- Inherited marker: start any gating reviewer, commit anything while it runs, observe
tmp/<reviewer>-passed-<new-HEAD> for a SHA that reviewer never read.
- Cross-worktree: from a second worktree on a different branch,
git push --dry-run — the gate reports the session worktree's branch and HEAD.
Found while landing #440.
The pre-push review gate (
.claude/hooks/agent-bash-gate.sh+.claude/hooks/review-marker.sh) can attest to code it never reviewed. Two independent holes, both found while pushing #440's fix. Neither requires malice — I hit both by working normally.The gate's whole value is that an agent can't fake a marker (AGENTS.md §"Don't bypass the gates" — hooks run at Claude Code privilege level precisely so the orchestrator can't write one itself). Both bugs below let a marker exist for something unreviewed without anyone hand-writing it, which is the failure mode the design is meant to exclude.
1. A marker can be inherited by a commit the reviewer never saw
The more serious of the two, because it fires silently during ordinary work.
review-marker.shresolves the SHA at the moment the subagent stops:head_sha=$(git rev-parse HEAD 2>/dev/null || echo "")A reviewer takes 4–9 minutes. If HEAD moves during that window — the orchestrator commits an unrelated fix, addresses another reviewer's finding, or (as happened here) lands a doc tweak — the
ship_itverdict for the old HEAD is written as a marker for the new one.Observed
Docs reviewer read
40da1924and returnedship_it. I committedf4e64d5bwhile it was still running. The marker it wrote:f4e64d5bwas never reviewed by anything. Nothing in the output flags this — the marker looks identical to a legitimate one, and the push gate would have accepted it.In my case the delta was one documentation sentence and the same reviewer had already considered the point, so nothing unreviewed would actually have shipped. That was luck, not a property of the system: the same sequence with a code commit lands unreviewed code behind a green gate.
Note this is more likely the better an agent behaves. Running reviewers in parallel and continuing useful work while they run — exactly what
/prepushstep 3 asks for — is what opens the window.Suggested fix
Capture HEAD when the subagent starts and write the marker only if it still matches at stop; otherwise log loudly and write nothing. If
SubagentStopcan't see start-time state,review-marker.shcould instead record the SHA the reviewer reported in its own output and cross-check.Cheaper interim mitigation: have
/prepushstate explicitly that HEAD must not move while reviewers are in flight.2. The gate inspects the session's worktree, not the branch being pushed
Both hooks begin:
CLAUDE_PROJECT_DIRis the session's project directory. In a multi-worktree session — the standard parallel-agent workflow this repo endorses viawt switch --createin.config/wt.toml— an agent cancdinto a sibling worktree andgit pusha branch the gate never examines.Observed
From
.worktrees/sdk-fetch-override(branchsdk-fetch-override, HEAD154cd451):a2b960a3/local-ci-failare the other worktree's HEAD and branch. The gate blocked, but for the wrong reason and against the wrong branch. The corollary is the problem: once the session's own branch has its markers, a push of any sibling branch sails through unreviewed, because the gate keeps checking the branch that already passed.The same
cdalso means a reviewer run "for" a sibling branch writes its marker into the session worktree'stmp/, keyed to the session branch's HEAD — so you can't satisfy the gate for the sibling branch even if you want to.Suggested fix
Resolve the repo from the push command's own working directory rather than
CLAUDE_PROJECT_DIR. Or, more conservatively, refuse the push outright when its cwd resolves to a different worktree thanCLAUDE_PROJECT_DIR— that enforces one-session-per-branch, which the marker mechanism already assumes.Not affected
tmp/ci-passed-tree-<TREE>markers are keyed to the tree hash and written byscripts/ci-marker.shfrom inside the worktree that ranmake ci, so they are per-worktree and content-addressed. Both bugs are specific to the HEAD-keyed review markers.Repro
tmp/<reviewer>-passed-<new-HEAD>for a SHA that reviewer never read.git push --dry-run— the gate reports the session worktree's branch and HEAD.Found while landing #440.