Skip to content

fix(gate): rule 3b printed an unrunnable remediation, and deferred to a guard the caller can switch off - #214

Merged
wshallwshall merged 6 commits into
mainfrom
claude/trusting-wu-c2e6d5
Aug 5, 2026
Merged

fix(gate): rule 3b printed an unrunnable remediation, and deferred to a guard the caller can switch off#214
wshallwshall merged 6 commits into
mainfrom
claude/trusting-wu-c2e6d5

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Rule 3b of the worktree gate printed a remediation command that new.ps1 refuses 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-format accepts ;, $, |, " and ' in a refname. The legal branch x';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 victim live in another worktree:

command result
git checkout victim fatal: already used by worktree
git checkout --ignore-other-worktrees victim succeeds
git checkout --detach victim succeeds
git checkout -d victim succeeds
git switch --detach victim succeeds
git switch -d victim succeeds
git checkout --force victim fatal (does not bypass)
git switch --discard-changes victim fatal (does not bypass)

Detach 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 — --detach was missed while fixing --ignore-other-worktrees, and -d would have been missed while fixing --detach. A future git flag would silently reopen it. The cost is a needless deny on git 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" + newline matches ^[A-Za-z0-9._-]+$, because .NET's $ also matches before a trailing newline. Fixed to \A..\z in all four copies of that pattern.

The filed defect, and a hazard the fix created

new.ps1 used one parameter for a directory name and a git ref. Now split into -Name (directory) and -Branch (ref), defaulting -Branch to -Name so 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 adopts prune-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.ps1 runs from an installed copy that does not update itself. tests/test_gate_installed_parity.py will 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.

…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
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
wshallwshall merged commit fdaf53f into main Aug 5, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/trusting-wu-c2e6d5 branch August 5, 2026 23:59
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.
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