fix(hooks): a backslash-escaped quote must not end a double-quoted word in the Bash write guard - #10406
Draft
qq9340100 wants to merge 1 commit into
Draft
fix(hooks): a backslash-escaped quote must not end a double-quoted word in the Bash write guard#10406qq9340100 wants to merge 1 commit into
qq9340100 wants to merge 1 commit into
Conversation
…rd in the Bash write guard An escaped \" inside a double-quoted word ended the quote, so everything after it was re-read as bare shell. In a `node -e "..."` program that tail contains JS arrow functions, whose ASCII > landed in redirection-operator position — and the guard then named the following JS fragment as a write "target" and blocked a pure-read command. A guard that names a concrete target is convincing, so the false positive trained agents toward OS_ALLOW_MAIN_EDITS=1: the one outcome a write guard must never encourage. Backslash now keeps its meaning inside "..." only before " \\ $ and a backtick, per POSIX, in both the segmenter and the tokeniser; single quotes still take no escapes. This narrows the block side only — no shape that was allowed becomes blocked. Also pins the ASCII-only operator contract in the self-test: non-ASCII codepoints are never operators or operator boundaries, with negative twins asserting a real ASCII redirect in an otherwise-similar command still blocks. Part of #10247 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T1sSZbQTnLhrK9HhNdNiB
This was referenced Aug 20, 2026
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.
Part of #10247
Premise correction: the arrow was never the cause
The card's diagnosis is that the guard splits the command at the
→(U+2192), treating a non-ASCII codepoint as a redirection operator. I could not reproduce that, and it is impossible by construction:→U+2192e2 86 92⇒U+21D2e2 87 92➜U+279Ce2 9e 9cASCII
>is0x3eand<is0x3c. Every byte of a non-ASCII UTF-8 codepoint is>= 0x80, so an arrow cannot collide with an operator byte-wise; and the operator test is a bashcase "$ch" in '>' | '<')over${s:i:1}, which is character-wise in a UTF-8 locale and byte-wise otherwise. I ran the card's isolating experiment on the hook unchanged atorigin/main(the file is byte-identical to the card's19f98fa1—git log 19f98fa1..HEADon it is empty) under all three locales present in the container (C.utf8,C,POSIX): arrow andthenboth ALLOW, in every locale. The arrow is not decisive.The card's own evidence already says so. Its reported target is:
That fragment contains the arrow rather than starting after it, and it begins immediately after
st=>— a real ASCII>, in the JS arrow function.The real cause
\"inside a double-quoted word ended the quote. POSIX says backslash keeps its meaning inside"…"only before",\,$and a backtick; the segmenter and the tokeniser both closed the string at the\"instead. Everything after it — the rest of anode -e "…"program — was then re-read as bare shell, where a JS arrow functionst=>st.xputs a real ASCII>in operator position. The guard then named the JS fragment that followed as a write "target" and blocked a pure-read command.Reproduced in the card's exact shape (harness feeds the hook the PreToolUse payload with
cwd= the shared primary checkout):The second row is the card's
targetshape, and the arrow/thencolumns are identical — the escape is decisive, the arrow is not.This is the failure direction the card is right about: a guard that names a concrete target is far more convincing than one that says "unsure", so the false positive trains agents onto
OS_ALLOW_MAIN_EDITS=1.The fix
Backslash now keeps its meaning inside
"…"before exactly",\,$and a backtick, in bothsplit_segmentsandtokenize. Single quotes still take no escapes — inside'…'a backslash is literal, as in a real shell. The header documents the invariant and the ASCII-only operator contract.Direction check: this narrows the block side only. No shape that was allowed before becomes blocked; what is removed is false confidence, not blocking, exactly as adjudicated.
Self-test — the standing hook rule
Re-ran
.claude/hooks/guard-main-checkout-bash.selftest.shat734ad09a9. Verdict line:Baseline before the change was
70 passed, 0 failed, so all 70 pre-existing cases still hold and 12 probes are new:node -e/echo/grepwhose string literal carries→,⇒,➜(the CLI boot banner glyph), asserted ALLOWED.>/>>still BLOCKED, so the allow side is widened for non-ASCII only and not for redirection at large.\"-carrying pure reads asserted ALLOWED.Ablation
Reverted only the hook to
origin/main, keeping the new probes, then restored it. Both legs confirmed on disk with anchored greps on the text I changed (revert: escape-branch occurrences0; restore: occurrences2, at lines 148 and 179 —split_segmentsandtokenize). No build is involved: the hook is executed directly from source by the selftest, so there is nodist/for a stale artifact to hide in.Predicted direction before running: the
\"allow probes go red; the arrow probes stay green, because the arrow never was the cause. Observed,78 passed, 4 failed:Two things worth flagging, one of them unpredicted:
want=block got=allow. The unfixed guard was not only over-blocking — the premature quote close desynced the parse far enough that a real write went unnoticed.sed -i "s/\"a\"/\"b\"/" pkg/x.tsis precisely the reflexivesed -ithis guard exists to catch, and it was slipping through. The fix closes a false negative as well as the false positive.Gates
Re-derived on the actual diff with
node scripts/pm/dispatch-gates.mjs(no paths passed — it takes its own change set from the merge base), run at734ad09a9with a clean tree. All four match the dispatch list; none added. Their own verdict lines:skip-changeset:.claude/**is internal agent tooling and publishes nothing.What is left open
The card's arrow mechanism is disproven, so the card as written describes a bug that does not exist. What does exist is the harm it reports — a pure-read
node -eblocked with a confidently-wrong target — and that is fixed here, with the arrow behaviour pinned by tests so it can never regress into the card's shape. Whether that closes the card or the card should be amended and closed separately is the maintainer's call, which is why this PR uses no closing keyword.Generated by Claude Code
Generated by Claude Code