Skip to content

Commit 5c3faa7

Browse files
Jack Qclaude
andauthored
fix(hooks): a backslash-escaped quote must not end a double-quoted word in the Bash write guard (#10406)
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 Claude-Session: https://claude.ai/code/session_019T1sSZbQTnLhrK9HhNdNiB Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdc7eff commit 5c3faa7

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

.claude/hooks/guard-main-checkout-bash.selftest.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,33 @@ expect allow "$(printf "cat > /tmp/notes.md <<'EOF'\nsed -i 's/a/b/' %s/pkg/x.ts
143143
expect block "$(printf 'cat > %s/notes.md <<EOF\nhello\nEOF\n' "$MAIN")"
144144
expect allow 'grep -q worktree <<<"$AGENTS"'
145145

146+
echo "== non-ASCII codepoints are never operators; ASCII redirects still are (#10247) =="
147+
CWD="$MAIN"
148+
# A pure-read `node -e` whose string literal carries U+2192. Nothing is redirected: the
149+
# arrow is a display separator. Every byte of a non-ASCII codepoint is >= 0x80 and `>` is
150+
# 0x3e, so no arrow can ever reach operator position.
151+
expect allow "node -e \"console.log(steps.map(st=>st.a).join(' $(printf '\xe2\x86\x92') '))\""
152+
expect allow "echo 'build $(printf '\xe2\x86\x92') test $(printf '\xe2\x86\x92') ship'"
153+
expect allow "grep -n '$(printf '\xe2\x9e\x9c') API:' boot.log" # the CLI banner glyph
154+
expect allow "echo 'a $(printf '\xe2\x87\x92') b'"
155+
# The negative twins: an otherwise-similar command with a REAL ASCII redirect still blocks,
156+
# so the allow side above is widened for non-ASCII only and not for redirection at large.
157+
expect block "node -e \"console.log(1)\" > out.log"
158+
expect block "echo 'build $(printf '\xe2\x86\x92') ship' > steps.txt"
159+
expect block "echo 'a $(printf '\xe2\x86\x92') b' >> pkg/x.ts"
160+
161+
echo "== \\\" inside a double-quoted word does not end the quote (#10247 real cause) =="
162+
CWD="$MAIN"
163+
# The shape from the card: an escaped \" used to close the string, after which the JS arrow
164+
# function `st=>` put a real `>` in operator position and the guard named the JS tail that
165+
# followed it as a write target. Pure read — must be allowed.
166+
expect allow 'node -e "const j=require(\"./a.json\"); console.log(j.x.map(st=>st.a).join(\" - \"))"'
167+
expect allow 'node -e "console.log(\"a\", x.map(s=>s.t))"'
168+
expect allow 'grep -rn "he said \"sed -i\" once" .claude/'
169+
# Negative twins: a real write is still caught even when an escaped quote precedes it.
170+
expect block 'node -e "console.log(\"hi\")" > pkg/out.json'
171+
expect block 'sed -i "s/\"a\"/\"b\"/" pkg/x.ts'
172+
146173
echo "== shapes this guard deliberately does NOT claim (documented fail-open) =="
147174
CWD="$MAIN"
148175
expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\""

.claude/hooks/guard-main-checkout-bash.sh

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,27 @@
4848
# for anyone who means to write there, and the target of this guard is the reflexive
4949
# `sed -i` an agent reaches for mid-task, not a determined evader.
5050
#
51-
# Writing ABOUT the ban must never trip the ban (#4890's lesson). Two layers:
51+
# Writing ABOUT the ban must never trip the ban (#4890's lesson). Three layers:
5252
# 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is
5353
# literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass;
5454
# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF`
5555
# body are documentation, not commands, and segmentation alone (which splits on
56-
# newlines) would happily read them as such.
56+
# newlines) would happily read them as such;
57+
# 3. a backslash-escaped `\"` inside a double-quoted word does NOT end the quote — POSIX
58+
# says `\` keeps its meaning before `"`, `\`, `$` and a backtick there, and nowhere else.
59+
# Layer 1 used to end the string at the `\"`, and everything after it — the rest of a
60+
# `node -e "…"` program — was then read as bare shell. A JS arrow function `st=>st.x`
61+
# in that tail put a REAL ASCII `>` in operator position, so the guard named the
62+
# following JS fragment as a write "target" and blocked a pure-read command (#10247).
63+
# Single quotes take no escapes: inside '…' a backslash is literal, as in a real shell.
64+
#
65+
# Redirection is recognised on ASCII operators ONLY — `>` `>>` `<` `<<` and their fd-prefixed
66+
# forms. No non-ASCII codepoint is ever an operator or an operator boundary, and this is
67+
# structural rather than a list to maintain: every byte of a non-ASCII UTF-8 codepoint is
68+
# >= 0x80, while `>` is 0x3e and `<` is 0x3c, so `→` (e2 86 92), `⇒` (e2 87 92) and `➜`
69+
# (e2 9e 9c) cannot collide with an operator byte-wise or character-wise. That matters
70+
# because `➜` is in this repo's own CLI boot banner (`➜ API:`, `➜ Console:`), so echoing
71+
# or grepping a banner must stay allowed. The self-test pins both directions.
5772
#
5873
# Exit-code contract, mirroring guard-main-checkout.sh: 0 = allow, 2 = block with the reason
5974
# on stderr.
@@ -130,6 +145,12 @@ split_segments() {
130145
for ((i = 0; i < n; i++)); do
131146
ch="${s:i:1}"
132147
if [ -n "$q" ]; then
148+
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then
149+
case "${s:i+1:1}" in
150+
'"' | '\' | '$' | '`')
151+
seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
152+
esac
153+
fi
133154
seg+="$ch"
134155
[ "$ch" = "$q" ] && q=""
135156
continue
@@ -155,6 +176,12 @@ tokenize() {
155176
for ((i = 0; i < n; i++)); do
156177
ch="${s:i:1}"
157178
if [ -n "$q" ]; then
179+
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then
180+
case "${s:i+1:1}" in
181+
'"' | '\' | '$' | '`')
182+
i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;;
183+
esac
184+
fi
158185
if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi
159186
have=1
160187
continue

0 commit comments

Comments
 (0)