Skip to content

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
mainfrom
claude/1065-chdir-window-candidate
Open

fix(gate): rule 3c allowed a disarm behind a no-op chdir, and beside a neighbouring read (BACKLOG #1065, #1446)#881
wshallwshall wants to merge 2 commits into
mainfrom
claude/1065-chdir-window-candidate

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

What this is

Two live fail-opens in worktree_gate rule 3c, both of BACKLOG #1065's class, both measured against origin/main and 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 on origin/main gate blob 8d5c4d50. The scalar $where[0] read the scoring row still names is gone -- rule 3c walks foreach ($cand in $where). The identical corpus run against the gate the item was FILED on (a67838d2, blob 3e7db362) 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

git commit -C HEAD && git config core.hooksPath /nope           DENY
git commit -C HEAD && cd . && git config core.hooksPath /nope   ALLOW   <- the same write

The chdir guard answers a chdir in [first git token, disarm) by SUPPRESSING the base candidate. The resolver cannot follow that chdir either -- its $Prefix is sliced at the FIRST git token -- so the candidate set collapses to the decoy -C alone, 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), && and ;, core.hooksPath and alias.*.

Fail-open 2 -- a neighbouring read excuses the write

git config core.hooksPath /nope                        DENY
git config --list && git config core.hooksPath /nope   ALLOW

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:

  1. Get-ChdirTargetRaw extracted from the resolver -- one definition, two callers, no second spelling of "follow a cd". The resolver's behaviour is unchanged ("" is falsy exactly where $null was).
  2. Rule 3c follows the guard-window chdir and appends it as the LAST candidate, composing a relative window chdir onto a prefix chdir. Gated on the disarming invocation naming no repository of its own, so an explicit -C/--git-dir still decides. $where[0] never moves, so the unresolvable-target refusal is decided on exactly the token it was.
  3. The read exclusion is tested against $ownCmdWin plus $rest -- the same window $ownGitDir already 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:

row filed gate a67838d2 origin/main 8d5c4d50 this branch
CONTROL bare disarm DENY DENY DENY
CONTROL -c one-shot disarm DENY DENY DENY
CONTROL alias disarm DENY DENY DENY
chained: commit -C HEAD && the disarm ALLOW DENY DENY
chained: the disarm && commit -C HEAD ALLOW DENY DENY
value carries -C: alias ALLOW DENY DENY
value carries -C: hooksPath ALLOW DENY DENY
value carries -C: include.path ALLOW DENY DENY
value carries -C: core.worktree ALLOW DENY DENY
commit msg carries -C ALLOW DENY DENY
from the NESTED worktree ALLOW DENY DENY
from the LINKED worktree ALLOW DENY DENY
lowercase -c is NOT a path (sanity) DENY DENY DENY
commit -C HEAD && cd . && the disarm ALLOW ALLOW DENY
config --list && the disarm ALLOW ALLOW DENY
commit -C HEAD && cd <ungoverned> && the disarm (must allow) ALLOW ALLOW ALLOW
config --get <key> (must allow) ALLOW ALLOW 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 disarm row 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 a pwsh launch 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:

what value
the #1065 banner's claim commit a67838d2, blob 3e7db362
origin/main (the base of this branch) blob 8d5c4d50, sha256 59c896fc, 3172 lines, $GateVersion 2026.09.03.1
INSTALLED at ~/.claude/hooks/worktree_gate.ps1 sha256 6d95811e, 198887 bytes, 2712 lines, $GateVersion 2026.08.13.1

The 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 to e7133498, identical to origin/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 -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 this PR:

cwd = ungoverned:  git commit -C HEAD && cd "<primary>"    && git -C . config <key> /nope   ALLOW, lands in the primary
cwd = primary:     git commit -C HEAD && cd "<ungoverned>" && git -C . config <key> /nope   DENY, lands elsewhere

Left open deliberately: closing it means composing that chdir into the -C BRANCH of the resolver, widening the base every calling rule measures a -C against -- 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

check result
ruff check . All checks passed!
ruff format --check . 1241 files already formatted
mypy messagefoundry Success: no issues found in 267 source files
all 18 tests/test_worktree_gate*.py suites 823 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-smoke and the web console leg must be read on the PR by whoever picks it up.

Open questions the brief left, answered here

  1. The banked round-2 and round-3 patches could not be found. I swept every ref (8547 of them), 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.
  2. The score was not moved. Its stated basis was factually wrong and is corrected; the numbers are left where the re-scoring pass put them, because what remains open under #1065 is a different quantity from what 7/6 was measured against. Re-pricing it is a Console call.
  3. #1065 is amended, not closed. Two residuals plus #1446 remain under its title, and the item's own standing warning is that no banner may imply rule 3c is unbypassable.

🤖 Generated with Claude Code

wshallwshall and others added 2 commits September 4, 2026 18:36
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant