Fix guard_master.py: judge branch from a cd-chain, not session cwd - #320
Merged
Conversation
…ands Add effective_dir() so git-merge/git-push worktree rules resolve branch state from where a `cd <path> &&` or `git -C <path>` command actually runs, not the session's registered cwd -- fixes tonight's false deny of legitimate feature-branch conflict resolution.
…ffective-dir # Conflicts: # docs/troubleshooting.md
Drop freestanding `git -C <path>` support from effective_dir() -- it was an unanchored scan an unrelated earlier `-C` in a compound command could hijack, wrongly ALLOWing a real merge-into-master. Now only follows a `cd <path>` chain connected to the merge/push by an unbroken `&&` (never across ';', single '&', '|', or '||'), matching real shell semantics.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tonight's incident:
guard_master.py'sgit merge/git pushworktreerules judge branch state via
current_branch(cwd), wherecwdis theClaude Code session's registered working directory -- not wherever the
command itself redirects git to via a leading
cd <path> &&chain. Acommand like:
run from a session whose registered cwd happens to be a master checkout
got judged as if it were running on master, and was wrongly denied even
though it was legitimate feature-branch conflict resolution, not an
unauthorized merge into master.
Update (same-day follow-up, still 2026-07-22): the first cut of this
PR also added
git -C <path>support, resolved via an unanchored scanover the whole command. The orchestrator flagged that as a false-ALLOW
risk before merge, I traced and confirmed it, and this PR now ships the
tightened version:
git -Csupport is dropped entirely. See "Whatchanged since the first commit" below for the exact delta.
Fix
Adds
effective_dir(command, session_cwd, target_re):;,&,|,&&, and||(longer operators matched first so&&/||aren'tmistaken for two single-character separators).
target_re(the same "git merge"/"gitpush" the calling rule already detected).
to it by an unbroken chain of
&&-- acdbehind a;, single&,|, or||boundary is a different shell statement and isdeliberately not followed. This mirrors real shell semantics:
&&only runs the next step if the previous one succeeded, so a
cdgenuinely chained by
&&is guaranteed to have actually taken effectbefore the merge/push ran; a
cdbehind any other separator carriesno such guarantee.
cd <path>in turn (quoted or bare).session_cwd(today's pre-fix behavior) wheneverthere's no leading
cdchain, or anycdin it targets a path thatisn't a real directory -- fail-closed: a resolution failure is never
more permissive than session-cwd-only judging.
git -C <path>support (see below).Used in both
current_branch(...) == "master"checks (thegit mergerule and the
git pushworktree rule), each passing its own leadingpattern (
MERGE_LEAD_RE/PUSH_LEAD_RE) soeffective_dir()locatesthe right segment for whichever rule is asking. Explicitly unchanged:
the unconditional
gh pr mergedenial, the--ff-onlyexemption, alldeny messages,
log_stub's behavior, and the push rule'sin_worker_worktreegate (still computed from raw session cwd -- itidentifies which session is running a worker worktree, not which
directory a given command targets; routing it through
effective_dirtoo would have broken the existing "bare
git pushwhile on master,worker -> DENY" case).
What changed since the first commit (closing the git -C false-ALLOW gap)
The first cut resolved a bare
git -C <path>anywhere in the commandvia an unanchored
re.search. Traced and confirmed: an earlier,unrelated
git -C /some/repo status && git merge origin/mastergot itsunrelated
-Cpath substituted in for the real merge's own context --if that unrelated repo wasn't on master, a genuine merge-into-master
from the session's actual branch would read as non-master and get
wrongly ALLOWED. That's the more dangerous failure direction (a
false ALLOW, not a false DENY), so per the orchestrator's explicit
tightening request,
git -Csupport is now dropped entirely rather thananchored to the specific triggering invocation (anchoring was
considered; dropping it is the acceptable, smaller-blast-radius
simplification since leading-
cd-chains are the pattern that actuallyoccurred in production). This reopens a narrower, already-existing,
safe gap: a bare
git -C <path> merge/push ...command still isn'trecognized as a merge/push attempt at all (the calling rules' own
detection regexes require "git" and "merge"/"push" adjacent with no
intervening flag -- unchanged, pre-existing). Under-triggering only ever
produces an unnecessary DENY, never a wrong ALLOW, so it's accepted as
open item #1 below rather than fixed.
Minimal change, no broader process modification, per the approved shape.
Tests
.claude/hooks/test_guard_master.pyalready existed (added in #136,subprocess-driven black-box harness piping synthetic PreToolUse JSON at
the hook and asserting exit codes) -- extended it in place rather than
rewriting it as a fresh pytest suite (see Deviations below). Coverage:
git merge/git pushrules for thecd <path> &&chain form: allowed-when-resolves-off-master,denied-unchanged-baseline, fail-closed-on-nonexistent-path, for both
merge and push, plus confirming
gh pr mergeand the--ff-onlyexemption are untouched.
(a)
git -C /unrelated status && git merge x, session cwd on master-> DENIED (the false-ALLOW gap is closed).
(b)
cd /worktree && git fetch && git merge origin/master-> ALLOWED(a
cdearlier in the same&&chain still carries through anintervening non-
cdstep).effective_dir()itself (via a smallload_hook_module()helper): multi-step&&chains, quoted paths,chain-boundary behavior (
;/single-&/|/||correctly NOTcarrying a
cdacross), and confirminggit -Cis no longer resolvedat all.
All pass locally (
python3 .claude/hooks/test_guard_master.py-- 35cases, 0 failures), plus
ruff check,black --check, andisort --check --profile blackclean on both changed files. All PR CI checksgreen (
gh pr checks),mergeable: true.Deviations from spec
Test file already existed (
.claude/hooks/test_guard_master.py,Phase 1/2 orchestration groundwork: guard hook, worker roster, corrections ledger, docs manifest #136) using a subprocess-driven black-box harness, not pytest, and not
calling
main()in-process. The task asked for "plain pytest... drivingmain()". Given this repo's explicit "minimal change, no broader process
modification" constraint, and that the existing harness already
satisfies the substance of the ask (pipes synthetic stdin payloads,
asserts exit codes) with a proven, currently-green suite, I extended it
in place rather than rewriting it into pytest. Happy to convert on
request.
No CI wiring. Confirmed no workflow under
.github/workflows/currently runs
.claude/hooks/test_guard_master.py-- it has neverbeen part of CI. Per the "minimal change" instruction I did not add
wiring; flagging it here since it means this suite is currently run
manually only.
Open items / needs an owner decision
git -Creachability gap (under-triggering, safe direction,pre-existing and unchanged by this PR). The
git merge/git pushrules' own outer detection regexes (
git\s+merge,git\s+push)require "git" and "merge"/"push" adjacent with no intervening flag. A
bare
git -C <path> merge ...command therefore isn't recognized asa merge/push attempt at all today. Only reachable via a compound
command where the outer regex matches a separate
git merge/git pushelsewhere in the string (in which caseeffective_dir()nowcorrectly ignores the unrelated
-C, per the fix above). Not fixedhere -- widening those regexes to also catch
git -C ... merge/pushis a separate, broader change than this fix's remit.Whether to wire
.claude/hooks/test_guard_master.pyinto any CIworkflow (see Deviations Promote PringlePrints to a full ordering tab #2) -- currently manual-only.
Docs
docs/troubleshooting.md's entry for this incident(
guard_master.py denies a git merge/git push you ran from inside a feature worktree) is updated in place (not appended) to describe thefinal, tightened fix and the same-day
git -Cfollow-up, per the repo'sdocs convention.