From 6ac8913e783f634644c288da86524f46ba5c4aca Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 4 Sep 2026 18:35:51 -0500 Subject: [PATCH 1/2] fix(gate): rule 3c allowed a disarm behind a no-op chdir, reverting its own closure (BACKLOG #1065) Rule 3c's chdir guard answers a chdir between the first git token and the disarm by SUPPRESSING the base candidate. The resolver cannot follow that chdir either -- its $Prefix is sliced at the FIRST git token -- so on a line that also carries a decoy `-C` the candidate set collapsed to the decoy alone, git rejected it, the chain ran out, and the rule allowed. Suppressing the base is not the same as knowing where the write lands. Measured against a throwaway governed rig, hook subprocess cwd equal to the payload cwd: git commit -C HEAD && git config core.hooksPath /nope DENY git commit -C HEAD && cd . && git config core.hooksPath /nope ALLOW Thirteen rows moved back to ALLOW on origin/main -- seven chdir spellings (cd, cd ./, pushd, chdir, sl, Set-Location, Push-Location), three cwds (primary, nested worktree, linked worktree), the semicolon join as well as &&, and alias.* as well as core.hooksPath. Consequence read back rather than inferred: the ALLOWed command really runs, and core.hooksPath then reads /nope from a DIFFERENT worktree of the same repository, which is the shared config. The fix follows the chdir instead of declining to. Get-ChdirTargetRaw is extracted from the resolver -- one definition, two callers, no second spelling of "follow a cd" -- and rule 3c calls it on the guard window, composing a relative window chdir onto a prefix chdir and appending the result as the LAST candidate. It is gated on the disarming invocation naming no repository of its own, so an explicit -C or --git-dir still decides and a governed chdir cannot manufacture a refusal for a write aimed elsewhere. $where[0] never moves, so the unresolvable-target refusal is decided on exactly the token it was. Both directions are pinned, because appending a candidate can only add denials and the risk this carries is a refusal earned by the wrong candidate. Eight deny rows: one per chdir verb the guard enumerates, the semicolon join asserting a second key, and a chdir INTO the governed repo from an ungoverned cwd. Four allow rows: a chdir away to an independent clone, an explicit -C beside a chdir, an unfollowable double chdir recorded as a negative control rather than left to be discovered, and ordinary config after a chdir. A SECOND LIVE FAIL-OPEN OF THE SAME CLASS, found by the same sweep and closed here too. The explicit READ flags were matched against the WHOLE SEGMENT, so a read belonging to a neighbouring command excused the write beside it: git config core.hooksPath /nope DENY git config --list && git config core.hooksPath /nope ALLOW Fifteen rows on origin/main -- every disarm key by every cwd. Consequence read back: run from the linked worktree, the primary then reads /nope. The banner claims this shape was closed, but by the REJECTED round-3 patch, which never shipped. The exclusion is now tested against $ownCmdWin plus $rest -- the same window $ownGitDir already uses -- so `git config --get ` and `git --no-pager config --get ` are excluded exactly as before while the neighbour no longer reaches across the separator. Six deny rows over the read spellings and two separators, and seven allow rows over the honest reads. Whole-corpus regression check: a 205-row adversarial matrix -- five disarm keys by ten poison shapes by three cwds, plus 39 allow-side controls and an ungoverned clone -- run against origin/main's gate and this one. Co-Authored-By: Claude Opus 5 --- scripts/hooks/worktree_gate.ps1 | 193 ++++++++++++++++++---- tests/test_worktree_gate_control_plane.py | 186 +++++++++++++++++++++ 2 files changed, 344 insertions(+), 35 deletions(-) diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 8d5c4d502..525fc2c80 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -67,7 +67,7 @@ param( # the drift, but a stamp that disagrees with the verdict beside it is the exact ambiguity this machinery # exists to remove. -Status now prints the SHA prefix on both lines, so agreement is visible rather than # asserted, and this label can never again be the only thing a reader compares. -$GateVersion = "2026.09.03.1" +$GateVersion = "2026.09.04.1" # Fail OPEN: any unhandled error must let the tool call through, never block it. $ErrorActionPreference = "SilentlyContinue" @@ -434,6 +434,52 @@ function Resolve-ShellIndirection([string]$Token, [string]$Prefix) { $out } +# ONE DEFINITION OF "FOLLOW A CHDIR", AND IT IS EXTRACTED RATHER THAN COPIED (BACKLOG #1065). +# +# This body used to live inline in Get-GitTargetCandidatesRaw and had exactly one caller. Rule 3c now +# needs the same answer for a DIFFERENT span of the same command line -- the region between the first +# git token and the disarm, which the resolver's own $Prefix is sliced short of and can never see. A +# second copy beside the first is how the two drift apart silently, and this file already records that +# happening: #1229's third round was a measured fail-open caused by two places spelling one fact +# differently, and the resolver's own residual list says the fix for a resolution defect belongs in the +# shared helper "rather than to this rule". +# +# RETURNS "" FOR EVERY CASE IT CANNOT ANSWER, and the callers treat "" and $null alike because both are +# falsy in PowerShell -- so the resolver's behaviour is byte-identical to the inline version it replaces. +# The four unanswerable cases are unchanged and are all "this text cannot be composed": +# * `popd` or `cd -` -- restores a directory this scan never saw; +# * a `(` or `{` -- a subshell whose chdir does not affect the parent; +# * more than one -- the fold is not a single token and this is not a shell; +# * a target that is blank after trimming. Answering "" there costs an unclosed shape and never a +# wrong one; inventing a target from the residue would be the second kind. +# +# WHAT QUOTING DOES TO A CALLER READING THE SCAN STRING, MEASURED RATHER THAN ASSUMED -- because the +# obvious guess is wrong in one direction and right in the other. Remove-QuotedSpans UNMASKS a quoted +# span holding one bare word, so `cd "."`, `cd '.'` and a quoted path with no space are all followed +# exactly like the unquoted spelling. It blanks a span containing whitespace to an empty pair, so a +# QUOTED target CONTAINING A SPACE (`cd "C:/Pri mary"`) is not followed at all -- and that is a stated +# residual, not a hazard: the pair defeats the regex, nothing is returned, and the caller keeps the +# behaviour it had. The UNQUOTED spacey spelling IS followed, because the capture class here admits +# spaces and stops at the separator -- unlike the `-C` reader's `[^"\s]+`, which is a different +# residual this file already records. A caller reading RAW text is unaffected by any of it. +function Get-ChdirTargetRaw([string]$Text) { + if (-not $Text) { return "" } + if ($Text -match '(?:^|\s)(?:popd|cd\s+-(?:\s|$))') { return "" } + if ($Text -match '[({]') { return "" } + # THE VERB LIST AND THE IGNORECASE OPTION ARE BOTH LOAD-BEARING and are carried over verbatim. + # [regex]::Matches is case-SENSITIVE by default and PowerShell verbs are conventionally written + # `Set-Location`, so a case-sensitive alternation of lowercase spellings would match none of them + # and this helper would silently do nothing. The shells being matched are themselves + # case-insensitive, so this widens nothing that was not already reachable. + $chdirComposeVerbs = 'cd|chdir|pushd|sl|set-location|push-location' + $cds = [regex]::Matches( + $Text, + "(?:^|\s)(?:$chdirComposeVerbs)\s+`"?([^`"&|;]+?)`"?\s*(?:&&|;|\||`$)", + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($cds.Count -ne 1) { return "" } + $cds[0].Groups[1].Value.Trim() +} + function Get-GitTargetCandidatesRaw([string]$Line, [string]$Prefix, [string]$CwdRaw, [switch]$AllTargets, [switch]$BaseFallback, [switch]$ExplicitFirst) { @@ -480,38 +526,29 @@ function Get-GitTargetCandidatesRaw([string]$Line, [string]$Prefix, [string]$Cwd # The bail-outs are unchanged and still guard both branches: `popd` and `cd -` restore an unknown # directory, and `(`/`{` mean a subshell whose `cd` does not affect the parent -- in all three the # prefix cannot be composed and $cd stays null, which falls back to exactly the old behaviour. - $cd = $null - if ($Prefix -notmatch '(?:^|\s)(?:popd|cd\s+-(?:\s|$))' -and $Prefix -notmatch '[({]') { - # THE VERB LIST MATCHES RULE 3c's CHDIR GUARD, and it did not until now. This composer knew - # only `cd` and `pushd`, so a PowerShell chdir verb never resolved its target and the command - # after it was judged against the SESSION cwd instead. Measured on the shipped gate, with the - # consequence read back from the governed working tree rather than inferred from a verdict: - # - # Push-Location ; git reset --hard ALLOWED, and it DESTROYED uncommitted work - # - # run from an ungoverned cwd. That is precisely the hijack rule 3 exists to prevent, reached by - # spelling one verb differently. - # - # THE ABSOLUTE AND RELATIVE CASES FAILED DIFFERENTLY, which is why the fix is here rather than at - # a call site. With an ABSOLUTE governed path `sl` and `Set-Location` already denied -- caught - # downstream by the path itself -- while `Push-Location` did not. With a RELATIVE target every - # uncomposed verb failed open, because nothing resolved `../../..` against the chdir at all. - # - # IGNORECASE IS REQUIRED AND IS THE ONE RISKY CHARACTER HERE. [regex]::Matches is case-SENSITIVE - # by default, and PowerShell verbs are conventionally written `Set-Location`, so a case-sensitive - # alternation of lowercase spellings would match none of them and this fix would silently do - # nothing. The shells being matched are themselves case-insensitive, so this widens nothing that - # was not already reachable. - # - # ADDITIVE BY CONSTRUCTION: composing a chdir can only make a target RESOLVE where it previously - # did not, so every verdict it changes moves ALLOW to DENY. - $chdirComposeVerbs = 'cd|chdir|pushd|sl|set-location|push-location' - $cds = [regex]::Matches( - $Prefix, - "(?:^|\s)(?:$chdirComposeVerbs)\s+`"?([^`"&|;]+?)`"?\s*(?:&&|;|\||`$)", - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if ($cds.Count -eq 1) { $cd = $cds[0].Groups[1].Value.Trim() } - } + # THE VERB LIST MATCHES RULE 3c's CHDIR GUARD, and it did not until #1304. The composer knew only + # `cd` and `pushd`, so a PowerShell chdir verb never resolved its target and the command after it + # was judged against the SESSION cwd instead. Measured on the shipped gate, with the consequence + # read back from the governed working tree rather than inferred from a verdict: + # + # Push-Location ; git reset --hard ALLOWED, and it DESTROYED uncommitted work + # + # run from an ungoverned cwd. That is precisely the hijack rule 3 exists to prevent, reached by + # spelling one verb differently. + # + # THE ABSOLUTE AND RELATIVE CASES FAILED DIFFERENTLY, which is why the fix is in the composer rather + # than at a call site. With an ABSOLUTE governed path `sl` and `Set-Location` already denied -- + # caught downstream by the path itself -- while `Push-Location` did not. With a RELATIVE target every + # uncomposed verb failed open, because nothing resolved `../../..` against the chdir at all. + # + # ADDITIVE BY CONSTRUCTION: composing a chdir can only make a target RESOLVE where it previously did + # not, so every verdict it changes moves ALLOW to DENY. + # + # THE BODY MOVED TO Get-ChdirTargetRaw (BACKLOG #1065) and is byte-identical in behaviour: the two + # bail-outs, the verb list, the IgnoreCase option and the exactly-one rule are all carried across, + # and "" is falsy exactly where $null was. It moved because rule 3c needs the same answer for a span + # this $Prefix is sliced short of, and a second copy is how two spellings of one fact drift apart. + $cd = Get-ChdirTargetRaw $Prefix # git's global `-C `, read CASE-SENSITIVELY. `-match` is case-INsensitive in PowerShell, so # git's lowercase `-c name=value` config override was captured as if it were a path -- and being the @@ -1970,8 +2007,8 @@ if ($tool -in @("Bash", "PowerShell")) { $rest = $dis.Groups['rest'].Value $via = $dis.Groups['via'].Value $viaConfig = $via -match '\bconfig\b' - # A read is not a write -- the EXPLICIT read flags. - if ($seg.Scan -match '(?:^|\s)--(get|get-all|get-regexp|list|show-origin)(\s|$)') { continue } + # THE EXPLICIT READ FLAGS ARE TESTED BELOW, AGAINST THE DISARMING INVOCATION'S OWN WINDOW, and + # they used to be tested here against the WHOLE SEGMENT. See the block after $ownCmdWin. # ...AND THE IMPLICIT ONE (BACKLOG #1306). `git config ` with NO VALUE AFTER IT assigns # nothing -- it is the bare read, and `--get` is merely its explicit spelling. Measured against # real git: bare `git config ` exits 1 on an unset key and stores nothing, while @@ -2057,6 +2094,31 @@ if ($tool -in @("Bash", "PowerShell")) { $ownCmdWin = $seg.Scan.Substring($ownStart, $dis.Index - $ownStart) $ownGitDir = ($ownCmdWin -match '(?:^|\s)(--git-dir[=\s]|GIT_DIR=)') + # =============================================================================================== + # A READ IS NOT A WRITE -- BUT IT HAS TO BE *THIS* INVOCATION'S READ (BACKLOG #1065). + # + # THE DEFECT, MEASURED, AND IT IS THIS ITEM'S CLASS WITH A DIFFERENT TOKEN. The explicit read + # flags were matched against the WHOLE SEGMENT, so a read belonging to a NEIGHBOURING command + # excused the write beside it: + # + # git config /nope DENY + # git config --list && git config /nope ALLOW <- the same write + # + # Fifteen rows on `origin/main`, every disarm key this rule names by every cwd -- primary, + # nested worktree, linked worktree. `git config --list` is an ordinary thing to run, so the + # excusing token needs no more intent than the `-C HEAD` this item was filed about, and the + # mechanism is identical: a token that belongs to a different command on the line decides the + # rule. The banner records a REJECTED patch closing this shape; nothing shipped, so it was live. + # + # $ownCmdWin IS THE SAME WINDOW $ownGitDir ALREADY USES, deliberately, so there is one answer to + # "what did the disarming invocation itself say" rather than two. It runs from the separator + # before the owning git token to the disarm, which is where a read flag is actually written, and + # `$rest` is appended for the trailing spelling. `git config --get ` is therefore excluded + # exactly as before -- the flag is inside its own window -- and so is `git --no-pager config + # --get `, while the neighbouring read no longer reaches across the separator. + # =============================================================================================== + if ("$ownCmdWin $rest" -match '(?:^|\s)--(get|get-all|get-regexp|list|show-origin)(\s|$)') { continue } + # THE CHDIR GUARD, AND ITS POSITION BOUND IS THE LOAD-BEARING HALF. $pfx is sliced at the FIRST git # token, so the resolver's own `cd` composer cannot see a chdir that appears AFTER it. Without this # guard `git commit -C HEAD && cd && git config v` denied and NAMED THE GOVERNED @@ -2086,6 +2148,63 @@ if ($tool -in @("Bash", "PowerShell")) { # THE FALLBACK IS THE ONLY SUBTRACTIVE PIECE IN THIS CHANGE, and it subtracts from something that # did not exist before, so getting either guard wrong leaves a shape unclosed and cannot open one. $fallbackOk = (-not $ownDashC) -and (-not $chdirBefore) + + # =============================================================================================== + # SUPPRESSING THE BASE IS NOT THE SAME AS KNOWING WHERE THE WRITE LANDS (BACKLOG #1065). + # + # THE DEFECT, MEASURED, AND IT REVERTS THIS RULE'S OWN HEADLINE CLOSURE WITH ONE ORDINARY TOKEN. + # The guard above answers "a chdir happened, so the session cwd is no longer the answer" by + # dropping the base candidate. But the resolver cannot follow that chdir either -- $pfx is sliced + # at the FIRST git token and the chdir sits after it -- so on a line that also carries a decoy + # `-C` the candidate set collapses to the decoy alone, git rejects it, and the rule allows: + # + # git commit -C HEAD && git config /nope DENY (the closure) + # git commit -C HEAD && cd . && git config /nope ALLOW (the same write) + # + # Measured on this file before this change from the primary, a nested worktree and a linked + # worktree, and with every chdir verb the guard above enumerates -- `cd`, `cd ./`, `pushd`, + # `chdir`, `sl`, `Set-Location`, `Push-Location` -- plus the `;` spelling and the alias key: + # THIRTEEN rows, every one ALLOW, every one landing in the shared config. `cd .` is a no-op, so + # the poison token needs no intent and changes nothing about what the command does. + # + # THE FIX IS TO ANSWER THE QUESTION RATHER THAN TO DECLINE IT. When the disarming invocation + # names no repository of its own, the directory the shell is standing in IS the target, and that + # directory is the chdir this guard just found. Follow it with the SAME helper the resolver uses + # and append it as a candidate. + # + # GATED ON THE INVOCATION NAMING NO REPOSITORY ITSELF, and that gate is what keeps this from + # manufacturing a deny. If the disarming invocation carries its own `-C` or `--git-dir`, THAT + # token decides where the write lands and the surrounding chdir does not -- appending it there + # would let a governed chdir refuse a write aimed by an explicit token at an ungoverned repo, + # which is the #1085 shape this rule has already been fixed for twice. + # + # APPENDED LAST, NEVER FIRST. $where[0] is unchanged, so the unresolvable-target refusal below is + # still decided on exactly the token it is decided on today, and a candidate that ANSWERS still + # decides before this one is ever tried. The only reachable change is where the chain used to run + # out and allow. + # + # COMPOSE, NEVER REPLACE -- the rule the resolver states for the same reason. A relative chdir in + # the window resolves against a chdir in the PREFIX, so the two are joined. If the prefix carries + # a chdir the helper cannot follow, nothing is appended at all: a base that is wrong is worse than + # no base, because it produces a confident answer about the wrong repository. + # + # THE WINDOW IS READ OFF SCAN AND THE PREFIX OFF RAW, and the split is deliberate rather than an + # oversight. A chdir inside a quoted VALUE is not a chdir, which is why the window uses the same + # blanked string the guard above tests; $pfx is the resolver's own argument and stays RAW so this + # rule and the resolver compose the identical prefix. See Get-ChdirTargetRaw for exactly what + # quoting costs on the scan side -- one bare word survives, a spacey quoted target does not. + $chdirTarget = "" + if ($chdirBefore -and -not $ownDashC -and -not $ownGitDir) { + $winCd = Get-ChdirTargetRaw $chdirWin + $pfxCd = Get-ChdirTargetRaw $pfx + $pfxFollowable = $pfxCd -or ($pfx -notmatch "(?:^|\s)(?:$chdirVerbs)(?:\s|$)") + if ($winCd -and $pfxFollowable) { + $chdirTarget = $( + if ($pfxCd -and -not [System.IO.Path]::IsPathRooted($winCd)) { Join-Path $pfxCd $winCd } + else { $winCd }) + } + } + # =============================================================================================== # -AllTargets IS GATED ON $ownDashC, and the gate is the whole point (BACKLOG #1065). # # Sweeping EVERY `-C` on the line was too wide, and adversarial measurement caught it: from an @@ -2191,6 +2310,10 @@ What to do instead: # the primary's own root -- the exact spelling #1061 was filed about. "Unresolvable means not # governed" is how this whole defect shipped; it is not reinstated here in any form. # =============================================================================================== + # The followed chdir joins the chain HERE and nowhere earlier, so both properties above hold of + # it unchanged: $where[0] never moves, and the refusal above has already been decided. + if ($chdirTarget) { $where = @($where) + @($chdirTarget) } + $govCfg = $null foreach ($cand in $where) { $candRaw = Get-FullPathRaw $cand $cwdRaw diff --git a/tests/test_worktree_gate_control_plane.py b/tests/test_worktree_gate_control_plane.py index 7081bdc8b..38be81fe1 100644 --- a/tests/test_worktree_gate_control_plane.py +++ b/tests/test_worktree_gate_control_plane.py @@ -1138,6 +1138,192 @@ def test_rules_3_and_3d_are_unchanged_by_the_candidate_switches(repo: SimpleName assert "working tree of the SHARED PRIMARY checkout" not in removal +# ------------------------- rule 3c: a chdir INSIDE the guard window (BACKLOG #1065, third half) +# +# The rows above close "a `-C` owned by another command must not end the rule". The guard that closed +# them also SUPPRESSES the base candidate whenever a chdir appears between the first git token and the +# disarm -- correctly, because after a chdir the session cwd is no longer where the write lands. +# +# But suppressing the base is not the same as knowing where the write DOES land. The resolver cannot +# follow that chdir either: its ``$Prefix`` is sliced at the FIRST git token and the chdir sits after +# it. So on a line that also carries a decoy ``-C`` the candidate set collapsed to the decoy alone, git +# rejected it, and the rule allowed -- reverting the closure above with ONE ordinary token: +# +# git commit -C HEAD && git config core.hooksPath /nope DENY +# git commit -C HEAD && cd . && git config core.hooksPath /nope ALLOW, same write +# +# ``cd .`` is a no-op. The poison needs no intent, no relative path and no unusual spelling, and it was +# measured ALLOW from the primary, a nested worktree and a linked worktree, on every chdir verb the +# guard enumerates. The rule now FOLLOWS that chdir with the same helper the resolver uses and appends +# it as the LAST candidate. +# +# BOTH DIRECTIONS ARE ASSERTED AND NEITHER IS OPTIONAL. Appending a candidate can only add denials, so +# the risk this change carries is a refusal EARNED BY THE WRONG CANDIDATE -- the BACKLOG #1085 shape, +# which is the reason the chdir guard exists at all. The chdir-away and explicit-target rows below are +# that direction; a suite carrying only the deny rows would pass against a rule that denied everything. + + +@pytest.mark.parametrize( + "chdir", + ["cd .", "cd ./", "pushd .", "chdir .", "sl .", "Set-Location .", "Push-Location ."], +) +def test_a_chdir_between_the_decoy_and_the_disarm_does_not_revert_the_closure( + repo: SimpleNamespace, chdir: str +) -> None: + """One no-op chdir, seven spellings, and every one was ALLOW before this fix. + + The verb list is the guard's own, so a spelling it suppresses the fallback for is a spelling this + row has to cover -- otherwise the fix closes ``cd`` and leaves ``Push-Location`` open. + """ + command = f"git commit -C HEAD && {chdir} && git config core.hooksPath /nope" + reason = assert_denied(run_gate(shell(command, cwd=repo.wt), repo.repos)) + assert "setting 'core.hooksPath'" in reason + + +def test_a_chdir_between_the_decoy_and_the_disarm_is_followed_across_a_SEMICOLON( + repo: SimpleNamespace, +) -> None: + """``&&`` short-circuits on a failed decoy; ``;`` does not, so this is the reachable spelling. + + It also asserts a DIFFERENT key, which is what stops the group from being seven names for one + assertion: a rule that recognised only ``core.hooksPath`` passes the rows above and fails here. + """ + command = 'git commit -C HEAD ; cd . ; git config alias.zz "commit --no-verify"' + reason = assert_denied(run_gate(shell(command, cwd=repo.wt), repo.repos)) + assert "setting 'alias.zz'" in reason + assert "setting 'core.hooksPath'" not in reason + + +def test_a_chdir_INTO_the_governed_repo_beside_a_decoy_is_denied( + repo: SimpleNamespace, vendored: Path +) -> None: + """The session stands in an ungoverned clone and walks into the governed repo before writing. + + Nothing on this line names the governed repository except the chdir, so a rule that cannot follow + the chdir cannot see the target at all. + """ + command = f'git commit -C HEAD && cd "{repo.primary}" && git config core.hooksPath /nope' + reason = assert_denied(run_gate(shell(command, cwd=vendored), repo.repos)) + assert "setting 'core.hooksPath'" in reason + + +def test_a_chdir_AWAY_from_the_governed_repo_still_allows( + repo: SimpleNamespace, vendored: Path +) -> None: + """The anti-narrowing direction, and the reason the chdir guard exists in the first place. + + The session stands in the governed primary, walks into an independent clone, and writes there. A + refusal here would name a repository the write never touches -- the BACKLOG #1085 defect. This row + passes against the pre-fix gate too, so it pins a property the fix had to KEEP, not one it added. + """ + command = f'git commit -C HEAD && cd "{vendored}" && git config core.hooksPath /nope' + assert run_gate(shell(command, cwd=repo.primary), repo.repos) is None + + +def test_an_EXPLICIT_target_beside_a_chdir_still_decides( + repo: SimpleNamespace, vendored: Path +) -> None: + """A chdir does not aim a write that carries its own ``-C``, and must not be able to refuse one. + + The followed chdir is appended only when the disarming invocation names no repository itself. Here + it names one, and it names the ungoverned clone, so the governed chdir must not manufacture a deny. + """ + command = f'git commit -C HEAD && cd . && git -C "{vendored}" config core.hooksPath /nope' + assert run_gate(shell(command, cwd=repo.primary), repo.repos) is None + + +def test_an_UNFOLLOWABLE_chdir_appends_no_candidate(repo: SimpleNamespace) -> None: + """A stated residual, recorded as a negative control rather than left to be discovered. + + Two chdirs are not a single token and this gate is not a shell, so the helper declines to answer and + nothing is appended -- the pre-fix behaviour, unchanged. This is BACKLOG #1000's shape: the ALLOW is + documentation of a limit, not an endorsement of it. It is also what stops a later fix from guessing + a base it cannot compute and refusing the wrong repository with confidence. + """ + command = "git commit -C HEAD && cd . && cd . && git config core.hooksPath /nope" + assert run_gate(shell(command, cwd=repo.wt), repo.repos) is None + + +def test_ordinary_config_after_a_chdir_is_untouched(repo: SimpleNamespace) -> None: + """The rule must not become a general ban on configuring a repository you walked into.""" + for command in ( + "git commit -C HEAD && cd . && git config user.email a@b.c", + "git commit -C HEAD && cd . && git config --get core.hooksPath", + "git commit -C HEAD && cd .", + ): + assert run_gate(shell(command, cwd=repo.wt), repo.repos) is None + + +# ---------------------- rule 3c: the READ exclusion belongs to ONE invocation (BACKLOG #1065) +# +# Same class as the rows above with a different token. The explicit read flags were matched against +# the WHOLE SEGMENT, so a read belonging to a NEIGHBOURING command excused the write beside it: +# +# git config core.hooksPath /nope DENY +# git config --list && git config core.hooksPath /nope ALLOW, and the write still lands +# +# Measured ALLOW on origin/main for every disarm key this rule names, from the primary, a nested +# worktree and a linked worktree -- fifteen rows. `git config --list` is an ordinary thing to run, so +# the excusing token needs no more intent than the `-C HEAD` this item was filed about. +# +# THE MUST-ALLOW ROWS ARE THE POINT OF THE PAIRING. Narrowing an exclusion can only add denials, and +# the thing that would break is an honest read: a suite carrying only the deny row passes against a +# rule that had lost the exclusion altogether. + + +@pytest.mark.parametrize( + "read", + [ + "git config --list", + "git config --get user.email", + "git config --get-all user.email", + "git config --get-regexp user", + "git config --list --show-origin", + ], +) +def test_a_NEIGHBOURING_read_does_not_excuse_the_write(repo: SimpleNamespace, read: str) -> None: + """The read is a different command on the same line, so it says nothing about the write.""" + reason = assert_denied( + run_gate(shell(f"{read} && git config core.hooksPath /nope", cwd=repo.wt), repo.repos) + ) + assert "setting 'core.hooksPath'" in reason + + +def test_a_neighbouring_read_does_not_excuse_the_write_across_a_semicolon( + repo: SimpleNamespace, +) -> None: + """A second separator and a second key, so the pair is not one assertion under two names.""" + reason = assert_denied( + run_gate( + shell('git config --list ; git config alias.zz "commit --no-verify"', cwd=repo.wt), + repo.repos, + ) + ) + assert "setting 'alias.zz'" in reason + + +@pytest.mark.parametrize( + "command", + [ + "git config --get core.hooksPath", + "git config --get-all core.hooksPath", + "git config --get-regexp core.hooksPath", + "git config --list --show-origin", + "git --no-pager config --get core.hooksPath", + "git config core.hooksPath", + "git config --list && git config --get core.hooksPath", + ], +) +def test_reading_a_disarm_key_is_still_not_a_write(repo: SimpleNamespace, command: str) -> None: + """The exclusion still fires for the invocation that owns the flag, which is the whole point. + + The last row is the pairing that matters: a read beside a read stays allowed, so narrowing the + window has not turned the exclusion off. ``git config `` with no value is the implicit read + (BACKLOG #1306) and is excluded by a different clause -- kept here so a change to either is visible. + """ + assert run_gate(shell(command, cwd=repo.wt), repo.repos) is None + + # ------------------------------- rule 3c: an EXPLICIT target outranks the IMPLICIT cwd (ordering) # # Get-GitTargetCandidatesRaw builds an ORDERED candidate list and rule 3c takes the first candidate git From 62a240370ebd3c99f4e1a8249b9bf9da581dd7d0 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 4 Sep 2026 18:36:16 -0500 Subject: [PATCH 2/2] docs(backlog): amend #1065 with the measured closure and the chdir hole it left, and file #1446 The scoring row's premise was stale. Rule 3c has walked the candidate chain since 7599ad190, and all thirteen rows of the item's own acceptance corpus DENY on origin/main's gate blob 8d5c4d50. The identical corpus reproduces the filed ALLOWs against a67838d2 / blob 3e7db362, so the rig can produce a different answer rather than agreeing with itself. What actually stood was one layer along: a single no-op chdir between the decoy -C and the disarm reverted every one of those thirteen closures to ALLOW, with the consequence read back from a different worktree of the same repository. The amendment records the mechanism, the fix, and the residuals as "at least these" rather than as a list. It also records a second live fail-open of the same class, closed in the same act: the explicit read flags were matched against the whole segment, so `git config --list && git config /nope` allowed the write -- fifteen rows, every disarm key by every cwd. The banner claims that shape was closed, but by the rejected round-3 patch, which never shipped. It also records three hashes, because the banner's pair is false and the sentence in #1061 that corrects it is now stale too: the installed hook is 460 lines behind the repo copy and carries the gate blob of 2b9f5b3c4, so it has this item's candidate chain and not #1379, #1359 or #1229. Nothing in this branch installs anything; the drift is reported, not repaired. #1446 is filed for the half deliberately left open: a relative -C on the disarming invocation still resolves against the SESSION cwd when a chdir in the guard window has already moved the shell. Measured in both directions and identical before and after the fix, because closing it means composing that chdir into the -C branch of the resolver, which is the widening two rejected rounds of #1065 died of. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5a070ff8b..876ca042e 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -392,7 +392,7 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | 27 | **#122** | Corrupted application-log detection, rollover, and connection-stop | 7 | 6 | _big bet_ | P2 | No enforcement ships: the only log handlers on main are the stdout StreamHandler at logging_setup.py:427 and the syslog family, and nothing anywhere reacts to a write failure by stopping work. The owner ruling of 2026-08-11 binds this to the count-and-log invariant, which is enforcement rather than the visibility the 2/10 priced. Difficulty 6 prices a fail-closed halt across the listener and the internal routed/outbound stages plus a console-seam change, with the eight branch commits unverified and carrying a seam bump #1220 obsoleted. | | 28 | **#318** | DAST — authenticated dynamic security testing of the running engine | 7 | 6 | _big bet_ | P2 | Increment 1 is on disk and wired into the required pytest legs; the remainder is increment 2, whose highest-value piece is fuzzing the unauthenticated MLLP/raw-TCP/X12 ingress plane, and no mutator exists in the tree to extend. Value 7 for a real gap with no workaround (every other security test here is static or in-process); difficulty 6 for a mutator the project does not have plus an OpenAPI security overlay with a fifth DEP-1 lock, a TLS black-box target and the console plane, with only the nightly-notice widening cheap. | | 29 | **#1059** | Shell variable indirection defeats the worktree gate's path resolution, so the primary is reachable | 7 | 6 | _big bet_ | P2 | The gate moved forward two versions without touching this: the shared resolver still hands raw candidate strings through with no sigil handling, and a test at test_worktree_gate_control_plane.py:685 now pins the variable spelling as a deliberate ALLOW, so the bypass is documented rather than closed. Difficulty stays at 6 because the fix moves fail-closed semantics into the resolver that rules 3, 3b, 3c and 3d all depend on, under 4935 lines of pinned gate tests, and needs remedy text precise enough not to be routed around plus the negative control #1000 demands. | -| 30 | **#1065** | Rule 3c reads the target-candidate SET as a scalar, so any `-C` token on the line disables it | 7 | 6 | _big bet_ | P2 | Rule 3c still takes the scalar $where[0] at worktree_gate.ps1:1031 from the set the resolver returns at :985, so neither banked fix is in the tree and the fail-open stands as filed. Value 7 is the rubric's real-gap-no-workaround rung: a gate that fails open admits no operator workaround, but the item's own Severity line records no product effect and no PHI effect, which forecloses rung 8's only applicable clause as well as rung 9's. Difficulty 6 because two written fixes were rejected by independent verification, the second with at least five new fail-opens and two false-deny classes that a 420-test green suite could not see, so the remainder is a correctness-gated rewrite of the matching rule. | +| 30 | **#1065** | Rule 3c reads the target-candidate SET as a scalar, so any `-C` token on the line disables it | 7 | 6 | _big bet_ | P2 | CORRECTED 2026-09-04, re-measured: the scalar read is gone (rule 3c walks foreach ($cand in $where)) and all thirteen rows of this item's own acceptance corpus DENY on origin/main's gate blob 8d5c4d50, so the still-takes-the-scalar premise this rationale carried is stale; what actually stood were two live fail-opens of the same class, both closed in the amendment -- one no-op chdir between the decoy -C and the disarm reverted every one of those thirteen rows to ALLOW, and a neighbouring `git config --list` excused the disarm beside it in fifteen more. The numbers are left as the re-scoring pass set them rather than re-priced against a different remainder. Value 7 is the rubric's real-gap-no-workaround rung: a gate that fails open admits no operator workaround, but the item's own Severity line records no product effect and no PHI effect, which forecloses rung 8's only applicable clause as well as rung 9's. Difficulty 6 because two written fixes were rejected by independent verification, the second with at least five new fail-opens and two false-deny classes that a 420-test green suite could not see, so the remainder is a correctness-gated rewrite of the matching rule. | | 31 | **#1066** | Rule 3c strips double quotes only, so a single-quoted -C target bypasses it -- including an absolute one | 7 | 6 | _big bet_ | P2 | Both halves are live on the repo blob and on the installed hook that actually governs this machine: the -C reader is the unchanged double-quote-only regex and the disarm-key match still reads the quote-blanked scan text, so a single-quoted absolute -C and every quoted -c alias override remain reachable by ordinary spellings. Value 7 because this governs agent behaviour in development with no product or PHI effect, which is above the rubric's parity rungs but below the production and ASVS rungs; difficulty 6 because two written fixes have been rejected by adversarial verification, the second yielding at least five new fail-opens with residuals filed as #1067, #1069, #1070, #1071 and #1072. | | 32 | **#1108** | research an honest pass for ASVS 2.1.1 -- what the operator API's input validation rules would have to be before any documentation could define them | 7 | 6 | _big bet_ | P2 | Counted at HEAD, api/models.py carries 21 max_length bounds against 206 str annotations, reproducing the item's 21-of-205, expose_docs still ships False at settings.py:726, and no per-field validation reference exists under docs/ (HL7-VALIDATION.md and CODESETS.md are both data-plane). Value 7 for a real L1 gap with no workaround where the coverage half is not merely documentary; difficulty 6 because the item forbids restating field types as the honest pass, so an expected structure must first be decided for ids, connection names, globs, time ranges and free-text search and then made real across the API models under mypy strict, with the console, harness, IDE extension and apiclient all consuming those bounds. | | 33 | **#1109** | research an honest pass for ASVS 2.2.1 -- positive validation that does not sacrifice the tolerance the HL7 default exists to protect | 7 | 6 | _big bet_ | P2 | Both limbs stand -- validation.strict ships False at config/models.py:667 and api/models.py carries no model_config across 84 models, against five extra=forbid declarations in config/models.py. Value 7 not 8: the 2.2.1 cell is graded at LEVEL 1, so rung 8's ASVS L3 Partial limb does not reach it, and an authenticated loopback-bound API silently ignoring unknown body keys is not rung 8's production blind spot with no workaround -- it is rung 7, a real gap an operator cannot close from outside the app. Difficulty 6 stands: two independent limbs, a method ruling on which clause binds an L1 requirement inside an L3 assessment, and an API-side positive-validation change that would reach the console and apiclient callers too. | @@ -6454,6 +6454,49 @@ Fixed in the rule-3c follow-on work. **The rule-3 and rule-3d half is NOT this i **Two further classes found in the same pass are INHERITED rather than introduced and are filed as #1379**, both consequence-proven against the governed shared config: `-C` precedence composition, and the PowerShell environment-assignment spelling. + +**AMENDMENT 2026-09-04 (builder). THE SCORING ROW'S PREMISE IS STALE, THE CLOSURE IT DENIES IS REAL -- AND ONE NO-OP TOKEN REVERTED IT, WHICH IS WHAT THIS ACT FIXES.** + +**Re-measured, not argued.** Throwaway governed rig with a primary, a nested worktree under `.claude/worktrees/`, a linked sibling worktree and an independent clone; hook subprocess cwd set equal to the payload cwd. **All thirteen rows of the measured acceptance corpus above DENY** on `origin/main`'s gate, and the scalar read the scoring rationale still names is gone -- rule 3c walks `foreach ($cand in $where)`. The identical corpus run against the gate this item was FILED on (`a67838d2`, blob `3e7db362`) reproduces the filed ALLOWs, so the rig can produce a different answer rather than agreeing with itself. + +**THE CLOSURE WAS ONE ORDINARY TOKEN FROM BEING REVERTED.** + +``` +git commit -C HEAD && git config core.hooksPath /nope DENY +git commit -C HEAD && cd . && git config core.hooksPath /nope ALLOW <- the same write +``` + +Thirteen rows moved back to ALLOW on `origin/main`: seven chdir spellings (`cd`, `cd ./`, `pushd`, `chdir`, `sl`, `Set-Location`, `Push-Location`), three cwds (the primary, a nested worktree, a linked worktree), the `;` join as well as `&&`, and `alias.*` as well as `core.hooksPath`. **Consequence read back rather than inferred:** with `;` the command really runs, and `core.hooksPath` then reads `/nope` **from a different worktree of the same repository** -- which is what proves it reached the SHARED config rather than something local. `cd .` is a no-op, so the poison needs no intent, no relative path and no unusual spelling. + +**MECHANISM -- this item's own fix, one layer along.** The chdir guard covers `[first git token, disarm)` and answers a chdir by SUPPRESSING the base candidate. The resolver cannot follow that chdir either: its `$Prefix` is sliced at the FIRST git token and the chdir sits after it. So on a line that also carries a decoy `-C` the candidate set collapses to the decoy alone, git rejects it, the chain runs out, and the rule allows. **Suppressing the base is not the same as knowing where the write lands.** + +**THE FIX FOLLOWS THE CHDIR INSTEAD OF DECLINING TO.** `Get-ChdirTargetRaw` is extracted from the resolver -- one definition, two callers, no second spelling of "follow a cd" -- and rule 3c calls it on the guard window, composing a relative window chdir onto a prefix chdir and appending the result as the **LAST** candidate. Gated on the disarming invocation naming **no repository of its own**, so an explicit `-C`/`--git-dir` still decides and a governed chdir cannot manufacture a refusal for a write aimed elsewhere. `$where[0]` never moves, so the unresolvable-target refusal is still decided on exactly the token it was. + +**A SECOND LIVE FAIL-OPEN OF THE SAME CLASS, FOUND BY THE SAME SWEEP AND CLOSED IN THE SAME ACT.** The explicit READ flags were matched against the **whole segment**, so a read belonging to a NEIGHBOURING command excused the write beside it: + +``` +git config core.hooksPath /nope DENY +git config --list && git config core.hooksPath /nope ALLOW <- the same write +``` + +**Fifteen rows on `origin/main`** -- every disarm key this rule names (`core.hooksPath`, `core.worktree`, `include.path`, `includeif.`, `alias.*`) by every cwd (primary, nested worktree, linked worktree). **Consequence read back:** run from the LINKED worktree, the PRIMARY then reads `/nope`. This banner already claims the shape was closed -- but by the **rejected** round-3 patch, which never shipped, so it has been live the whole time and had no number of its own. The exclusion is now tested against `$ownCmdWin` plus `$rest`, the same window `$ownGitDir` already uses, so `git config --get ` and `git --no-pager config --get ` are excluded exactly as before while the neighbour no longer reaches across the separator. + +**RESIDUALS -- READ THIS AS "AT LEAST THESE", and none of it says rule 3c is unbypassable.** More than one chdir in the window is not followed: the helper declines rather than guess a base, and that ALLOW is pinned as a negative control (#1000's shape) rather than left to be discovered. A prefix chdir the helper cannot follow suppresses the append entirely. A chdir target that is **quoted AND contains a space** is blanked on the scan string and is not followed -- measured on a rig whose governed root is literally `Pri mary`, where `cd ""` stays ALLOW while the unquoted spelling, `cd "."` and `cd '.'` all DENY, because `Remove-QuotedSpans` unmasks a quoted span holding one bare word and blanks one containing whitespace. That is the same space family the file already records against the `-C` reader. Everything #1066, #1067 and #1069-#1072 record stays open. + +**NOT FIXED HERE, FILED AS #1446** (allocated on this branch, so the citation resolves only once both land): a RELATIVE `-C` on the disarming invocation still resolves against the SESSION cwd when a chdir in the guard window has already moved the shell. Measured in both directions and identical before and after this act. Closing it means composing that chdir into the `-C` branch of the resolver, which is the widening two rejected rounds died of. + +**THE BANNER'S HASHES ARE FALSE, AND THE SENTENCE IN #1061 THAT CORRECTS THEM IS NOW STALE TOO. Measured 2026-09-04:** + +| what | value | +|---|---| +| the banner's claim | commit `a67838d2`, blob `3e7db362` | +| `origin/main`, immediately before this act | blob `8d5c4d50`, sha256 `59c896fc`, 3172 lines, `$GateVersion` `2026.09.03.1` | +| INSTALLED at `~/.claude/hooks/worktree_gate.ps1` | sha256 `6d95811e`, 198887 bytes, 2712 lines, `$GateVersion` `2026.08.13.1` | + +The installed hook is **460 lines behind the repo copy** and carries the gate blob of `2b9f5b3c4` -- so it has this item's candidate chain and **not** #1379's `--git-dir` ranking, #1359's rule-3b verbs or #1229's encoding fix. #1061's correction (*"the installed hook hashes to `e7133498`, identical to `origin/main`'s blob ... byte-identical"*) was true when written and is not true now, so **a third re-measurement is owed before either sentence is cited again**. Nothing in this act installs anything: the drift is reported, not repaired. + +**The score is NOT moved here.** Its stated basis is corrected because it was factually wrong; the numbers are left where a re-scoring pass put them, because what remains open under this title (the residuals above, plus #1446) is a different quantity from what it was measured on and re-pricing it is not this act's work. + ## 1066. Rule 3c strips double quotes only, so a single-quoted -C target bypasses it -- including an absolute one > ✅ **DECLINED-BY-DESIGN 2026-08-25, OWNER RULING extending the 2026-08-23 tokeniser ruling to the family (#1066/#1070/#1086/#1305/#1336).** Confirmed one-hop: the Liaison put it to the owner directly and read the answer, rather than relaying a third party's account. **#1336 already established this row cannot be fixed alone** -- it and `#1086` are "the same design error pulling in opposite directions," and closing this row's fail-open without widening `#1086`'s false-deny needs exactly the block-tracking shell tokeniser the ruling declines. The banked second fix below remains open evidence of why a narrower attempt still fails: it is MINIMAL-FROM-COMMITTED rather than a parser, and it was REJECTED by four independent verifiers finding at least five new fail-opens and two new false-deny classes -- the same failure shape #1336's own history shows four separate candidates hitting. **The gate governing this machine is unchanged and this row's bypass is real and remains live**; the ruling accepts that risk rather than building a fifth tokeniser-shaped candidate to close it. 🔢 **Re-scored 2026-08-20 -> P2.** Value **7/10** · Difficulty **6/10** · _big bet_. Both halves are live on the repo blob and on the installed hook that actually governs this machine: the -C reader is the unchanged double-quote-only regex and the disarm-key match still reads the quote-blanked scan text, so a single-quoted absolute -C and every quoted -c alias override remain reachable by ordinary spellings. Value 7 because this governs agent behaviour in development with no product or PHI effect, which is above the rubric's parity rungs but below the production and ASVS rungs; difficulty 6 because two written fixes have been rejected by adversarial verification, the second yielding at least five new fail-opens with residuals filed as #1067, #1069, #1070, #1071 and #1072. _(was 9/10 · 3/10.)_ @@ -22066,3 +22109,41 @@ So `test_the_script_prefers_its_own_repo_over_an_earlier_path_entry` supplies th **Verification:** 8 passed in `tests/test_webconsole_seam_snapshot.py`. Mutation check run rather than argued -- with the `sys.path.insert` line deleted, the decoy test reds naming the decoy import, and the by-path digest test **stays green**, which is the luck described above measured rather than predicted. Anchor restored, 8 passed again. **Adjacent and NOT fixed here, named rather than numbered.** `docs/WEBCONSOLE-PACKAGE.md`'s seam-refresh procedure is stale in three steps left behind by #1220: it says to bump `ENGINE_UI_SEAM` by hand (`1` to `2`) when the value is a derived digest, it says to update curated lists in this script that #1220 retired, and its step 5 prescribes `python scripts/webconsole_seam_snapshot.py > tests/golden/...`, the shell redirect this script's own docstring forbids because PowerShell's `>` writes UTF-16LE with a BOM into a file the test reads as UTF-8. That is doc drift with its own cause and it wants its own item; folding a documentation rewrite into a `sys.path` fix would make both harder to review. + +## 1446. Rule 3c resolves a relative -C against the session cwd when a chdir in the guard window has already moved the shell + +> 🔢 **Filed 2026-09-04 (builder) -- MEASURED IN BOTH DIRECTIONS, INHERITED RATHER THAN INTRODUCED, AND DELIBERATELY NOT FIXED IN THE ACT THAT FOUND IT.** Found by the adversarial pass on #1065's chdir-window fix, measured against `origin/main`'s gate and against the branch that closes that one, and byte-identical on both. + +**Cluster:** Session-drift controls / gate integrity. **Priority:** P2. **Verdict:** build. + +**Severity:** no product effect and no PHI effect -- this governs agent behaviour in development. *(Clause carried verbatim from #1065, because without it this row reads as a product-severity claim and it is not.)* It is a **fail-open in an enforcement control** whose mirror is a **false deny naming a repository the write never touches**, and both are reached by ordinary tokens. + +**What.** `Get-GitTargetCandidatesRaw` roots a relative `-C` against the session cwd, composing it with a chdir it can see in the PREFIX (#1085). It cannot see a chdir sitting BETWEEN the first git token and the disarm, because the prefix is sliced at the first git token. So when the disarming invocation carries its own relative `-C` and a chdir has already moved the shell, the gate measures that `-C` against the directory the session started in rather than the one git will stand in. + +**Measured 2026-09-04.** Throwaway governed rig -- a primary, a nested worktree, a linked worktree and an independent clone -- with the hook subprocess cwd set equal to the payload cwd. Identical verdicts on `origin/main` and on the #1065 chdir-window branch: + +``` +cwd = an UNGOVERNED clone + git commit -C HEAD && cd "" && git -C . config core.hooksPath /nope ALLOW + reality: the write lands in the GOVERNED primary's shared config + +cwd = the GOVERNED primary + git commit -C HEAD && cd "" && git -C . config core.hooksPath /nope DENY + reality: the write lands in the ungoverned clone, and the refusal names the primary + +controls, the same `-C .` with no chdir on the line + from the primary DENY + from the ungoverned clone ALLOW +``` + +The controls are what make the two rows above readable: `-C .` alone is judged correctly in both directions, so the chdir is the only variable. + +**ONE ROOT CAUSE, SYMPTOMS IN BOTH DIRECTIONS**, which is a signature this file already records twice -- a base that is not the base. A fix aimed only at the ALLOW direction produces the DENY direction, and #1085 is this same defect one window earlier on the line. + +**WHY #1065's ACT STOPPED SHORT, stated so the gap is not read as an oversight.** That fix appends the followed chdir as a candidate ONLY when the disarming invocation names no repository of its own. The gate is deliberate: when an explicit `-C` or `--git-dir` IS present, that token decides where the write lands, and letting a governed chdir outrank it manufactures exactly the false deny above. Closing THIS row instead means composing the window chdir into the **`-C` branch** of the resolver -- widening the base every `-C` on every calling rule is measured against -- and that is the class of change two rejected rounds of #1065 died of, both times because the replacement turned out narrower or wider than the matching it displaced. + +**ANY FIX MUST PIN BOTH DIRECTIONS PLUS AN UNGOVERNED CONTROL**, red-first against the pre-fix gate in each direction. A suite carrying only the fail-open row passes against a rule that denies everything, and the false-deny row is the one that actively misinforms the session reading it. + +**Related:** #1065 (the pass that measured this, and whose fix deliberately stops at the gate described above), #1085 (the same COMPOSE-versus-PREFER defect in the prefix window, fixed and verified), #1066 (declined-by-design 2026-08-25, the residual family this joins), #1000 (a control green because it cannot see the class it covers). + +**Source:** the adversarial pass on #1065's own chdir-window fix, tasked with finding the shapes the new matching does not catch rather than confirming the ones it does.