diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index d5943e76161..d12e39124f9 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -304,12 +304,35 @@ expect allow "$(printf "grep '#' README.md < /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\nsed -i s/a/b/ pkg/x.ts\n")" expect allow "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\ngit status\n")" +echo "== a \$(…) inside \"…\" is its own quoting context (#24812) ==" +CWD="$MAIN" +# The measured false blocks: a `"` inside the substitution was read as CLOSING the outer string, +# so the `>` in a quoted JSON body became a redirect and the fragment after it a write target. +# Pure reads — nothing is written. +expect allow 'echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)"' +expect allow "echo \"a \$(curl -d '{\"c\":\"x > 1\"}' http://h)\"" +expect allow 'echo "a `curl -d "{\"c\":\"x > 1\"}" http://h`"' # the backtick spelling +# Recall is untouched: a real write beside a quoted substitution still blocks — in the same +# command, behind a separator, and inside a sed -i script — and so does the card's control. +expect block 'echo hi > out.txt' +expect block 'echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)" > pkg/x.ts' +expect block 'X="$(git rev-parse HEAD)" ; sed -i s/a/b/ pkg/x.ts' +expect block 'sed -i "s/a/$(date +%F)/" pkg/x.ts' +# The fail-OPEN mirror: one `"` in the body used to leave the outer string hanging, so the +# separator behind it went inert and the real write rode through as text. +expect block "echo \"\$(printf '\"')\" ; sed -i s/a/b/ pkg/x.ts" +expect block "echo \"\$(echo \"\$(printf '\"')\")\" ; sed -i s/a/b/ pkg/x.ts" # nested +# The heredoc scan reads the body the same way: the ` #` inside it is no comment, so the +# introducer behind it is found and the body stays prose. +expect allow "$(printf 'echo "$(echo " #")" < (the target is not a shell token, so there is nothing to read); # - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable @@ -51,6 +52,8 @@ # Writing ABOUT the ban must never trip the ban (#4890's lesson). Four layers: # 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is # literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass; +# a `$(…)` or backtick inside "…" is a quoting context of its own, so a `"` in its body +# nests instead of closing the outer string (subst_end(), shared by every pass); # 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <` in a quoted curl body was taken +# for a redirect whose "target" was the next fragment — a false BLOCK (#24812). All three +# quote-aware scanners hand such a body to this ONE function, so they agree on where it ends. +# +# subst_end S I: S[I] is the `$` of a `$(`, or a backtick, met inside "…". Sets SUBST_END to +# the index of the character that closes the substitution, reading the body with its own +# quotes, escapes and nested `(…)` / `$(…)` / backticks; when the input ends first, to the +# last index — the rest is one inert run (bash refuses such a command anyway). The body is +# kept verbatim as part of the word, so a target holding it still fails open on its `$`. +# Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment) moves the end — +# too early, and the rest is read as it was before this function existed; too late, and the +# text swallowed with the body is inert, i.e. fails open. +SUBST_END=0 +subst_end() { + local s="$1" i="$2" n=${#1} st ch top + if [ "${s:i:1}" = '`' ]; then st='`'; else st=')' ; i=$((i + 1)); fi + for ((i = i + 1; i < n; i++)); do + ch="${s:i:1}" ; top="${st:${#st}-1:1}" + if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi + if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi + if [ "$top" = '"' ]; then + case "$ch" in + '"') st="${st%?}" ;; + '`') st+='`' ;; + '$') if [ "${s:i+1:1}" = '(' ]; then st+=')' ; i=$((i + 1)); fi ;; + esac + continue + fi + case "$ch" in + "'" | '"') st+="$ch" ;; + '(') st+=')' ;; + ')') [ "$top" = ')' ] && st="${st%?}" ;; + '`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;; + esac + if [ -z "$st" ]; then SUBST_END=$i; return; fi + done + SUBST_END=$((n - 1)) +} + # --- heredoc bodies are text, not commands --------------------------------------------- # `cat > /tmp/notes.md <