Skip to content

Commit 764dbbc

Browse files
claude[bot]claude
andauthored
fix(hooks): make guard-main-checkout-bash agree with itself on backslashes and comment-named heredocs (#11278)
* fix(hooks): give split_segments() the backslash branch tokenize() already had (#11131) Outside quotes a backslash escapes the next character, so an escaped `\"` opens no quoted region. tokenize() modelled that; split_segments() did not. The disagreement was fail-OPEN: the `"` behind the backslash opened a quote that never closed, every separator after it went inert, the command collapsed into a single `echo` segment, and a real `sed -i` into the shared checkout rode through as just another argument. Measured probe, cwd inside the shared primary checkout: echo \" ; sed -i s/a/b/ pkg/x.ts before: hook-exit=0 ALLOWED after: hook-exit=2 sed -i s/a/b/ pkg/x.ts (control) before: hook-exit=2 after: hook-exit=2 The mirror case #10247 (an escaped quote INSIDE a double-quoted word, a false BLOCK) stays allowed — its twins are in the matrix next door and are unchanged. Selftest: 100 -> 110 passed, 0 failed. Both directions pinned, including the precision twins where the escape must NOT manufacture a target, and the same command aimed at a linked worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ * fix(hooks): a heredoc introducer named inside a comment introduces nothing (#11133) strip_heredocs() ran on raw text and modelled neither quotes nor comments, so a `<<EOF` occurring inside prose registered as a real introducer. The delimiter then never appeared on a line of its own, the pending heredoc was never satisfied, and every remaining line — including real commands — was dropped before either quote-aware pass could see it. Fail-OPEN, and total for the rest of the command. Measured probe, cwd inside the shared primary checkout: "# use cat > /tmp/n <<EOF for notes" + sed -i s/a/b/ pkg/x.ts before: hook-exit=0 ALLOWED after: hook-exit=2 "# plain prose comment" + same write (control) before: hook-exit=2 after: hook-exit=2 Shape: the card's candidate 1 (narrow, keeps the existing pass order), not candidate 2 (one merged scanner). Only the DELIMITER SCAN consults the comment rule; the line itself is passed through untouched, so the landed #10570 comment rule in the two quote-aware passes stays the single implementation of it. The card's ordering hazard — an unbalanced apostrophe in a heredoc BODY desynchronising a quote-aware comment scanner — is structurally out of reach here: body lines are consumed by the `pending` branch and never reach the scan. Pinned as its own case. Selftest: 110 -> 121 passed, 0 failed. Both directions pinned, including a QUOTED `#` on an introducing line (truncating there would expose the body as commands) and an inline comment after a real introducer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ * docs(hooks): the header enumerates four layers, not three (#11234) PR #10406 correctly moved the count "Two" -> "Three" when it added layer 3. PR #11129 (#10570) added layer 4 and left the count alone. This header is the enumerated contract a reader checks a change against — and it is the thing that told objectui's dev the two copies must not drift — so a miscount is worth the one word. No logic, no self-test change: 121 passed, 0 failed, unchanged. The objectui copy carries the miscount verbatim and deliberately (objectui#5459); it ports downstream from here like any other drift item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e353c9b commit 764dbbc

2 files changed

Lines changed: 112 additions & 2 deletions

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,30 @@ expect allow 'grep -rn "he said \"sed -i\" once" .claude/'
170170
expect block 'node -e "console.log(\"hi\")" > pkg/out.json'
171171
expect block 'sed -i "s/\"a\"/\"b\"/" pkg/x.ts'
172172

173+
echo "== an UNQUOTED \\\" opens no quote, so the write behind it is still seen (#11131) =="
174+
CWD="$MAIN"
175+
# The measured hole: segmentation read the escaped `"` as OPENING a quoted region that never
176+
# closed. Every separator behind it went inert, the command collapsed into one `echo`
177+
# segment, and the real in-place write was just another argument. tokenize() always had the
178+
# backslash branch; this is the pass agreement.
179+
expect block 'echo \" ; sed -i s/a/b/ pkg/x.ts'
180+
expect block 'echo \" ; rm -rf pkg/x.ts'
181+
expect block 'printf \" ; tee pkg/a.ts'
182+
expect block "$(printf 'echo \\"\nsed -i s/a/b/ pkg/x.ts\n')"
183+
# Precision twins: the escape must not manufacture a target where nothing is written, and
184+
# the same command aimed at a linked worktree stays allowed.
185+
expect allow 'echo \" ; echo hello'
186+
expect allow 'echo \" ; cat README.md'
187+
expect allow 'echo a\ b'
188+
expect allow 'echo \\ ; grep -n worktree README.md'
189+
# NOT a discriminating case for this fix, kept as a plain regression pin: a `>` target is
190+
# collected wherever it appears, so this blocked even while the passes disagreed. What the
191+
# disagreement lost was the COMMAND-NAME writers (sed -i / rm / tee) — once the command
192+
# collapsed into one segment the head word became `echo` and they were mere arguments.
193+
expect block 'echo \" && echo x > pkg/x.ts'
194+
CWD="$WT"
195+
expect allow 'echo \" ; sed -i s/a/b/ pkg/x.ts'
196+
173197
echo "== a shell COMMENT is text, not a command (#10570) =="
174198
CWD="$MAIN"
175199
# The measured false blocks: prose in a comment put a real `>` in operator position and the
@@ -199,6 +223,32 @@ expect block 'echo ${x#a} > pkg/x.ts'
199223
# an escaped `\#` outside quotes is a literal, not a comment opener
200224
expect allow 'echo \# not a comment'
201225

226+
echo "== a heredoc introducer NAMED in a comment introduces nothing (#11133) =="
227+
CWD="$MAIN"
228+
# The measured hole: `<<EOF` inside prose registered as a real introducer. `EOF` never
229+
# appeared on a line of its own, so the pending heredoc was never satisfied and every
230+
# remaining line — including the real write — was dropped before analysis.
231+
expect block "$(printf '# use cat > /tmp/n <<EOF for notes\nsed -i s/a/b/ pkg/x.ts\n')"
232+
expect block "$(printf '# see the <<EOF trick\necho x >> pkg/x.ts\n')"
233+
expect block "$(printf 'git status # cat <<MARKER writes notes\nrm -rf pkg/x.ts\n')"
234+
expect block "$(printf '# heredocs: <<-EOF and <<"Q" both introduce\ntouch pkg/new.ts\n')"
235+
# Real heredocs are untouched: a body is still prose, and the introducing line's own
236+
# redirect is still a write.
237+
expect allow "$(printf "cat > /tmp/notes.md <<'EOF'\nsed -i 's/a/b/' %s/pkg/x.ts\nEOF\n" "$MAIN")"
238+
expect block "$(printf 'cat > %s/notes.md <<EOF\nhello\nEOF\n' "$MAIN")"
239+
# An inline comment AFTER a real introducer must not cancel it — the body is still stripped,
240+
# and the negative twin shows a real write after the terminator is still caught.
241+
expect allow "$(printf 'cat > /tmp/n.md <<EOF # notes\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
242+
expect block "$(printf 'cat > /tmp/n.md <<EOF # notes\nhello\nEOF\nsed -i s/a/b/ pkg/x.ts\n')"
243+
# A QUOTED `#` on the introducing line is not a comment: truncating there would lose the
244+
# real introducer and expose the body as commands. This is the load-bearing precision case.
245+
expect allow "$(printf "grep '#' README.md <<EOF\nsed -i 's/a/b/' pkg/x.ts\nEOF\n")"
246+
# Ordering pin: an unbalanced apostrophe inside a heredoc BODY must not desynchronise the
247+
# comment rule for the lines that follow. Body lines never reach the comment scan, so the
248+
# real write after the terminator is still analysed.
249+
expect block "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\nsed -i s/a/b/ pkg/x.ts\n")"
250+
expect allow "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\ngit status\n")"
251+
202252
echo "== shapes this guard deliberately does NOT claim (documented fail-open) =="
203253
CWD="$MAIN"
204254
expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\""

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
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). Three layers:
51+
# Writing ABOUT the ban must never trip the ban (#4890's lesson). Four 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`
@@ -61,6 +61,11 @@
6161
# in that tail put a REAL ASCII `>` in operator position, so the guard named the
6262
# following JS fragment as a write "target" and blocked a pure-read command (#10247).
6363
# Single quotes take no escapes: inside '…' a backslash is literal, as in a real shell.
64+
# OUTSIDE quotes the same escape holds and both passes must agree that it does: a `\"`
65+
# there opens no quoted region at all. Only tokenise() knew that, so segmentation read
66+
# the `"` as opening a region that never closed, went inert for every separator behind
67+
# it, and let a real `sed -i` through as a mere argument — the fail-OPEN mirror of the
68+
# false block above (#11131). The two passes now share the rule in both directions.
6469
# 4. shell COMMENTS are text, not commands — both quote-aware passes stop at an unquoted
6570
# `#` that starts a WORD and resume at the next newline (#10570). A comment cannot
6671
# write anything, so reading one as a command is a false BLOCK: prose arrows (`->`,
@@ -118,6 +123,47 @@ fi
118123
# on newlines, so without this pass a documented example inside the body ("sed -i … main
119124
# checkout") would be analysed as if the agent had run it. Drop body lines (and their
120125
# terminator); the introducing line — which is where the real redirection lives — stays.
126+
#
127+
# A `<<WORD` that is merely NAMED inside a COMMENT introduces nothing — bash removes the
128+
# comment before it ever looks for a heredoc. Reading one as a real introducer was fail-OPEN
129+
# and badly so: the delimiter never appeared on a line of its own, so the pending heredoc
130+
# was never satisfied and EVERY remaining line — including real commands — was dropped
131+
# before either quote-aware pass could see it (#11133).
132+
#
133+
# Only the DELIMITER SCAN consults the comment rule; the line itself is passed through
134+
# untouched, because both quote-aware passes already own that rule (#10570) and a second
135+
# implementation of it here is exactly how the two would drift.
136+
#
137+
# This is why the fix is a narrow scan-side truncation rather than a comment-stripping pass
138+
# run BEFORE this one: a heredoc BODY may contain an unbalanced apostrophe, and a quote-aware
139+
# comment scanner run over the raw text would desynchronise on it for every following line.
140+
# Body lines never reach this scan — they are consumed by the `pending` branch below and
141+
# `continue` before it — so that hazard is structurally out of reach here.
142+
#
143+
# Word-start rule, identical to split_segments()/tokenize(): a `#` opens a comment only where
144+
# a WORD could start — at line start, after a blank, or after one of `; | & ( ) > <`.
145+
strip_line_comment() {
146+
local s="$1" n=${#1} i ch q="" word=0
147+
for ((i = 0; i < n; i++)); do
148+
ch="${s:i:1}"
149+
if [ -n "$q" ]; then
150+
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi
151+
[ "$ch" = "$q" ] && q=""
152+
continue
153+
fi
154+
case "$ch" in
155+
'#')
156+
if [ "$word" = 0 ]; then printf '%s' "${s:0:i}" ; return ; fi
157+
;; # foo#bar, ${x#y}, url/#frag
158+
'\') i=$((i + 1)) ; word=1 ;;
159+
"'" | '"') q="$ch" ; word=1 ;;
160+
';' | '|' | '&' | '(' | ')' | ' ' | $'\t' | '>' | '<') word=0 ;;
161+
*) word=1 ;;
162+
esac
163+
done
164+
printf '%s' "$s"
165+
}
166+
121167
strip_heredocs() {
122168
local s="$1" out="" line scan d t
123169
local -a pending=()
@@ -130,8 +176,10 @@ strip_heredocs() {
130176
continue
131177
fi
132178
out+="$line"$'\n'
179+
# A `<<WORD` behind an unquoted word-start `#` is prose, not an introducer (#11133).
180+
scan="$(strip_line_comment "$line")"
133181
# `<<<` is a herestring, not a heredoc — mask it before hunting for delimiters.
134-
scan="${line//<<</__OS_HERESTRING__}"
182+
scan="${scan//<<</__OS_HERESTRING__}"
135183
case "$scan" in
136184
*'<<'*)
137185
while IFS= read -r d; do
@@ -182,6 +230,18 @@ split_segments() {
182230
fi
183231
seg+="$ch" # foo#bar, ${x#y}, url/#frag
184232
;;
233+
'\')
234+
# Outside quotes a backslash escapes the NEXT character, so an escaped `\"` does NOT
235+
# open a quoted region. tokenize() has always had this branch; this pass did not, and
236+
# the disagreement was a fail-OPEN hole: the `"` after the backslash opened a quote
237+
# here that never closed, every later separator went inert, the whole command
238+
# collapsed into one `echo` segment, and a real `sed -i` behind it was just another
239+
# argument (#11131). Both characters are kept verbatim — this pass only SPLITS, and
240+
# tokenize() re-reads the escape when it strips quoting.
241+
seg+="$ch"
242+
if [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)) ; seg+="${s:i:1}" ; fi
243+
word=1
244+
;;
185245
"'" | '"') q="$ch" ; seg+="$ch" ; word=1 ;;
186246
';' | '|' | '&' | '(' | ')' | $'\n') segments+=("$seg") ; seg="" ; word=0 ;;
187247
'{' | '}') segments+=("$seg") ; seg="" ; word=1 ;;

0 commit comments

Comments
 (0)