fix(gate): read a quoted span in the encoding its own host uses (BACKLOG #1229) - #830
Merged
Conversation
…LOG #1229) The quote scanner blanked spans without knowing which escape character the executing host uses. PowerShell escapes with a backtick, so the scanner held a span open that PowerShell had already closed, straddled the live command between it and a later quote, and blanked it. A gated git command in that position reached no rule. Remove-QuotedSpans and Get-ScannableSegments now take a convention name rather than a bool that could only say "sh" or "not sh". Get-FlagOwner returns that name so an extracted payload keeps its own interpreter's rules, and the Windows set splits into a pwsh set and a cmd set, cmd having no escape character at all. Get-EscapeChar is the single table both the scanner and the extraction regex derive from, so the two spellings that disagreed in an earlier round are now unrepresentable rather than watched. Get-ScannableSegments additionally emits one extra segment for a cmd-owned payload: the outer host's escaped quotes resolved, then cmd's documented wrapper quotes removed, which is the encoding cmd.exe actually receives. An extra segment cannot introduce a fail-open because every rule reaches a segment through a continue-or-deny loop and the new view is appended after the existing ones, leaving rule 3's first-verb bookkeeping untouched. Measured against hash-verified copies of each build, every arm carrying a positive control that must deny. Against origin/main the PowerShell backtick straddle goes ALLOW to DENY, and the change introduces no new ALLOW across an exhaustive sweep of 7,056 side-string pairs plus about 28,000 random samples, with the oracle proven live by 84 confirmed closures in the same run. The posix arm is byte-identical to origin/main across that corpus. Folding cmd into the backtick set was measured and rejected: it loses a deny the gate has today, because a backtick is an ordinary character to cmd.exe, and that shape really executes. Decoding in place rather than appending was also measured and rejected: it re-opens a nested bash-c deny. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#1229 is NOT closed by the quote-scanner fix. A quoted span crossing a newline straddles through the gate's per-line split, and inserting one newline into a quoted word re-opens every shape this item closed, including the shape in the item's own title. Reproduced three times against hash-verified gate copies, with a positive control denying and the middle statement pinned to a marker that computes rather than echoes. The re-score's conditional, that a reviewer may rule the escape hole a separate filing, names the backslash escape. That hole was fixed here rather than re-filed, so the conditional does not apply. What governs is the unconditional half of the same sentence: a wrongly-closed item is invisible forever. #1429 records the newline class as a separate FIX rather than a separate CLASS, and states that it re-opens this item's pins. Both items cross-link. Counts are floors with their corpus named rather than enumerations presented as properties. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the BACKLOG.md conflict deliberately rather than mechanically. The pull_request workflows could not run at all before this. GitHub builds no refs/pull/830/merge for a CONFLICTING pull request, so every pull_request- triggered check was ABSENT rather than queued or failing -- 2 checks against 42 to 46 on every other open pull request. Only push and pull_request_target workflows fired. #1229 KEEPS MAIN'S CLOSURE. This branch had reverted it. Another seat closed the item on 2026-09-03 after verifying the escape limb shipped, and explicitly scoped the per-line split out as an inherited residual. Deferring to that: a class that defeats every rule in the gate is its own item, not evidence that one item is unfinished, and settling a scope disagreement between seats inside a merge resolution would make it invisible to the seat that closed it. #1429 IS KEPT, AND IT IS A COUNTEREXAMPLE TO #1427. That item, added on main while this branch was held, records the per-line split as producing no hole -- six spellings tested, zero found -- and cites the gate's own docstring agreeing that a quoted argument spanning lines denies today anyway. This branch measured the opposite three times with a positive control and an inert marker that computes. One of the two readings is wrong; #1429 is where that gets settled. Both sides' new items are kept, interleaved in number order. The gate itself auto-merged with no conflict, and the merged gate was driven before this commit: the PowerShell backtick straddle goes ALLOW on both old and new main to DENY on the merge, with every control still denying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For the Lander, first
The BACKLOG.md row is alone in the final commit (
8766bc170), with all code and tests in5ceb3b08bbefore it. A conflict here is confined to one commit and should be a scripted row-merge.Merge safety measured, not assumed.
#1359landed on this same hook while this branch was held.git merge-treeagainst currentorigin/main:scripts/hooks/worktree_gate.ps1auto-mergesclean; only
docs/BACKLOG.mdconflicts. I then drove the merged gate rather than trusting aclean textual merge of two independent edits to one scanner, every arm with a positive control:
So the fix is still needed against current main and survives the merge.
I have not enqueued this and have not armed auto-merge. The number is yours to take.
#1229 stays OPEN. It is not closed by this change; see the ledger commit and the section below.
fix(gate): the quote scanner learns PowerShell's escape, then reads a cmd payload in the encoding cmd receives (BACKLOG #1229)
THIS FILE MUST NOT MERGE, AND AS OF THIS COMMIT IT CANNOT. It is
git rm --cacheded and matchedby a
/PR_BODY.mdrule in.gitignore, so it is untracked working-tree scratch that stays with theworktree and never reaches
main. It sits at the repo root only because a push hold stopped a pullrequest being opened. Whoever opens the PR: paste this as the body, then leave the file untracked.
NOT PUSHED. The branch
claude/gate-quotes-1229is local only, on theMessageFoundry-gate-quotes-1229worktree. Nothing has reached any remote: nogit push, nogh pr create, no label, no comment.BACKLOG #1229is claimed by this worktree(
scripts\coord\claim.ps1 -Take 1229); release it if the branch is abandoned.Read this first: the fail-opens the fifth round introduced are CLOSED
The second pass of this branch introduced live fail-opens and pinned them as tripwires rather than
fixing them. This third pass fixes them, and the floor turned out to be 5 rather than 2 -- one of the
five on a DIFFERENT RULE, which is what rule-agnostic means. No new
ALLOW replaces them: zero across 56 driven shapes, backed by a structural argument that is stronger
than the sweep.
NEW fail-opens INTRODUCED2-> 5, all CLOSED in round 6Every row is a floor, not an enumeration. No sweep here ranged over the whole input space, and
the eight false denies all share one left-hand side (an odd trailing backtick), so they are one
family enumerated rather than eight independent classes.
The full record lives in
docs/BACKLOG.md#1229 and #1429, not here. This file does not restateit. What follows is only what a reviewer needs in order to decide.
#1229 IS NOT CLOSED. A closure on this branch is reverted.
Insert one newline into a quoted word and this item's own titled shape goes back to ALLOW, on
origin/mainand on this branch alike, and the middle statement runs. Reproduced independently threetimes, against gate copies hash-verified byte-identical to
origin/main(blob
b194d0a0420a6c87b8786994785155c91908d7b6) and to the branch head(blob
94162a04a6fe51fa7ac2a7f7c6a14de2d3a4e4d8):ORIG+NLruns: with the gated command swapped forexpr 111 \* 3, bash prints333, which anecho-back cannot produce.
The closing argument is retracted at the claim. It leaned on the 2026-08-20 re-score's "if a
reviewer rules the escape hole a separate filing, the remainder text above is the filing." That
antecedent names the backslash escape, which this change fixed rather than re-filed, so the
conditional never triggered. The same sentence's unconditional half governs: a wrongly-closed item
is invisible forever.
#1429 is correctly allocated and stays open -- as a separate FIX, not a separate CLASS. Its
banner previously called the newline case distinct while conceding it is "#1229's straddle exactly";
that contradiction is resolved in favour of the measurement, and the two items now cross-link.
The fail-opens the fifth round introduced, and how round 6 closes them
Under the PowerShell tool, against gate copies hash-verified byte-identical to
origin/main, toround 5 and to round 6, cwd inside the governed repo:
The rule-1 row is the one that matters most for the count. The defect is in the SCANNER, so it
disarms whatever rule sits behind it; enumerating by rule under-counts by construction. Round 5
enumerated rule 3. Nothing here swept rule 3b, so 5 remains a floor.
RUNSmeans the inert markerset /a 111*3printed333through the real shape; the negativecontrol
set /a nope*3prints0, so an echo-back cannot pass for a run. Round 5 recorded 2 hereand the honest number is 4 -- it measured the two rows it knew about; sweeping the class found two
more of the same mechanism.
The mechanism. The outer line is PowerShell, so the outer scan honours the backticks, sees one
span, blanks it and reaches no verdict. Extraction then asks
Get-FlagOwner, which answerscmd, whose convention isnone-- but the extracted text still carries the outer host's backticks,because pwsh has not run and nothing has consumed them. Scanning it with no escape rule pairs the two
escaped quotes across the git command and blanks it. Span ownership deciding the wrong way, one level
in. Verified here rather than inherited, by driving
Get-ScannableSegmentsdirectly: the outerscan yields
cmd /c ""and the extracted payload scan yields`"".The fix.
Get-ScannableSegmentsemits ONE EXTRA SEGMENT for a cmd-owned payload: the same textwith the outer host's escaped quotes resolved and cmd's own documented
/cwrapper quotes removed --the encoding cmd.exe actually receives. Both steps are needed and neither is enough alone, measured:
decoding leaves
"git -C <governed> reset --hard", which the scanner blanks as an ordinary quotedspan; unwrapping without decoding finds a backtick in first position and does nothing.
An extra segment cannot introduce a fail-open, and that argument carries the claim rather than the
sweep. Every rule reaches a segment through a continue-or-deny loop, so an added segment can only
ADD a deny, and it is appended after every existing segment so rule 3's first-verb-wins bookkeeping
sees exactly what it saw.
Two tidier fixes, both measured WRONG
1. Fold
cmdandwslinto$pwshSet. Round 5 asserted this was wrong and could not show itbehaviourally; the assertion HOLDS, and here is the probe. It separates the two conventions in the
direction that matters -- the fold loses a deny the gate has today:
A backtick is an ordinary character to cmd.exe, so the quote after it really closes the span and the
gated command stays in plain view; reading that quote as escaped holds the span open across the gated
command and blanks it.
&is used and not;, because;is not a command separator in cmd; apwsh single-quoted string is fully literal, which is what carries the backtick through intact. Now
pinned as
test_a_LITERAL_backtick_in_a_cmd_payload_does_not_hide_a_gated_command.Round 5's own separating probe is retired, not kept: it read
head ALLOW / cmdfold DENY, andround 6 denies both, so it no longer separates anything.
2. Decode the payload IN PLACE rather than adding a view. This is the change that reads as a
correction, and it re-opens round 3:
bash -c "bash -c \"<gated>\""goes DENY -> ALLOW, becausethe function recurses ONE level and that round-3 deny depends on the escaped text staying visible at
this level. Measured, two new fail-opens.
Costs, stated because a one-sided note reads as a clean win
Round 6 re-denies shapes round 5 had allowed that are INERT on this host. Both DENY on
origin/maintoo, so these are pre-existing false denies restored, not new ones:
wsl -cis not a valid wsl flag at all --wsl -c <anything>prints "Invalid command lineargument: -c", while
wsl -- expr 111 \* 3prints333, so wsl itself works and the probe isnot dead.
cmd /c ""echo git checkout main""merely echoes. Its unescaped twincmd /c "echo git checkout main"denies onorigin/mainand on every build since, so this is apre-existing prose-inside-an-interpreter-payload false deny, reached rather than created.
Round 6 also closes two PRE-EXISTING holes of the same wrapper class, both ALLOW on
origin/main:cmd /c "\"<gated>\""under the Bash tool, andcmd /c ""git -C worktree remove""(rule 2). The rule-2 row's carriage RUNS. The Bash-tool row could not be pinned towhether it RUNS, because
cmdis not on this environment's Bash PATH (command -v cmd-> notfound). Recorded as unmeasured rather than assumed.
No sweep cited here is reproducible from this branch
That applies to round 6's as much as to round 5's. The 22-shape matrix, the 64-shape sweep, and round
6's two corpora (38 shapes covering the reported fail-opens, the fold probes, every earlier round's
tripwires and the #1429 newline rows; plus 18 aimed only at false denies through the new code path)
all lived in a session scratchpad with their drivers. Their counts must not be cited as standing
evidence. What a reader can re-derive from the branch alone is every row pinned as a test in
tests/test_worktree_gate_quote_straddle.pyandtests/test_worktree_gate_escaped_quote.py, plusthe tables above, each of which names its corpus and its controls. Rebuilding the
maincolumn istwo
git shows into a scratch directory driven through the same subprocess harness the suites use.Round 6's "zero new ALLOWs" is a floor over 56 driven shapes, not over the input space -- which
is weaker than the structural argument beside it, and is why that argument carries the claim.
What changed in the code
Unchanged from the original filing and still the right shape:
Remove-QuotedSpansandGet-ScannableSegmentstake[string]$Convention(
posix|pwsh|cmd|none) instead of[bool]$PosixEscapes. The bool could say only "sh"or "not sh", so a
pwshpayload and acmdpayload arrived identical and the backtick had nowhereto live. Default
none: an unmeasured host gets no escape rule.Get-HostConventionmaps the tool name for the outer line;Get-FlagOwnerreturns a conventionname and
$owneris passed through whole, so an extracted payload keeps its interpreter's rules.$winSetsplits into$pwshSetand$cmdSet. The no-change option for cmd and wsl -- and seethe two new fail-opens above, which are the cost of that split rather than a reason to undo it.
Get-EscapeCharis the single escape table and the extraction regex is derived from it, so theround-3 duplicate-spelling defect is unrepresentable rather than watched.
Added by this third pass (round 6), five small pieces:
Remove-EscapeCharsresolves the OUTER host's escaped quotes. Escaped quotes only, and thenarrowness is measured: a blanket strip turns
D:\Work\xintoD:Workx, so the view built toSEE a gated command stops resolving the repository it names.
printf '%s' "D:\Work\x"printsD:\Work\xon this host -- sh honours the backslash before$`"\and newline andnowhere else. It reads
Get-EscapeChar, so there is still one escape table.Remove-CmdWrapperQuotesapplies cmd.exe's own documented/crule (cmd /?): where the firstcharacter is a quote, strip it and the last quote on the line. Its other arm -- quotes preserved
when the quoted text names an executable file -- is a filesystem question this hook cannot answer,
and getting it wrong only makes MORE text visible.
single-quoted word is fully literal on both hosts, so re-decoding it would corrupt a legitimate
backslash or backtick.
[regex]::Replace. The regex form isshorter and it is a quoting hazard inside the one file whose subject is quoting -- an equivalence
check of the two forms failed on the apostrophe case purely on how the pattern had to be spelled.
Checks run
pytest -k worktree_gate(sequential, against the exact committed gate)pyteston the two changed test files, after the last editmypy messagefoundry(strict)ruff check .ruff format --check tests/Missing ')'git status --shortEvery gate copy driven in the tables above is hash-verified against the file being committed:
git hash-objectonscripts/hooks/worktree_gate.ps1and on the scratchpad copy return the sameblob, and the
mainandround 5columns come fromgit show origin/main:andgit show HEAD:whose blobs match
git rev-parsefor those refs.The test arithmetic, reconciled
pytest -k worktree_gate --collect-only -qon the previous pass ->767/15611 tests collected.767 + 3 (the pass before, uncounted here) + 2 added by round 6is not how it reconciles, so here is the arithmetic that does: the runimmediately before round 6's test edits reported
2 failed, 768 passed= 770 outcomes; round 6adds exactly 2 tests and flips the 2 failures to passes, giving 772. The 2 failures were the
KNOWN_NEW_fail_opentripwire asserting ALLOW, which is the success signal it was written to give.tests/test_off_loopback_runbook.py:55andtests/test_runbook_proxy_tls_floor.py:66, both"docs/security/OFF-LOOPBACK-DEPLOYMENT.md is private-only". A module skipped at import reports
one SKIPPED outcome and contributes zero collected tests.
uncommitted intermediate working tree and is not recoverable from git. Recorded, not corrected.
Skipped: the full
pytestsuite. This worktree's venv installs fewer extras than CI(BACKLOG #1230), so a full local run would not be a clean-suite claim in any case. Legs that only
run on a hosted runner have not been read,
windows-service-smokeincluded.Open questions for a reviewer
opened at least four; round 6 closes those four and opens none across 56 driven shapes, with a
structural argument -- an added segment can only add a deny -- that does not depend on the sweep.
The remaining trade is a handful of restored false denies on shapes that are INERT on this host
and that
origin/maindenies anyway.#1229stays OPEN. The #1429 newline class is untouched by this pass. If a reviewer wants theitem closed, the newline evidence in its banner is what has to be answered first.
#1429is unscored -- filed with a verdict and closing-act, no value or difficulty.#1229's row is still indocs/BACKLOG.md;36 of the 231 rows in that table already point at items that are now closed, so touching one row
would be an unrequested change inconsistent with 35 neighbours.
#1341(the siblingblock-blanket-git-stage.ps1,whose
Hide-QuotedSpanshas this class open),#1359,#1086. Nothing here was measured againstthem.
tests/test_worktree_gate_quoted_key.pyis untouched and no row flipped.""is deliberately not modelled. PowerShell's doubled-quote escape needs no rule: naivepairing covers the same extent, so there is no gap for live code, and the extraction regex is left
equally blind so the two cannot disagree.
cmdbecause that is what was measured.Get-FlagOwneranswerscmdfor cmd and wsl alike, and the wrapper rule is cmd.exe's, so for wsl it is anover-approximation -- the harmless direction. It is deliberately NOT widened to the
posixandpwshconventions: no probe here separates them, and widening a security control by analogy ishow rounds 2 through 5 happened.
cmd /?preserves the quotes when the quoted text names anexecutable file; that is a filesystem question the hook cannot answer, and guessing wrong only
makes more text visible. Named so nobody reads the wrapper rule as complete.
Scope
A local maintainer-workstation guardrail whose own
.SYNOPSISdeclines to be a security boundary.No product, engine or PHI effect, and no deployment axis (CLAUDE.md section 0). What it costs is
the guardrail's reliability on shapes a developer reaches for by habit.