From 5295de1cf949aab7613653bac65534d8d7949580 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:47:03 +0000 Subject: [PATCH 1/3] fix(hooks): read a command substitution inside "..." as its own quoting context guard-main-checkout-bash.sh read a `"` inside a `$(...)` (or backtick) that sits inside a double-quoted word as the END of the outer string. The rest of the substitution body was then scanned as bare shell, so a `>` inside a quoted curl JSON body became a redirect and the next fragment a write "target": a read-only command run from the primary checkout was blocked. One helper, subst_end(), now answers where such a substitution ends, reading its body with its own quotes, escapes and nesting. All three quote-aware scanners (the heredoc comment scan, segmentation, tokenisation) delegate to it, so they agree on the extent by construction. The body stays part of the word verbatim, so a target holding it still fails open on its `$`; a write INSIDE a quoted substitution stays unclaimed, as before. Self-test: the card's two forms and the backtick/nested spellings now allow; the plain-redirect control, a write behind a quoted substitution, a sed -i script holding one, and the fail-open mirror (one `"` in the body used to hide a real sed -i behind a separator) all block. Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L Co-authored-by: Claude --- .../guard-main-checkout-bash.selftest.sh | 23 ++++++++ .claude/hooks/guard-main-checkout-bash.sh | 55 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index d5943e76161..ddcbc04a9f7 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 +expect allow 'echo "$(echo "$(echo "x > 1")")"' # nested +# 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" +# 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, and +# the text after it is then read exactly as before this function existed. +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 < Date: Tue, 20 Oct 2026 12:48:46 +0000 Subject: [PATCH 2/3] test(hooks): pin the nested quoted substitution by its recall twin The depth-2 nested allow case passed on the pre-fix hook by quote parity, so it discriminated nothing. Replace it with a nested shape whose real sed -i behind a separator is only seen when the helper reads a `$(` inside a string inside the substitution as one more nesting level. Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L Co-authored-by: Claude --- .claude/hooks/guard-main-checkout-bash.selftest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index ddcbc04a9f7..d12e39124f9 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -312,7 +312,6 @@ CWD="$MAIN" 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 -expect allow 'echo "$(echo "$(echo "x > 1")")"' # nested # 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' @@ -322,6 +321,7 @@ 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 " #")" < Date: Tue, 20 Oct 2026 13:02:44 +0000 Subject: [PATCH 3/3] docs(hooks): state both directions of subst_end's paren boundary Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L Co-authored-by: Claude --- .claude/hooks/guard-main-checkout-bash.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index f6017a77c8c..e4b127a7ac5 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -133,8 +133,9 @@ fi # 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, and -# the text after it is then read exactly as before this function existed. +# 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