Skip to content

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
mainfrom
claude/issue-10247-hook-unicode-arrow
Draft

fix(hooks): a backslash-escaped quote must not end a double-quoted word in the Bash write guard#10406
qq9340100 wants to merge 1 commit into
mainfrom
claude/issue-10247-hook-unicode-arrow

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Part of #10247

Deliberately Part of, not a closing keyword — the card's stated mechanism is disproven below, so whether the card can close is the maintainer's call, not this PR's. See "What is left open".

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:

codepoint UTF-8 bytes
U+2192 e2 86 92
U+21D2 e2 87 92
U+279C e2 9e 9c

ASCII > is 0x3e and < is 0x3c. 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 bash case "$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 at origin/main (the file is byte-identical to the card's 19f98fa1git log 19f98fa1..HEAD on it is empty) under all three locales present in the container (C.utf8, C, POSIX): arrow and then both ALLOW, in every locale. The arrow is not decisive.

The card's own evidence already says so. Its reported target is:

target:  st.action||st.type).join( → ));
         console.log(  teardown:,

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 a node -e "…" program — was then re-read as bare shell, where a JS arrow function st=>st.x puts 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):

balanced outer dquote            arrow=ALLOW                          then=ALLOW
escaped \" inside outer dquote   arrow=BLOCK[st.a||st.t).join( → ));] then=BLOCK[st.a||st.t).join( then ));]

The second row is the card's target shape, and the arrow/then columns 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 both split_segments and tokenize. 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.sh at 734ad09a9. Verdict line:

82 passed, 0 failed

Baseline before the change was 70 passed, 0 failed, so all 70 pre-existing cases still hold and 12 probes are new:

  • Adjudicated arrow probes (4)node -e/echo/grep whose string literal carries , , (the CLI boot banner glyph), asserted ALLOWED.
  • Negative twins (3) — an otherwise-similar command with a real ASCII > / >> still BLOCKED, so the allow side is widened for non-ASCII only and not for redirection at large.
  • Real-cause probes (3)\"-carrying pure reads asserted ALLOWED.
  • Real-cause negative twins (2) — a genuine write is still caught when an escaped quote precedes it.

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 occurrences 0; restore: occurrences 2, at lines 148 and 179 — split_segments and tokenize). No build is involved: the hook is executed directly from source by the selftest, so there is no dist/ 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:

ok   allow  node -e "console.log(steps.map(st=>st.a).join(' → '))"
ok   allow  echo 'build → test → ship'
ok   allow  grep -n '➜  API:' boot.log
ok   allow  echo 'a ⇒ b'
FAIL want=allow got=block  node -e "const j=require(\"./a.json\"); …"
FAIL want=allow got=block  node -e "console.log(\"a\", x.map(s=>s.t))"
FAIL want=block got=allow  node -e "console.log(\"hi\")" > pkg/out.json
FAIL want=block got=allow  sed -i "s/\"a\"/\"b\"/" pkg/x.ts

Two things worth flagging, one of them unpredicted:

  1. The arrow probes stay green under ablation. They lock an invariant that already held; they are not a regression repro. That is the honest reading, and it is why they are paired with negative twins — on their own they could never fail.
  2. Unpredicted third direction: the last two lines flipped the other way, 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.ts is precisely the reflexive sed -i this 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 at 734ad09a9 with a clean tree. All four match the dispatch list; none added. Their own verdict lines:

✓ check-governed-merges --self-test: 81 assertions …
✓ check-doc-authoring self-test: scope wiring … all hold.
✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files
✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 407 files / 1416 TS blocks judged clean

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 -e blocked 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

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants