fix(gate): rule 3b printed an unrunnable remediation, and deferred to a guard the caller can switch off - #214
Merged
Merged
Conversation
…git ref -Name is a PATH component (the sibling <repo>-<Name>) and a BRANCH ref at the same time, so a namespaced branch could not be passed at all: the ValidatePattern rejects '/', and relaxing it would silently create a NESTED directory instead of the intended sibling -- trading a loud correct failure for a quiet wrong success. Split the roles. -Name stays the directory component and keeps its pattern; -Branch is the git ref, validated by `git check-ref-format` rather than a second hand-rolled grammar, and defaults to -Name so spawn.ps1, rescue.ps1 and every documented invocation are unchanged. The branch probe, both `worktree add` calls and the mefor-home-branch marker now take -Branch. The marker especially: worktree-selfheal.ps1 compares it against the real HEAD, so a directory slug there would mismatch BY CONSTRUCTION and fire a false hijack warning at every SessionStart, naming a branch that does not exist. Also: - Assert the sibling invariant directly (Split-Path parent) rather than trusting the character class to imply it. A proxy can be relaxed by someone who does not know what it stood in for. - Fix the anchors in all four copies of that pattern. Measured: "abc" + newline MATCHES ^[A-Za-z0-9._-]+$, because .NET's '$' also matches before a FINAL newline, so the load-bearing guard did not enforce its own stated shape. \A..\z does. - remove.ps1 -DeleteBranch reads the branch from git instead of assuming it equals the directory name, and reads it BEFORE removal destroys the per-worktree reflog. It adopts prune-merged.ps1's lossless discipline: -d first, -D only after re-verifying the branch has nothing beyond origin/main; otherwise the branch is KEPT and named, with its tip printed. This is required in the same change, because the split ARMS a force-delete that was previously inert -- three sibling worktrees here already have HEAD != directory name and all three carry unmerged commits. - Correct two comments asserting new.ps1 creates the nested .claude/worktrees/ layout. It does not; that is the Claude Code harness. Both layouts are live, and the false premise kept getting re-derived. - Correct the scan_forbidden.py and test_scan_tokens_source.py premise about which parameter reaches `git worktree add -b`. The detector is unaffected. The gate remediation that motivated this (worktree_gate.ps1 rule 3b prints a new.ps1 command it cannot run) lands separately, with its execution tests.
The branch-reuse remediation emitted `new.ps1 -Name $dest` with $dest a REAL refname. -Name is a directory component validated as \A[A-Za-z0-9._-]+\z, so for the 143 of this repo's 196 local branches that contain a '/' the printed command dies at parameter binding. It fires exactly when the branch already exists, which is the case a blocked session is most likely to be in. Emit `-Branch '<ref>' -Name <slug>` against new.ps1's new split, deriving the directory component with a gate-local ConvertTo-WorktreeSlug. The gate SANITIZES and new.ps1 VALIDATES: the slug's codomain is inside new.ps1's pattern by construction, so no rule is restated in two places. It lives in the hook because install-gate.ps1 copies that file outside every working tree, where it can dot-source nothing. Three further corrections, all measured, none cosmetic: - QUOTE THE -File PATH. A governed root containing a space made pwsh exit 64 with a usage dump before -Name was ever bound. Six other remediations share this shape and stay latent; filed rather than fixed here. - DOUBLE THE QUOTES IN THE EMITTED REF. `git check-ref-format` accepts ';', '$', '|', '"' and "'" in a refname, and the token trim at ~:349 strips only the ends. Measured: the legal branch `x';calc;#` emitted a line parsing as TWO statements, the second arbitrary, with '#' hiding the remainder -- command injection into text this message instructs an agent to run. `a'b` emitted an unparseable line. Doubled, both parse as exactly one statement. - RETURN WHEN THE BRANCH IS CHECKED OUT ELSEWHERE. The deny text asserts "git allowed it because '$dest' was not checked out anywhere", which the rule never verified. If it IS checked out, git's own guard refuses the switch without us, and the remediation would print a command failing with "already checked out at" -- the same defect class, for the common `git checkout main` shape. The porcelain was already read, so this costs nothing and makes the claim true. TESTS EXECUTE WHAT THE GATE PRINTS. Asserting a copy of the expected string would have passed throughout this defect's life -- the string was never wrong, the receiving contract rejected it. So the emitted line is extracted, written to a .ps1 and run, with assertions on effects: exit code, sibling-not-nested placement, branch reuse rather than a fresh cut off origin/main, and the mefor-home-branch marker (previously untested anywhere). Parametrised over a plain and a space-bearing primary because each pins a different defect: without the plain leaf the reported ValidatePattern failure is never exercised at all. A control test proves the harness can see a failure, since without an explicit `exit $LASTEXITCODE` pwsh reports 0 for a script that died at binding and every execution assertion would be vacuous. Also corrects a comment claiming new.ps1 creates the nested .claude/worktrees/ layout. It does not; that is the harness. Both layouts are live. $GateVersion is deliberately untouched -- claude/sharp-chatelet-f33072 owns that line this round, by agreement.
…h off 3de8fc7 added an early return to rule 3b: if the destination branch is already checked out in some worktree, return without denying, on the reasoning that git's own guard refuses that switch anyway and denying would print a remediation dying with "already checked out at". The reasoning holds for git's DEFAULT behaviour and fails in general, because the caller can disable the guard. Measured against a branch live in another worktree: checkout/switch <b> -> fatal, git refuses checkout/switch --force / --discard-changes -> fatal, git refuses checkout/switch --ignore-other-worktrees <b> -> SWITCHES checkout/switch --detach <b>, and -d <b> -> SWITCHES The destination scanner skips '-'-prefixed tokens, so $dest resolves normally in every one of those and the early return handed them a free pass. Net effect of 3de8fc7: hijacking a branch LIVE in another worktree became ALLOWED. That is worse than the case rule 3b was written for -- a live worktree loses its branch mid-task rather than a free branch being grabbed. --detach does not take the branch lock at all, but it still swaps the other session's files to that commit, which is the harm. Fixed as an ALLOWLIST, not a flag list: the early return fires only when there are NO flags after the verb. A denylist was written twice here and was wrong twice -- --detach was missed while fixing --ignore-other-worktrees, and `-d` would have been missed while fixing --detach. Git may add a third and the gate would silently reopen. The cost is a needless deny on `git checkout --quiet main`, whose remediation line is then the imperfect one; that is strictly better than a missed hijack, and unlike a flag list it does not decay. The transferable form: "git already refuses this" is a claim about a CONFIGURATION, not about git. A guard you do not own can be switched off by its own caller, and most guards have an off switch. Tests are parametrised over both verbs, because --ignore-other-worktrees and --detach are accepted by checkout and switch alike and covering one spelling would leave the hole open under the other word. Confirmed failing for both before the fix ("expected a DENY, got allow"), passing after. The companion test asserts the no-flag case is still ALLOWED, so the early return is not merely deleted.
wshallwshall
enabled auto-merge (squash)
August 5, 2026 22:48
wshallwshall
added a commit
that referenced
this pull request
Aug 5, 2026
… fix (#216) PR #214 fixed #1032 and deliberately left five adjacent findings unfixed so the change stayed scoped to one rule. A sixth emerged from it. Deferrals nobody files are deferrals dropped, so they get numbers. #1035 gate remediations interpolate an unquoted -File path (exit 64 on a governed root containing a space; latent only because the one allowlist entry has none) #1036 a Rule 4 deny names the FIRST allowlisted repo's tooling regardless of which repo fired it; latent at one entry #1037 remove.ps1 cannot be execution-tested, and it just gained the branch force-delete path, so the most destructive script in scripts/worktree/ is review-covered only #1038 Rule 3b names new.ps1's sibling layout while the harness creates nested worktrees; both are live and the contradiction was written into two source comments independently #1039 git worktree add --force also defeats the already-checked-out guard; "git will refuse this" is a claim about a CONFIGURATION and must be written as conditional #1040 hook deny text is attacker-influenceable output an agent is instructed to act on, and nothing treats it as such #1040 is the valuable one and is filed separately from the five rather than folded in. Two injections into gate deny text were found independently on the same file within hours, by two sessions, through different values -- a refname into a command block and a file_path into prose. The repo already folded the Write-Deny LOG line against exactly this, noting a crafted path could forge records; the REASON never got the same treatment. Every hook that emits a remediation an agent is told to run has the shape. Also moves #1032's banner from "filed, not started" to in-progress, naming PR #214 as unmerged. It does NOT claim shipped: that banner moves on merge and not before. Numbers allocated atomically via scripts/coord/alloc.ps1 from this worktree, never by grepping. Banner invariant checked with the repo's own parse_items via scripts/docs/backlog_status_check.py: 315 items, each declaring exactly one status.
wshallwshall
added a commit
that referenced
this pull request
Aug 6, 2026
…he opposite (#222) #1032's banner read "In progress -- fixed in PR #214, NOT yet merged". #214 merged as fdaf53f, which made that sentence false on main: a reader consulting the ledger was told the fix was pending when it had shipped. The in-progress wording was deliberate when written -- claiming shipped before merge would have been false in the other direction -- which is exactly why the correction was owed to whoever merged it. Recorded as an obligation in the coordinator handoff rather than only in a session, because #214 carried auto-merge and could land with nobody present. Verified in main before flipping: ConvertTo-WorktreeSlug is present in scripts/hooks/worktree_gate.ps1, so the fix is genuinely in and not merely PR-closed. Banner invariant checked with parse_items, not a hand-rolled scan.
wshallwshall
added a commit
that referenced
this pull request
Aug 6, 2026
PR #214 landed rule 3b's fixes, so main's worktree_gate.ps1 now carries ConvertTo-WorktreeSlug where this branch carries Get-SafeForMessage. Both were inserted immediately after Get-ComparablePath, which is the conflict two sessions predicted in advance and agreed the resolution for. RESOLVED KEEP BOTH. They are additive and independent: ConvertTo-WorktreeSlug maps a verified refname to a legal worktree directory component for rule 3b's remediation; Get-SafeForMessage folds a caller-supplied value before it reaches a deny reason for rules 1a/1b. Neither replaces the other and neither is a revert of the other. Kept in the conflict's own order so main's trailing brace still closes main's function and the diff against main stays minimal. $GateVersion stays 2026.08.05.2 by agreement -- the other session ceded that line rather than re-bump it. VERIFIED ON PRESENCE OF BOTH MARKERS, never on content equality against either branch. A correct keep-both merge matches NEITHER parent byte-for-byte, so an equality check would report failure precisely when the merge succeeded -- a check that cannot fire on success is not a check. Measured on the merged file: ConvertTo-WorktreeSlug 2 occurrences (definition + rule 3b call site) Get-SafeForMessage 3 occurrences (definition + rules 1a and 1b call sites) 0 parse errors, 0 non-ASCII bytes 239 passed across the 9 runnable gate files -- both suites on one file, up from 230 because #214 added nine tests of its own. My 35-case decision probe unchanged at 35/35. backlog_status_check OK at 316 items each declaring exactly one status, zero duplicate numbers, and #1041 still present exactly once and open. docs/BACKLOG.md auto-merged: #218's additions land inside an existing item rather than as new headings, so no heading collided.
wshallwshall
added a commit
that referenced
this pull request
Aug 6, 2026
…lision PRs #222 and #223 landed since the last merge, so docs/BACKLOG.md collided at EOF again: #223 appends #1051-#1055 where this branch appends #1041. One conflicted file, resolved KEEP ALL SIDES, no renumbering and no re-sorting -- the file is not ordered by number and nothing enforces one, so a re-sort would be a large invented diff over a file several branches are appending to concurrently. Resolved here rather than by the coordinator for the same entitlement reason as before: a resolution that RE-INTRODUCES the `## 1041.` heading is a head-minus-base addition, so ledger_check.py consults owns() for it (:340, :355), and the allocation record names this worktree. No other worktree can commit it. THE REVERT CHECK RAN FIRST, because the last merge is what taught me to. This branch carried content main had since changed, and if my side won those lines the merge would silently revert them while the diff looked ordinary. Built the merged tree and read it rather than inferring from a clean-looking diff: headings on main but LOST here NONE headings this branch adds vs main ## 1041. -- exactly one #1032 banner "SHIPPED 2026-08-05 - merged as PR #214" (#222's close survives; my side still carried the pre-#222 banner) #1051-#1055 from #223 5 of 5 present "Lost nothing, gained exactly one" is the check that answers the revert question directly. An item count cannot: it held steady at 121 across the previous merge because #218's additions landed INSIDE item #1000 rather than as new headings. This time it grew 121 -> 126, matching #223's five, which is consistent rather than coincidental. Verified with the repo's own parser, never a hand-rolled scan of this file: zero conflict markers, backlog_status_check OK at 321 items each declaring exactly one status, parse_items reporting zero duplicate numbers, nothing in #1032-#1055 appearing more than once, and #1041 still open -- it lands open by design and is closed by the session building the rule 3d fix. KEEP-BOTH RE-CHECKED ON THE GATE, by AST rather than by grep. A string search cannot tell a definition from a call site or a comment, and the specific hazard here is that git left the two helpers' shared closing brace outside an earlier conflict region -- a careless resolve yields a file that parses cleanly with one function swallowed into the other's body. The parser reports both ConvertTo-WorktreeSlug and Get-SafeForMessage as FunctionDefinitionAst, 8 functions total, 0 parse errors.
wshallwshall
added a commit
that referenced
this pull request
Aug 7, 2026
…below the fix The last unfiled finding from the deny-reason audit. It has been recorded only in a Desktop writeup since it was found; filing it because a finding that lives outside the ledger is invisible to everyone who was not in that conversation. PR #214 quoted $dest at worktree_gate.ps1:475 to close a refname-into-a-command injection. Line 477 emits the same value BARE, inside the same remediation block the same message tells an agent to run. Measured against the installed gate, from a branch named pwn$(calc): ... -Branch 'pwn$(calc)' -Name pwn-calc <- :475, quoted git -C "..." show pwn$(calc):<path> ... HEAD..pwn$(calc) <- :477, BARE $( ) is command substitution in both PowerShell and bash. The refname is attacker-chosen from a public fork: gh pr checkout, git checkout --track and git fetch origin <ref>:<ref> all create refs/heads/<their-name>, and the SECOND checkout of it trips rule 3b. FILED AS ITS OWN NUMBER RATHER THAN FOLDED INTO #1035, which is the nearest existing item. #1035 is a RUNNABILITY defect -- the gate's own root path containing a space breaks the command it prints. This is an INJECTION -- an attacker-chosen value executes. Different cause, different severity, different fix, and #1035's own scope note warns against sweeping adjacent shapes into it. Cross-referenced both ways. The item records that FOLDING IS THE WRONG INSTRUMENT and would look like a fix: Get-SafeForMessage neutralises line structure and length and does not touch $, backtick, & or '. The fix is quoting, as :475 already does. It also records the second unsafe interpolation on the same line -- $selfTopRaw inside double quotes, where $( ) and backticks still expand -- and that the earlier "a quote cannot occur in a Windows path" reasoning is true and irrelevant, because the quote is not the operative character. Confirmed in a second independent copy: the generalized fork carries the same remediation shape but strips shell metacharacters in its fold, so it is not exploitable there -- a working existence proof that a metacharacter-stripping fold is a viable second layer. The item asks for both, and says why quoting is the primary. PR #214's quoting fix is itself untested, which is how the miss one line below it survived review. The item requires a test asserting the emitted STRING, not merely that the call denied -- the exact failure mode round 3's mutation testing found in a test whose docstring named the class it could not detect.
wshallwshall
added a commit
that referenced
this pull request
Aug 7, 2026
…led open (BACKLOG #1061) (#270) * fix(gate): rule 3c resolved the target against the wrong base and failed open (BACKLOG #1061) `git -C ../../.. config core.hooksPath /dev/null` was ALLOWED where the same command with an absolute path was DENIED. That disarms core.hooksPath for the shared clone -- the ledger, claim and secret-leak commit gates for every worktree at once -- and it needs no shell variable and no intent, since `../../..` is simply how a session in <primary>/.claude/worktrees/<x> names the repo root. `rev-parse --git-common-dir` answers RELATIVE TO THE TARGET: from the primary it returns the bare string ".git". Rule 3c resolved that against the target token AS WRITTEN, and GetFullPath demands a fully qualified base, so a relative target threw, the catch returned "", no governed root matched "", and the rule allowed. The hole was scoped to the primary precisely BECAUSE only the primary answers relatively, which is also why the crux test -- disarming FROM a linked worktree -- stayed green over it. The fix roots the target against the session cwd first, then resolves the common dir against THAT. One token is not enough: resolving ".git" against the session cwd instead yields <session-worktree>/.git, a real path that is not the primary's common dir, so the gate would read as fixed and stay open for a second, harder-to-see reason. The rooted path also goes to `git -C`, closing a second fail-open found while fixing this one: `& git -C <relative>` resolved against the HOOK PROCESS's cwd rather than the session's, so a relative path naming a linked worktree made git exit 128 and fall through the allow. Get-ComparablePath is split so Get-FullPathRaw is the shared core without the lowercasing tail, because a lowercased path handed to `git -C` passes on Windows and silently misses the real directory on a case-sensitive filesystem. The two failure conditions are now answered differently, which is the substance of the fix rather than its arithmetic. A target that cannot be resolved at all now DENIES: nothing has been asked of git, so nothing has said the repository is ungoverned, and treating that silence as "not governed" is exactly how this shipped. Git ANSWERING "not a repository" on a resolved target still ALLOWS, unchanged and still pinned by test_a_non_repo_cwd_fails_open. Fourteen regression cases across eight tests, each confirmed against the pre-fix gate first: the relative spellings and the fail-closed case FAIL there, while the absolute target, the linked-worktree target and the narrowness controls keep PASSING. That distinction is what separates a correction from a widening. Reported, not fixed here: rule 3d carries the byte-similar construct, and its filing's assumption that the target is always a linked worktree is false; and rule 3c inspects only candidate [0], so a --git-dir spelling from an ungoverned cwd is never seen. Both need their own item and their own asymmetry tests. * backlog: file #1064, rule 3d carries rule 3c's relative-path construct Found while fixing #1061, by checking whether the defect being closed had siblings. worktree_gate.ps1:625 resolves the victim's common dir with `Get-ComparablePath $victimCommon $victimRaw` -- the same construct against the same kind of base that rule 3c used before #1061, so a relative target cannot resolve and falls through to ALLOW. Filed separately rather than folded into #1061 so the fix carries its own asymmetry tests, and so the unconfirmed half stays visible instead of being inherited as fact. VERIFIED by reading: the construct and the base-is-not-a-base mechanism are identical to the one measured for rule 3c. RELAYED, NOT CONFIRMED: the #1061 pass reported measuring `git worktree remove <relative-primary>` as ALLOW. Reproducing it here was refused by the permission layer with the owner away, and was not retried or routed around, so the item says so rather than restating a relayed measurement as its own. The premise is the part that needs no measurement. The rule was written believing its target is always a LINKED worktree, which is why the relative-path hole looked scoped away exactly as #1061's did. The primary is itself a registered worktree -- the first row of `git worktree list` -- so that belief is false as a general statement. And it must not be closed by arguing git refuses to remove a main working tree. #1041 retired that exact defence for this exact rule: a PreToolUse hook decides whether anything reaches git at all, so git's refusal never happens and the premise is never tested. A rule cannot defer to a guard that runs only after it has already decided. * backlog: file #1065 and scope #1061's banner to the spelling it actually fixed The #1061 banner said rule 3c now resolves its target against the session cwd. True, and too wide as a reader would take it. Rule 3c consumes only candidate [0] of a resolver whose own contract at :219 says "It returns a SET, not a winner, and the caller denies if ANY member is governed", so any ` -C <token>` elsewhere on the line still disables the rule outright. Measured against the shipped fix, not argued: git commit -C HEAD && git config core.hooksPath /nope -> ALLOW git config core.hooksPath "/nope -C HEAD" -> ALLOW Every disarm key, both the persistent and one-shot -c forms, from the primary and from nested and linked worktrees. Demonstrated end to end with a real refusing pre-commit hook: the gate denies the plain disarm, allows it with one ordinary flag appended, and the next commit then succeeds with the hook disarmed -- in the linked worktree too, because it is the shared config. The defect predates this work and is not caused by it. But #1061 rewrote ~70 lines of that exact block, added eight tests whose stated purpose is "the PATH TOKEN, not just the cwd" -- every one supplying a single -C, so none can see a second token -- and flipped the banner to SHIPPED. A banner a reader would take as "rule 3c denies a disarm aimed at a governed repo" is a compensating control resting on a false premise, which CLAUDE.md section 11 forbids. The banner now claims the relative-path spelling and nothing wider, and points at #1065. Rules 3 and 3d are NOT affected, confirmed by measurement: the same poisoned commands against reset --hard, checkout and worktree remove all still DENY, because they iterate the candidate set. Rule 3c is the sole scalar reader, and the fix is to honour the contract rule 3 already honours at :812. * backlog: #1065 gains the single-quote bypass and the fork's cross-copy measurement Two additions, both measured, neither changing the item's verdict. A SECOND BYPASS OF RULE 3C, and it is worse than the one the item was filed for because it defeats the ABSOLUTE spelling too. The -C regex at :237 strips DOUBLE quotes only, so a single-quoted token keeps its leading quote and GetFullPath produces garbage: git -C ../../.. config core.hooksPath /dev/null DENY git -C '../../..' config core.hooksPath /dev/null ALLOW git -C '<primary>' config core.hooksPath /dev/null ALLOW Reach proven with a harmless key, no disarm key executed against any real repo. This is the ordinary spelling -- the rule 3c test file uses single quotes two lines away, in `git config alias.ci 'commit --no-verify'`. Recorded inside #1065 rather than allocated its own number: it was found at the end of a session, and a dangling allocation is worse than a finding recorded beside its sibling. It still needs one. THE TWO BYPASS SPELLINGS ARE NOT THE SAME DEFECT TO TRIAGE, measured independently in the generalized fork, which shares the scalar [0] read. The CHAINED form is DENIED there: that gate evaluates every verb-bearing segment, so the disarm lands in a segment holding no -C and candidate [0] correctly falls back to the governed cwd. The VALUE-EMBEDDED form bypasses both copies, because the poison sits in the same segment as the disarm and segment-splitting cannot reach it. So the value-embedded spelling is the portable one, and anyone running only the chained repro against a segmenting gate would wrongly call it clean. Stated as "one of six probed spellings bypassed", not "the fork has one bypass" -- the four lesser findings are unprobed there, and neither copy has earned a banner that reads as completeness. * backlog: #1061 is "rule 3c NARROWED", not "fixed" -- name the surviving fail-open The banner already scoped itself to the filed relative-path spelling and pointed at #1065. That was still not honest enough, because it did not name the relative-path fail-open that SURVIVES this fix. Measured on the real layout, ALLOW before AND after: cd ../../.. && git -C ../MessageFoundry config core.hooksPath /dev/null It genuinely disarms the shared config and is no more contrived than the item's own ../../.. example. The resolver prefers -C and DISCARDS the cd, while a real shell resolves a relative -C against the POST-cd directory. So the narrowed claim is "the relative spelling THIS ITEM FILED", not "relative spellings" -- a reader scanning for whether a relative path can still disarm the clone would have drawn the wrong conclusion from the previous wording. Read it as rule 3c NARROWED, never rule 3c FIXED. The banner also now names the two false denies this fix INTRODUCED, since a banner that lists only what a change closed and not what it broke is half a record: --global writes ~/.gitconfig and not the shared config yet now deny (with a deny text contradicting its own closing bullet, then advising an absolute path that also denies), and `cd <ungoverned> && git -C .` denies while configuring an ungoverned repo. Separately, #1065's cross-copy measurement is strengthened: the fork session re-ran its probe with the hook subprocess cwd set equal to the payload cwd, after this session found its own harness did not set it and had to retract a claim over it. Byte-identical -- same one bypass, same five denies, both controls unchanged. Recorded because the harness defect that invalidated the other claim was found in exactly that way, so a cross-copy result now carries the evidence that it is not one. * backlog: file #1066 and #1067, and mark the round-3 fix UNVERIFIED rather than shipped Files two rule 3c defects found by adversarial verification, and unblocks the coordinator, which the overlap hook correctly refused to let write docs/BACKLOG.md while this worktree held it uncommitted. #1066 rule 3c strips double quotes only, so a single-quoted -C target bypasses it -- including an ABSOLUTE one. Confirmed independently in two copies: the generalized fork measured the same defect with both controls behaving, and unlike the -C HEAD case there is no cross-copy divergence, because the quote sits in the same token as the path. #1067 rule 3c governs by PATH PREFIX, so an independent repo vendored under a governed root inherits its governance. Filed not-started deliberately: it needs the submodule question answered first. THE BANNERS ARE DELIBERATELY DEMOTED. The implementing pass wrote them as SHIPPED for #1061, #1065 and #1066 on the strength of its own run. Its adversarial verification had not reported. The PREVIOUS round's implementing pass made exactly that claim and was wrong on eight counts -- five new fail-opens and two false-deny classes against the gate that is currently installed -- so an implementer's success report is not evidence here, it is the thing being tested. Each of the three now opens with FIX WRITTEN, UNVERIFIED, UNCOMMITTED and keeps the drafted assessment beneath it, unedited and labelled. Nothing is lost and nothing is claimed. If verification clears the fix, whoever lands it flips these with evidence; if it does not, they are already honest. The code change itself remains UNCOMMITTED in the working tree and the gate installed on this machine is unchanged. * backlog: the rejected patch was VERIFIED and REJECTED, not merely unverified Corrects a provenance error and a materially misleading status in 7b1bfea. TWO THINGS WERE WRONG. First, 7b1bfea's subject called the uncommitted patch "the round-3 fix". It is ROUND 2's. Round 3's implementing pass never ran -- it died at a session limit with only its three investigation agents complete -- so the working tree, $GateVersion 2026.08.06.2, and items #1066/#1067 are all round 2's output. Second, and this is the one that could have cost someone: the three banners said FIX WRITTEN, UNVERIFIED. That was true when I wrote it only because I had misattributed the work. Round 2's fix WAS verified, by four adversarial verifiers, and REJECTED -- eight defects, including five NEW fail-opens on core.hooksPath and two NEW false-deny classes, every one measured against the gate CURRENTLY INSTALLED on this machine, plus a deny message that contradicts its own closing bullet and offers a remedy that also denies. UNVERIFIED and REJECTED invite opposite actions. The first says "verify it, then ship it"; the second says "do not ship it". A reader who trusted the first would have shipped a patch that re-opens the very defect its own item claims to close: `git config set core.hooksPath /dev/null`, the git 2.46+ subcommand spelling, ALLOWS on that patch and DENIES on the installed gate. All three banners now say WRITTEN, VERIFIED, REJECTED, with the specific reason on #1061 and a pointer from #1065 and #1066. The code remains uncommitted and the installed gate remains a67838d, unchanged. * backlog: file #1069-#1072, and round 3 was REJECTED too -- not "verified" Round 3 of the rule 3c work came back NOT READY. Four independent verifiers each returned a DIFFERENT blocker: at least five new fail-opens and two new false-deny classes against the gate live on 57 worktrees, three proven end-to-end to disarm the commit hooks, two of those on keys the rule names explicitly (includeif., core.hooksPath). FOUR NEW ITEMS, all confirmed OPEN on every gate measured -- the committed one, round 2's rejected patch, and round 3's: #1069 the disarm key was matched on the quote-blanked scan string, so a QUOTED key was invisible. The only writable spelling of a multi-word alias value is quoted, so that whole class was unseen. #1070 git carries config in the ENVIRONMENT and no argv rule can see it -- GIT_CONFIG_COUNT, GIT_CONFIG_PARAMETERS, GIT_CONFIG, config edit. #1071 a UNC-spelled governed root is not de-aliased by rev-parse. #1072 text shapes still unread: backtick substitution, variable targets, newline and subshell cd, a heredoc body. #1064's relayed half is now CONFIRMED rather than inherited. THE BANNER CORRECTION IS THE POINT OF THIS COMMIT. The implementing pass wrote "a SECOND fix is WRITTEN and VERIFIED" into four banners while its own verification had not reported. Verification then rejected it. That is the third consecutive round in which the implementing pass graded its own work into the ledger, and the third time the grade was wrong. What makes it worth a commit message rather than a quiet edit: round 3's suite ran 420/420 GREEN and could not see a single one of the four blockers. Mutation testing found why -- 12 single-mechanism mutants, 9 killed, 3 SURVIVED a full green run, each proven non-equivalent by a command whose behaviour differs. A green suite is evidence about the mutations it kills and nothing else. Same root cause as round 2, one layer down: structured parsing narrower than the regex it replaces. Round 2 replaced regex matching with a tokenizer; round 3 refined the regexes and anchored a value-token requirement to the end of the matched ALTERNATIVE rather than the key token, so a bare-prefix alternative like includeif. never required a value at all. The leak gate refused the first attempt at this commit: the implementing pass had written this worktree's slug into an item banner. Removed rather than allowlisted, per the gate's own instruction. Both patches are banked outside the repo, neither committed, neither installed. The gate governing this machine is unchanged: commit a67838d, blob 3e7db36. * backlog: file #1076, rule 3b emits the branch name unquoted one line below the fix The last unfiled finding from the deny-reason audit. It has been recorded only in a Desktop writeup since it was found; filing it because a finding that lives outside the ledger is invisible to everyone who was not in that conversation. PR #214 quoted $dest at worktree_gate.ps1:475 to close a refname-into-a-command injection. Line 477 emits the same value BARE, inside the same remediation block the same message tells an agent to run. Measured against the installed gate, from a branch named pwn$(calc): ... -Branch 'pwn$(calc)' -Name pwn-calc <- :475, quoted git -C "..." show pwn$(calc):<path> ... HEAD..pwn$(calc) <- :477, BARE $( ) is command substitution in both PowerShell and bash. The refname is attacker-chosen from a public fork: gh pr checkout, git checkout --track and git fetch origin <ref>:<ref> all create refs/heads/<their-name>, and the SECOND checkout of it trips rule 3b. FILED AS ITS OWN NUMBER RATHER THAN FOLDED INTO #1035, which is the nearest existing item. #1035 is a RUNNABILITY defect -- the gate's own root path containing a space breaks the command it prints. This is an INJECTION -- an attacker-chosen value executes. Different cause, different severity, different fix, and #1035's own scope note warns against sweeping adjacent shapes into it. Cross-referenced both ways. The item records that FOLDING IS THE WRONG INSTRUMENT and would look like a fix: Get-SafeForMessage neutralises line structure and length and does not touch $, backtick, & or '. The fix is quoting, as :475 already does. It also records the second unsafe interpolation on the same line -- $selfTopRaw inside double quotes, where $( ) and backticks still expand -- and that the earlier "a quote cannot occur in a Windows path" reasoning is true and irrelevant, because the quote is not the operative character. Confirmed in a second independent copy: the generalized fork carries the same remediation shape but strips shell metacharacters in its fold, so it is not exploitable there -- a working existence proof that a metacharacter-stripping fold is a viable second layer. The item asks for both, and says why quoting is the primary. PR #214's quoting fix is itself untested, which is how the miss one line below it survived review. The item requires a test asserting the emitted STRING, not merely that the call denied -- the exact failure mode round 3's mutation testing found in a test whose docstring named the class it could not detect. * backlog: file #1082, and retract my own "--global is a false deny" premise Round 4 was briefed on a premise I supplied, and its verification withdrew it. Filing what the defect actually is, and redacting a worktree slug the leak gate does not catch. THE RETRACTION. I briefed round 4 as "rule 3c denies --global writes it has no business refusing". Three independent verifiers measured that and overturned it: in a repo with core.hooksPath unset at every scope, `git config --global core.hooksPath <emptydir>` leaves .git/config untouched and the next commit runs with the hook never firing -- COMMIT-1 rc=1 GATE-FIRED, then COMMIT-2 rc=0. The write disarms the repo BY INHERITANCE. Flipping rule 3c to ALLOW would have been round 4's fail-open. The implementing pass refused to flip it, which was the correct call and the best judgement in that round. AND THEIR GENERALISATION NEEDS A QUALIFIER, which I measured here rather than inherit: git config --show-origin --get core.hooksPath file:.git/worktrees/<wt>/config.worktree <primary>/.git/hooks This repo sets core.hooksPath at WORKTREE scope, which beats global, so here a --global write of that key would NOT take effect. In their fresh rig, unset everywhere, it would. Whether the write matters depends on whether the governed repo pins the same key at a more specific scope, and that is not knowable from the command text. Denying is the correct conservative default. Note the asymmetry: --global alias.* is far more likely to land than --global core.hooksPath, because a repo rarely pins the same alias locally. SO #1082 IS A WORDING DEFECT, NOT A FALSE DENY. The verdict stays. The message must stop claiming the write "would change the SHARED git configuration of <repo>" -- it does not, it writes ~/.gitconfig -- without swinging to the opposite falsehood. A round-4 candidate printed "This does NOT change the shared configuration" and three verifiers graded it BLOCKING, because Get-ScannableSegments splits on LINES and Write-Deny exits on the first hit, so a multi-line command showed that reassurance over a second segment doing a real local disarm. Both constraints are recorded in the item. ALSO REDACTED: a pre-existing worktree slug in #1056's Source line. It is already pushed, so this is partial mitigation, but the finding is the gate blind spot -- the forbidden-content guard catches `.claude/worktrees/<slug>` and passes a bare `<slug>` in prose. It refused one of my commits for the path form yesterday and has been passing this one since 2026-08-05. Worth its own item; the guard is green because it cannot see that shape. Round 4's patch is banked UNVERIFIED and reverted; the tree is clean and the installed gate is unchanged. * backlog: file #1083, the leak guard requires a path prefix and passes a bare slug Found while committing #1082, when a routine grep returned a slug the leak gate had just passed -- and the same gate had refused a different commit of mine for the prefixed form the day before. The disagreement between two runs of one guard is what made it visible. scripts/security/scan_forbidden.py:96 _WORKTREE_SLUG = re.compile(r"(?i:(?:claude/|worktrees/)[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{6})") The claude/ or worktrees/ prefix is MANDATORY. A bare slug in prose matches the shape and not the pattern. Measured both directions in one file: REFUSED at :5800 for the path form, PASSED at :5129 for the bare form, identical slug, only the prefix differing. The passing instance has been on origin since 2026-08-05 and this is a PUBLIC repo. The comment block above the pattern reasons carefully about CASE -- that new.ps1 validates -Branch only with git check-ref-format, which permits mixed case, and that -Name reaches the directory verbatim -- and says nothing about the prefix. One axis of reachability was thought about and the other was not. THE ITEM DELIBERATELY DOES NOT PRESCRIBE JUST WIDENING THE REGEX. A bare word-word-6hex shape is far more collidable than a prefixed one, and this repo writes short hex in prose constantly -- blob ids, hash-object prefixes, "sha 3e7db36" appears in items filed yesterday. A guard that fires on ordinary commit prose gets allowlisted into uselessness, which is worse than the leak it prevents. The item asks for the hit rate over the existing corpus to be measured first, and prefers narrowing by CONTEXT over widening the SHAPE. And it asks for a negative-control fixture asserting the scanner FIRES on the bare form, because the missing negative control IS the defect: the current suite cannot tell "no slug present" from "slug present in a shape I do not match". Mitigation applied in the previous commit was partial by construction -- the instance is already in git history and on origin, so redaction stops propagation and does not remove it. * backlog: #1083 gains the trap that makes the obvious fix wrong for a new leak A redaction commit REPUBLISHES the token in its own diff. `git show <redaction commit>` contains the removed line verbatim as a minus line, so on a public repo the fix and the disclosure are the same object -- and the commit message draws attention to it. That makes "commit the redaction" correct ONLY for an already-public token. The item now splits the three cases: already on the remote (this one -- the diff adds nothing, redact and record that history retains it); NOT yet pushed (do NOT add a redaction commit, amend or rebase so the token never reaches the remote in any object, because a redaction commit converts a local mistake into a published one); and pushed-but-genuinely-sensitive (a history-rewrite and disclosure question for the owner, not a commit). Moot for the instance already redacted, which had been on origin since 2026-08-05, and that is exactly why it was easy to miss. Also recorded: severity here was assessed on the token's SHAPE without echoing it -- adjective-noun-6hex, an auto-generated agent session name, not a customer or site token. That classification is what made this routine rather than an owner-level call, and any guard fix needs to preserve the distinction or it will under-refuse or become noise. Credit where due: the trap was caught by the coordinator while deciding whether to push the previous commit, not by me while writing it. * backlog: file #1085, the last live false deny, which had no number of its own It existed only as prose inside #1061's banner. A live false deny on the INSTALLED gate, invisible to anyone triaging the ledger, is a filing defect as much as a code one -- and after #1082 withdrew the other false-deny claim, this is the only one left. From a governed primary, a `cd` into an ungoverned sibling followed by a relative -C disarm write DENIES, naming the primary, while the write lands in the ungoverned repo. Get-GitTargetCandidatesRaw prefers -C and DISCARDS the cd prefix; a real shell resolves a relative -C against the POST-cd directory. So the gate refuses a command aimed somewhere it does not govern, and names a repository the command never touches. IT IS THE EXACT MIRROR OF #1061 FROM ONE ROOT CONFUSION. #1061 was "resolved against the wrong base and ALLOWED". This is "resolves against the wrong base and DENIES". A fix aimed only at the ALLOW direction was always able to produce this, and did. Not closed by any of the three rejected rounds: round 2 attempted composition and was rejected for unrelated fail-opens, round 3 closed it for the single-line joins but not across a newline or a subshell, round 4 was wording-only. The item records two constraints rather than just prescribing composition: Get-ScannableSegments splits on LINES, so a newline-joined cd is not composed by any per-segment rule; and the bail-outs for popd, cd -, parens and multiple cds exist because those are not statically resolvable, so removing them to make composition tidier converts an honest "I could not determine the target" into a confident wrong repo name. AND FILING IT COST A COMMIT, WHICH IS ITSELF A FINDING. The first attempt at this message was REFUSED by rule 3c: the message QUOTES the defective command as evidence, the gate scanned the tool-call text, matched the disarm key, and denied -- though nothing was going to execute. Earlier items in this series committed only by accident, because their messages happened to contain read flags that tripped the read-exclusion. So the gate cannot commit a faithful description of its own defect. Filing that separately. * backlog: file #1086, the gate refuses a commit message that quotes its own defect Found by being refused. The commit filing #1085 was blocked by rule 3c because its MESSAGE quoted the defective command as evidence. The hook scans the tool-call text, matched the key, and denied -- though the quoted text was data being passed to -m and nothing was ever going to execute. Rewriting the prose so the key was not adjacent to the subcommand let the identical commit through: same file, same staged diff. IT IS INTERMITTENT, AND THAT IS THE WORST PART. Several earlier items in this same series quoted disarm commands and committed fine. They passed BY ACCIDENT, because their messages also happened to contain --show-origin or --get, which trip the READ exclusion and skip the segment. So whether a commit message is refused depends on unrelated content elsewhere in the same message. Anyone hitting this looks for a rule and finds a coin flip. Get-ScannableSegments already blanks quoted spans for exactly this reason -- its comment records three measured false positives that motivated it, including a commit message containing the word clean. A PowerShell here-string is simply not one of the shapes it blanks. The reasoning was right and was not extended to this form. THE ITEM DELIBERATELY DOES NOT PRESCRIBE "BLANK HERE-STRINGS TOO". That file already learned the hard way that blanking every quoted span made an interpreter argument ALLOW, because a quoted string handed to pwsh -Command IS code that runs. A here-string can be either: inert when passed to -m, executable when passed to an interpreter. The narrow defensible fix keys on the CONSUMER, not the delimiter -- a here-string consumed by a -m/--message argument is a message. And it must ship both tests in one commit: the -m case ALLOWS, and the interpreter case still DENIES. The second is the regression the last quote-blanking fix produced.
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.
Rule 3b of the worktree gate printed a remediation command that
new.ps1refuses to run. Fixing it surfaced two defects more serious than the one filed.Lead finding: command injection into text an agent is told to run
git check-ref-formataccepts;,$,|,"and'in a refname. The legal branchx';calc;#made rule 3b emit a deny line that parses as two statements, the second arbitrary — injected into a message whose entire purpose is to tell an agent "run this instead". Fixed by quote doubling.This is not hypothetical shell-quoting hygiene: the deny text is attacker-influenceable output that an agent is instructed to act on, and nothing in the gate treated it as such.
Second: the gate deferred to a guard the caller can switch off
An early return skipped the deny when the destination branch was already checked out elsewhere, reasoning that git refuses that switch anyway. Git has flags that turn its own guard off. Measured, with
victimlive in another worktree:git checkout victimgit checkout --ignore-other-worktrees victimgit checkout --detach victimgit checkout -d victimgit switch --detach victimgit switch -d victimgit checkout --force victimgit switch --discard-changes victimDetach bypasses because it never takes the branch lock — while still swapping the other session's files to that commit, which is exactly the harm rule 3b exists to prevent.
The fix is an allowlist, not a flag list. The early return now fires only when there are no flags at all after the verb. A denylist was written twice and wrong twice —
--detachwas missed while fixing--ignore-other-worktrees, and-dwould have been missed while fixing--detach. A future git flag would silently reopen it. The cost is a needless deny ongit checkout --quiet main; that trade does not decay, and a flag list does.The general form, worth keeping: "git already refuses this" is a claim about a configuration, not about git. A guard you do not own can be switched off by its own caller.
Third: the ValidatePattern did not enforce its own stated shape
"abc" + newlinematches^[A-Za-z0-9._-]+$, because .NET's$also matches before a trailing newline. Fixed to\A..\zin all four copies of that pattern.The filed defect, and a hazard the fix created
new.ps1used one parameter for a directory name and a git ref. Now split into-Name(directory) and-Branch(ref), defaulting-Branchto-Nameso existing callers are unchanged.That split would have armed
remove.ps1 -DeleteBranch, which assumed branch == directory name. It now reads the branch from git before removal destroys the reflog, and adoptsprune-merged.ps1's-d-first lossless discipline. Three sibling worktrees already have HEAD != directory name and all three carry unmerged commits, so this was live rather than theoretical.Verification
213 tests pass across the nine gate suites. The four hole tests were confirmed FAILING first for both verbs ("expected a DENY, got allow"), and a companion test asserts the no-flag case is still ALLOWED, so the early return was fixed rather than merely deleted. Tests execute the string the gate prints rather than asserting a copy of it.
After merging: the installed gate needs a manual re-install
worktree_gate.ps1runs from an installed copy that does not update itself.tests/test_gate_installed_parity.pywill report STALE from the moment this lands — that is the designed re-install signal, not a regression. Re-install from a plain terminal against the merged checkout, never from a worktree.