fix(gate): rule 3c allowed a disarm behind a no-op chdir, and beside a neighbouring read (BACKLOG #1065, #1446) - #881
Open
wshallwshall wants to merge 2 commits into
Open
Conversation
…ts own closure (BACKLOG #1065)
Rule 3c's chdir guard answers a chdir between the first git token and the disarm by
SUPPRESSING the base candidate. The resolver cannot follow that chdir either -- its
$Prefix is sliced at the FIRST git token -- so on a line that also carries a decoy `-C`
the candidate set collapsed to the decoy alone, git rejected it, the chain ran out, and
the rule allowed. Suppressing the base is not the same as knowing where the write lands.
Measured against a throwaway governed rig, hook subprocess cwd equal to the payload cwd:
git commit -C HEAD && git config core.hooksPath /nope DENY
git commit -C HEAD && cd . && git config core.hooksPath /nope ALLOW
Thirteen rows moved back to ALLOW on origin/main -- seven chdir spellings (cd, cd ./,
pushd, chdir, sl, Set-Location, Push-Location), three cwds (primary, nested worktree,
linked worktree), the semicolon join as well as &&, and alias.* as well as
core.hooksPath. Consequence read back rather than inferred: the ALLOWed command really
runs, and core.hooksPath then reads /nope from a DIFFERENT worktree of the same
repository, which is the shared config.
The fix follows the chdir instead of declining to. Get-ChdirTargetRaw is extracted from
the resolver -- one definition, two callers, no second spelling of "follow a cd" -- and
rule 3c calls it on the guard window, composing a relative window chdir onto a prefix
chdir and appending the result as the LAST candidate. It is gated on the disarming
invocation naming no repository of its own, so an explicit -C or --git-dir still decides
and a governed chdir cannot manufacture a refusal for a write aimed elsewhere. $where[0]
never moves, so the unresolvable-target refusal is decided on exactly the token it was.
Both directions are pinned, because appending a candidate can only add denials and the
risk this carries is a refusal earned by the wrong candidate. Eight deny rows: one per
chdir verb the guard enumerates, the semicolon join asserting a second key, and a chdir
INTO the governed repo from an ungoverned cwd. Four allow rows: a chdir away to an
independent clone, an explicit -C beside a chdir, an unfollowable double chdir recorded
as a negative control rather than left to be discovered, and ordinary config after a
chdir.
A SECOND LIVE FAIL-OPEN OF THE SAME CLASS, found by the same sweep and closed here too.
The explicit READ flags were matched against the WHOLE SEGMENT, so a read belonging to a
neighbouring command excused the write beside it:
git config core.hooksPath /nope DENY
git config --list && git config core.hooksPath /nope ALLOW
Fifteen rows on origin/main -- every disarm key by every cwd. Consequence read back: run
from the linked worktree, the primary then reads /nope. The banner claims this shape was
closed, but by the REJECTED round-3 patch, which never shipped. The exclusion is now
tested against $ownCmdWin plus $rest -- the same window $ownGitDir already uses -- so
`git config --get <key>` and `git --no-pager config --get <key>` are excluded exactly as
before while the neighbour no longer reaches across the separator. Six deny rows over
the read spellings and two separators, and seven allow rows over the honest reads.
Whole-corpus regression check: a 205-row adversarial matrix -- five disarm keys by ten
poison shapes by three cwds, plus 39 allow-side controls and an ungoverned clone -- run
against origin/main's gate and this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le it left, and file #1446 The scoring row's premise was stale. Rule 3c has walked the candidate chain since 7599ad1, and all thirteen rows of the item's own acceptance corpus DENY on origin/main's gate blob 8d5c4d5. The identical corpus reproduces the filed ALLOWs against a67838d / blob 3e7db36, so the rig can produce a different answer rather than agreeing with itself. What actually stood was one layer along: a single no-op chdir between the decoy -C and the disarm reverted every one of those thirteen closures to ALLOW, with the consequence read back from a different worktree of the same repository. The amendment records the mechanism, the fix, and the residuals as "at least these" rather than as a list. It also records a second live fail-open of the same class, closed in the same act: the explicit read flags were matched against the whole segment, so `git config --list && git config <key> /nope` allowed the write -- fifteen rows, every disarm key by every cwd. The banner claims that shape was closed, but by the rejected round-3 patch, which never shipped. It also records three hashes, because the banner's pair is false and the sentence in #1061 that corrects it is now stale too: the installed hook is 460 lines behind the repo copy and carries the gate blob of 2b9f5b3, so it has this item's candidate chain and not #1379, #1359 or #1229. Nothing in this branch installs anything; the drift is reported, not repaired. #1446 is filed for the half deliberately left open: a relative -C on the disarming invocation still resolves against the SESSION cwd when a chdir in the guard window has already moved the shell. Measured in both directions and identical before and after the fix, because closing it means composing that chdir into the -C branch of the resolver, which is the widening two rejected rounds of #1065 died of. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
What this is
Two live fail-opens in
worktree_gaterule 3c, both of BACKLOG #1065's class, both measured againstorigin/mainand both proven end to end by reading the value back from a different worktree of the same repository rather than trusting a verdict.#1065's filed defect is already closed on
main, and the ledger never recorded it. All thirteen rows of the item's own measured acceptance corpus DENY onorigin/maingate blob8d5c4d50. The scalar$where[0]read the scoring row still names is gone -- rule 3c walksforeach ($cand in $where). The identical corpus run against the gate the item was FILED on (a67838d2, blob3e7db362) reproduces the filed ALLOWs, so the rig can produce a different answer rather than agreeing with itself.What actually stood was two shapes one layer along.
Fail-open 1 -- one no-op chdir reverts the whole closure
The chdir guard answers a chdir in
[first git token, disarm)by SUPPRESSING the base candidate. The resolver cannot follow that chdir either -- its$Prefixis sliced at the FIRST git token -- so the candidate set collapses to the decoy-Calone, git rejects it, the chain runs out and the rule allows.13 rows on
origin/main: seven chdir verbs (cd,cd ./,pushd,chdir,sl,Set-Location,Push-Location), three cwds (primary, nested worktree, linked worktree),&∧,core.hooksPathandalias.*.Fail-open 2 -- a neighbouring read excuses the write
The explicit read flags were matched against the WHOLE SEGMENT. 15 rows on
origin/main: every disarm key the rule names by every cwd. The banner claims this shape was closed -- but by the rejected round-3 patch, which never shipped.The fix, and why it is not a fourth rewrite
The behavioural diff is 25 non-comment lines and three things:
Get-ChdirTargetRawextracted from the resolver -- one definition, two callers, no second spelling of "follow a cd". The resolver's behaviour is unchanged (""is falsy exactly where$nullwas).-C/--git-dirstill decides.$where[0]never moves, so the unresolvable-target refusal is decided on exactly the token it was.$ownCmdWinplus$rest-- the same window$ownGitDiralready uses -- instead of the whole segment.No regex was replaced. That is deliberate: the item records both previous rounds dying because structured parsing turned out narrower than the matching it displaced.
The measured table
The item's own thirteen-row acceptance corpus, plus the two new shapes:
a67838d2origin/main8d5c4d50-cone-shot disarmcommit -C HEAD &&the disarm&& commit -C HEAD-C: alias-C: hooksPath-C: include.path-C: core.worktree-C-cis NOT a path (sanity)commit -C HEAD && cd . &&the disarmconfig --list &&the disarmcommit -C HEAD && cd <ungoverned> &&the disarm (must allow)config --get <key>(must allow)New false-denies: hunted for, and none found
A 205-row adversarial matrix -- five disarm keys by ten poison shapes by three cwds, plus 39 allow-side controls and an ungoverned clone -- was run against
origin/main's gate and against this branch, twice.Fix 1 alone (the chdir candidate) moves ZERO of the 205 rows:
0 row(s) differ between gates. Its thirteen deny rows live in a separate chdir-specific corpus, so the matrix measures exactly the blast radius and finds none.Both fixes together move 16 rows, and 15 of them are the ones this PR is for -- the
read neighbour then disarmrow for each of the five disarm keys from each of the three cwds, every one ALLOW to DENY.The 16th was the rig lying, and it is recorded rather than quietly dropped.
[alias/primary] bare--git config alias.zz /nope, no chdir, no read, nothing this PR touches -- reported DENY on main and ALLOW here. The rig calls empty stdout an ALLOW, so apwshlaunch that dies under load is indistinguishable from an allow, and three pwsh-heavy jobs were running concurrently. Re-measured in isolation, four repeats per gate, printing the raw exit code and output length rather than the verdict: DENY 4/4 on both gates,rc=0, 1251 and 1279 bytes of refusal. It is an instrument artifact. (The shipped pytest harness does not have this weakness -- it raises on a launch timeout instead, deliberately, per BACKLOG #1304.)Every ordinary command stays allowed across the matrix:
user.email,commit -C HEAD,commit --amend -C HEAD,status,log -C -M,diff -C,push,config pull.rebase,config --list,config --get <key>, the bare read, a commit message quoting the key, a disarm in an ungoverned cwd, and a governed path sitting harmlessly on the same line as an ungoverned write.The banner's hashes are false, and the note correcting them is now stale too
Measured 2026-09-04, because the brief said to re-measure rather than cite:
a67838d2, blob3e7db362origin/main(the base of this branch)8d5c4d50, sha25659c896fc, 3172 lines,$GateVersion2026.09.03.1~/.claude/hooks/worktree_gate.ps16d95811e, 198887 bytes, 2712 lines,$GateVersion2026.08.13.1The installed hook is 460 lines behind the repo copy and carries the gate blob of
2b9f5b3c4, so it has #1065's candidate chain and NOT #1379, #1359 or #1229. #1061's correction ("the installed hook hashes toe7133498, identical toorigin/main's blob ... byte-identical") was true when written and is not true now. Nothing in this branch installs anything -- the drift is reported, not repaired, and repairing it needs an owner.Also filed: #1446
A RELATIVE
-Con the disarming invocation still resolves against the SESSION cwd when a chdir in the guard window has already moved the shell. Measured in both directions and identical before and after this PR:Left open deliberately: closing it means composing that chdir into the
-CBRANCH of the resolver, widening the base every calling rule measures a-Cagainst -- the class of change two rejected rounds died of.Residuals stated, not hidden
At least these. More than one chdir in the window is not followed (the helper declines rather than guess; pinned as a negative control). A chdir target that is quoted AND contains a space is blanked on the scan string and is not followed -- measured on a rig whose governed root is literally
Pri mary. A prefix chdir the helper cannot follow suppresses the append entirely. This does not say rule 3c is unbypassable; #1066 is declined-by-design and #1067 and #1069-#1072 stay open.Checks run
ruff check .All checks passed!ruff format --check .1241 files already formattedmypy messagefoundrySuccess: no issues found in 267 source filestests/test_worktree_gate*.pysuites823 passed in 1070.68s (0:17:50)That is 810 before the read-window tests and 823 after, which is the +13 those tests add. The PowerShell file is also parsed with
[Parser]::ParseFile(PARSE OK).Not run: the rest of the pytest suite, and every hosted-runner-only leg.
windows-service-smokeand the web console leg must be read on the PR by whoever picks it up.Open questions the brief left, answered here
git stash list, and the ledger for commit-ish anchors; the only #1065 objects in the store are the LATER work that landed (eb5c8a8a3,af805fe79,661ccbb1c,7599ad190). Both banked patches are dated 2026-08-06 and are not recoverable from this clone. I did not assume they were gone -- I am reporting that I could not find them.🤖 Generated with Claude Code