From 851a529193afed93f9360250be8380bbd7ca15b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:10:17 +0000 Subject: [PATCH 1/2] fix(hooks): read $(...), backticks and ${...} inside "..." as their own quoting context in every Bash guard guard-main-checkout-bash.sh's subst_end() now also answers where a ${...} met inside a double-quoted word ends, and decides itself whether the character opens such a context. guard-shared-stash.sh, guard-process-kill.sh and guard-tree-enum.sh carry a byte-identical copy and delegate to it from split_segments()'s in-quote branch. guard-tree-enum.sh also gains the in-quote backslash branch its two siblings already had, so the three simple segmenters are identical. Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L Co-authored-by: Claude --- .claude/hooks/guard-main-checkout-bash.sh | 91 ++++++++++++++++------- .claude/hooks/guard-process-kill.sh | 60 ++++++++++++++- .claude/hooks/guard-shared-stash.sh | 60 +++++++++++++++ .claude/hooks/guard-tree-enum.sh | 75 ++++++++++++++++++- 4 files changed, 252 insertions(+), 34 deletions(-) diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index e4b127a7ac5..c28f48b5e61 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -44,6 +44,9 @@ # node -e, awk > (the target is not a shell token, so there is nothing to read); # - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable # without running it; +# - everything after a `'` inside a QUOTED "${…}" unless the quoted run holds none of +# `"` `\` `$` `}` or a backtick: bash and POSIX shells disagree on where such an expansion +# ends (subst_end()); # - relative targets when the payload carries no cwd. # Each of those is a hole by choice, not by accident: OS_ALLOW_MAIN_EDITS=1 already exists # for anyone who means to write there, and the target of this guard is the reflexive @@ -52,8 +55,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); +# a `$(…)`, a backtick or a `${…}` 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. +# --- an expansion inside "…" is its own quoting context -------------------------------- +# Inside "…", `$(`, a backtick or `${` opens a NEW quoting context: a `"` in the body starts a +# string of its own and does not close the outer one. Each scanner below used to read that `"` +# as the closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was +# taken for a redirect whose "target" was the next fragment — a false BLOCK (#24812); the same +# reading of `echo "${x:-"a > b"}"` named `b}` as a target (#24901). All three quote-aware +# scanners hand such a body to this ONE function, so they agree on where it ends. +# guard-shared-stash.sh, guard-process-kill.sh and guard-tree-enum.sh segment commands the same +# way and carry a byte-identical copy; their self-tests fail when a copy differs from this one. # -# 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 S I: when S[I] opens such a context (the `$` of a `$(` or a `${`, or a backtick), +# sets SUBST_END to the index of the character that closes it and returns 0; otherwise returns 1 +# and changes nothing. The body is read with its own quotes, escapes and nesting; when the input +# ends first, SUBST_END is 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 `$`. +# +# A `${…}` body reads like "…" with one more closer: `"`, `$(`, `${` and a backtick nest in it, +# a backslash escapes, and `}` closes it. A `'` there is where shells part ways, measured: bash +# 5.2 reads `'…'` as a quoted run while it looks for the `}` (`"${x:-'}'}"` prints `'}'`), and +# `bash --posix` and dash read the `'` as a literal. The two readings agree only when the run +# holds none of `"` `\` `$` `}` or a backtick, so such a run is skipped, and any other `'` makes +# the rest one inert run: an extent that depends on the shell is not one this guard can read. +# +# Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment, an unquoted `${…}` +# holding one) 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 + local s="$1" i="$2" n=${#1} st ch top run + case "${s:i:2}" in + '$(') st=')' ; i=$((i + 1)) ;; + '${') st='}' ; i=$((i + 1)) ;; + '`'*) st='`' ;; + *) return 1 ;; + esac 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 + if [ "$top" = '"' ] || [ "$top" = '}' ]; then case "$ch" in - '"') st="${st%?}" ;; + '"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;; '`') st+='`' ;; - '$') if [ "${s:i+1:1}" = '(' ]; then st+=')' ; i=$((i + 1)); fi ;; + '$') + case "${s:i+1:1}" in + '(') st+=')' ; i=$((i + 1)) ;; + '{') st+='}' ; i=$((i + 1)) ;; + esac ;; + '}') [ "$top" = '}' ] && st="${st%?}" ;; + "'") + if [ "$top" = '}' ]; then + run="${s:i+1}" + case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac + run="${run%%"'"*}" + case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac + i=$((i + ${#run} + 1)) + fi ;; esac + if [ -z "$st" ]; then SUBST_END=$i; return 0; fi continue fi case "$ch" in @@ -158,9 +192,10 @@ subst_end() { ')') [ "$top" = ')' ] && st="${st%?}" ;; '`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;; esac - if [ -z "$st" ]; then SUBST_END=$i; return; fi + if [ -z "$st" ]; then SUBST_END=$i; return 0; fi done SUBST_END=$((n - 1)) + return 0 } # --- heredoc bodies are text, not commands --------------------------------------------- @@ -193,8 +228,8 @@ strip_line_comment() { ch="${s:i:1}" if [ -n "$q" ]; then if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi - if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then - subst_end "$s" "$i" ; i=$SUBST_END ; continue + if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then + i=$SUBST_END ; continue fi [ "$ch" = "$q" ] && q="" continue @@ -265,8 +300,8 @@ split_segments() { seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;; esac fi - if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then - subst_end "$s" "$i" ; seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue + if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then + seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue fi seg+="$ch" [ "$ch" = "$q" ] && q="" @@ -325,8 +360,8 @@ tokenize() { i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;; esac fi - if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then - subst_end "$s" "$i" ; tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue + if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then + tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue fi if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi have=1 diff --git a/.claude/hooks/guard-process-kill.sh b/.claude/hooks/guard-process-kill.sh index 561a0e3d880..7cc61a2882a 100755 --- a/.claude/hooks/guard-process-kill.sh +++ b/.claude/hooks/guard-process-kill.sh @@ -142,10 +142,63 @@ fi # (#11738 on the stash guard, #11131 on guard-main-checkout-bash.sh); # - INSIDE "…" the rule inverts: `\"` is a literal quote that leaves the region OPEN, and # reading it as closing splits where bash would not, turning the tail of a pure READ -# into a segment judged on its own head word — a false BLOCK (#11804 / #10406). +# into a segment judged on its own head word — a false BLOCK (#11804 / #10406); +# - INSIDE "…" a `$(`, a backtick or a `${` opens a quoting context of its own, so a `"` in +# its body nests instead of closing the outer string. Reading it as the closer split the +# body: `echo "a $(echo "x; pkill -f node")"` and `echo "${x:-"a; killall node"}"` were +# BLOCKED, measured on this repo's copy before the fix (#24901). # # Both characters are kept verbatim: this pass only SPLITS, and the classifiers below -# re-read the words afterwards. +# re-read the words afterwards. The self-test fails when this function differs from +# guard-shared-stash.sh's, and when subst_end() differs from guard-main-checkout-bash.sh's, +# whose comment above the function is its documentation. +SUBST_END=0 +subst_end() { + local s="$1" i="$2" n=${#1} st ch top run + case "${s:i:2}" in + '$(') st=')' ; i=$((i + 1)) ;; + '${') st='}' ; i=$((i + 1)) ;; + '`'*) st='`' ;; + *) return 1 ;; + esac + 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" = '"' ] || [ "$top" = '}' ]; then + case "$ch" in + '"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;; + '`') st+='`' ;; + '$') + case "${s:i+1:1}" in + '(') st+=')' ; i=$((i + 1)) ;; + '{') st+='}' ; i=$((i + 1)) ;; + esac ;; + '}') [ "$top" = '}' ] && st="${st%?}" ;; + "'") + if [ "$top" = '}' ]; then + run="${s:i+1}" + case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac + run="${run%%"'"*}" + case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac + i=$((i + ${#run} + 1)) + fi ;; + esac + if [ -z "$st" ]; then SUBST_END=$i; return 0; fi + 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 0; fi + done + SUBST_END=$((n - 1)) + return 0 +} + segments=() split_segments() { local s="$1" seg="" q="" ch i n=${#1} @@ -158,6 +211,9 @@ split_segments() { seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;; esac fi + if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then + seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue + fi seg+="$ch" [ "$ch" = "$q" ] && q="" continue diff --git a/.claude/hooks/guard-shared-stash.sh b/.claude/hooks/guard-shared-stash.sh index 4eb3233cfad..1ae762328ca 100755 --- a/.claude/hooks/guard-shared-stash.sh +++ b/.claude/hooks/guard-shared-stash.sh @@ -114,6 +114,63 @@ fi # that guard's `word` bookkeeping has no analogue here because this pass has no comment rule # to track word starts for. Both characters are kept verbatim — this pass only SPLITS, and # check_segment() re-reads the words afterwards. +# +# INSIDE "…" a `$(`, a backtick or a `${` opens a quoting context of its own, so a `"` in its +# body nests instead of closing the outer string. Without that rule this pass read the inner +# `"` as the closer, the `;` inside the body split, and a pure read became a stash command — +# measured on this repo's copy before the fix: `echo "a $(echo "x; git stash pop")"` and +# `echo "${x:-"a; git stash pop"}"` were BLOCKED (#24901). The body is kept verbatim inside +# the segment, so a stash inside a QUOTED substitution stays the fail-open it always was. +# +# subst_end() is copied byte-for-byte from guard-main-checkout-bash.sh, whose comment above +# the function is its documentation; this hook's self-test fails when the copy differs. +SUBST_END=0 +subst_end() { + local s="$1" i="$2" n=${#1} st ch top run + case "${s:i:2}" in + '$(') st=')' ; i=$((i + 1)) ;; + '${') st='}' ; i=$((i + 1)) ;; + '`'*) st='`' ;; + *) return 1 ;; + esac + 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" = '"' ] || [ "$top" = '}' ]; then + case "$ch" in + '"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;; + '`') st+='`' ;; + '$') + case "${s:i+1:1}" in + '(') st+=')' ; i=$((i + 1)) ;; + '{') st+='}' ; i=$((i + 1)) ;; + esac ;; + '}') [ "$top" = '}' ] && st="${st%?}" ;; + "'") + if [ "$top" = '}' ]; then + run="${s:i+1}" + case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac + run="${run%%"'"*}" + case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac + i=$((i + ${#run} + 1)) + fi ;; + esac + if [ -z "$st" ]; then SUBST_END=$i; return 0; fi + 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 0; fi + done + SUBST_END=$((n - 1)) + return 0 +} + segments=() split_segments() { local s="$1" seg="" q="" ch i n=${#1} @@ -126,6 +183,9 @@ split_segments() { seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;; esac fi + if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then + seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue + fi seg+="$ch" [ "$ch" = "$q" ] && q="" continue diff --git a/.claude/hooks/guard-tree-enum.sh b/.claude/hooks/guard-tree-enum.sh index b8f2029be67..b900e419d7f 100755 --- a/.claude/hooks/guard-tree-enum.sh +++ b/.claude/hooks/guard-tree-enum.sh @@ -96,20 +96,87 @@ fi [ -n "$cmd" ] || exit 0 # --- split the command into shell segments, honouring quotes --------------------------- -# Kept case-for-case identical to guard-shared-stash.sh's pass, including the #11738 -# repair: OUTSIDE quotes a backslash escapes the NEXT character, so an escaped `\"` opens -# no quoted region. Without that branch a stray `\"` makes every separator behind it inert, -# the command collapses into one segment, and the real work rides through as an argument. +# Kept case-for-case identical to guard-shared-stash.sh's pass, and the self-test fails when +# it is not. That covers the #11738 repair: OUTSIDE quotes a backslash escapes the NEXT +# character, so an escaped `\"` opens no quoted region. Without that branch a stray `\"` +# makes every separator behind it inert, the command collapses into one segment, and the +# real work rides through as an argument. +# +# INSIDE "…" two more rules hold, and this copy carried neither until #24901, while the line +# above already called it identical. A `\"` there is a literal quote that leaves the region +# OPEN (#11804); and a `$(`, a backtick or a `${` opens a quoting context of its own, so a +# `"` in its body nests instead of closing the outer string. Reading either `"` as the +# closer split a pure read: measured on this repo's copy before the fix, +# `echo "say \"; ls *.yml; git show origin/main:x\" ok"` and +# `echo "a $(echo "x; ls *.yml")" && git show origin/main:README.md` were both BLOCKED. +# subst_end() is copied byte-for-byte from guard-main-checkout-bash.sh, whose comment above +# the function is its documentation; the self-test pins that copy too. # # `(` and `)` are separators, which is what makes command substitution fall out correctly # here: `for f in $(git ls-tree --name-only origin/main dir)` becomes `for f in $` — no # glob, so not an enumeration — plus a `git ls-tree` segment, which is the suppressor. +SUBST_END=0 +subst_end() { + local s="$1" i="$2" n=${#1} st ch top run + case "${s:i:2}" in + '$(') st=')' ; i=$((i + 1)) ;; + '${') st='}' ; i=$((i + 1)) ;; + '`'*) st='`' ;; + *) return 1 ;; + esac + 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" = '"' ] || [ "$top" = '}' ]; then + case "$ch" in + '"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;; + '`') st+='`' ;; + '$') + case "${s:i+1:1}" in + '(') st+=')' ; i=$((i + 1)) ;; + '{') st+='}' ; i=$((i + 1)) ;; + esac ;; + '}') [ "$top" = '}' ] && st="${st%?}" ;; + "'") + if [ "$top" = '}' ]; then + run="${s:i+1}" + case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac + run="${run%%"'"*}" + case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac + i=$((i + ${#run} + 1)) + fi ;; + esac + if [ -z "$st" ]; then SUBST_END=$i; return 0; fi + 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 0; fi + done + SUBST_END=$((n - 1)) + return 0 +} + segments=() split_segments() { local s="$1" seg="" q="" ch i n=${#1} for ((i = 0; i < n; i++)); do ch="${s:i:1}" if [ -n "$q" ]; then + if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then + case "${s:i+1:1}" in + '"' | '\' | '$' | '`') + seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;; + esac + fi + if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then + seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue + fi seg+="$ch" [ "$ch" = "$q" ] && q="" continue From d83fca2fb36dd9d179980ae2cd3c5e96ef65bdb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:15:09 +0000 Subject: [PATCH 2/2] test(hooks): pin the nested-quote rule per hook and the shared copies byte for byte Each Bash guard's matrix gains the measured false blocks for $(...), backticks and ${...} inside "...", the recall controls next to them, and the fail-open mirrors the rule closes. The three copying guards also assert that their subst_end() equals guard-main-checkout-bash.sh's and, for guard-process-kill.sh and guard-tree-enum.sh, that their split_segments() equals guard-shared-stash.sh's. Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L Co-authored-by: Claude --- .../guard-main-checkout-bash.selftest.sh | 31 ++++++++++++++++ .claude/hooks/guard-process-kill.selftest.sh | 35 ++++++++++++++++++ .claude/hooks/guard-shared-stash.selftest.sh | 37 +++++++++++++++++++ .claude/hooks/guard-tree-enum.selftest.sh | 37 +++++++++++++++++++ 4 files changed, 140 insertions(+) diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index d12e39124f9..09fae6399fb 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -326,6 +326,30 @@ expect block "echo \"\$(echo \"\$(printf '\"')\")\" ; sed -i s/a/b/ pkg/x.ts" # introducer behind it is found and the body stays prose. expect allow "$(printf 'echo "$(echo " #")" < b"}"' +expect allow 'echo "${x#"a > b"}"' +expect allow 'echo "${x:-"$(printf "a > b")"}"' +expect allow 'echo "${x:-"a; sed -i s/a/b/ pkg/x.ts"}"' +expect allow "echo \"\${x:-'a > b'}\"" # control: no nested `"`, allowed before and after +# The heredoc scan agrees: the ` #` inside the expansion is no comment, so the introducer +# behind it is found and the body stays prose. +expect allow "$(printf 'echo "${x:-" #"}" < pkg/x.ts' +expect block 'echo "${x:-"a"}" ; sed -i s/a/b/ pkg/x.ts' +expect block "echo \"\${x:-'a'}\" ; sed -i s/a/b/ pkg/x.ts" +# A `$(…)` inside the expansion is read by the same function, quotes and all: the `"` inside +# its single quotes closes nothing, so the separator behind the word still splits. +expect block "echo \"\${x:-\$(printf '\"')}\" ; sed -i s/a/b/ pkg/x.ts" + echo "== shapes this guard deliberately does NOT claim (documented fail-open) ==" CWD="$MAIN" expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\"" @@ -333,6 +357,13 @@ expect allow 'echo pkg/x.ts | xargs sed -i s/a/b/' expect allow 'node -e "fs.writeFileSync(\"pkg/x.ts\", \"y\")"' expect allow 'python3 -c "open(\"pkg/x.ts\",\"w\").write(\"y\")"' expect allow 'echo "$(sed -i s/a/b/ pkg/x.ts)"' # a QUOTED $(…) body is one inert word +expect allow 'echo "${x:-"$(sed -i s/a/b/ pkg/x.ts)"}"' # …and so is a QUOTED ${…} body +expect allow 'echo hi > "${x:-"a > b"}"' # a target holding an expansion +# A '…' run inside a quoted ${…} that holds `}` (or `"` `\` `$` and a backtick): bash 5.2 reads +# it as quoted and POSIX shells as literal, so they end the expansion in different places and +# everything after it is inert. Under bash the first is a pure read, under dash a real write. +expect allow "echo \"\${x:-'}\"; sed -i s/a/b/ pkg/x.ts'}\"" +expect allow "echo \"\${x:-'}'}\" ; sed -i s/a/b/ pkg/x.ts" expect allow 'sed -i s/a/b/ $SOMEDIR/x.ts' expect allow 'sed -i s/a/b/ pkg/*.ts' diff --git a/.claude/hooks/guard-process-kill.selftest.sh b/.claude/hooks/guard-process-kill.selftest.sh index 34983a6ce46..decf375cefa 100755 --- a/.claude/hooks/guard-process-kill.selftest.sh +++ b/.claude/hooks/guard-process-kill.selftest.sh @@ -176,6 +176,41 @@ echo "== an escaped \\\" INSIDE a double-quoted word does NOT close it (#10406 h expect allow 'grep -rn "he said \"pkill -f vitest\" once" .claude/' expect block 'echo "he said \"x\"" && pkill -f vitest' +echo "== a \$(…), backtick or \${…} inside \"…\" is its own quoting context (#24901) ==" +# Reading the `"` inside the body as CLOSING the outer string split the body at its `;`, so a +# pure read became a name-matched kill. All four were BLOCKED before subst_end() existed here. +expect allow 'echo "a $(echo "x; pkill -f node")"' +expect allow 'echo "a `echo "x; pkill -f node"`"' +expect allow 'echo "${x:-"a; pkill -f node"}"' +expect allow 'echo "${x:-"; killall node"}"' +# Recall: a kill behind a quoted expansion is still seen, and a pattern held in one is still a +# pattern — the expansion is part of the word, not a way around the rule. +expect block 'echo "${x:-"a"}" ; pkill -f node' +expect block 'kill $(pgrep -f "${pat:-"node"}")' +expect block 'pkill -f "${pat:-"vitest; x"}"' +# The fail-OPEN mirror: the `"` inside the single quotes left the outer string hanging, so the +# separator behind it went inert. ALLOWED before subst_end() existed here. +expect block "echo \"\$(printf '\"')\" ; pkill -f vitest" +# Deliberately not claimed, as before: a kill INSIDE a quoted substitution is one inert word. +expect allow 'echo "$(pkill -f node)"' + +echo "== the shared quoting rules are copies, byte for byte (#24901) ==" +# split_segments() is guard-shared-stash.sh's and subst_end() is guard-main-checkout-bash.sh's. +# A copy that drifts is a quoting rule the guards no longer share, and nothing else notices: +# each hook's own cases keep passing on its own dialect. +fn_text() { sed -n "/^$1() {\$/,/^}\$/p" "$2"; } +same_fn() { # same_fn + local mine theirs + mine="$(fn_text "$1" "$hook")"; theirs="$(fn_text "$1" "$2")" + if [ -n "$mine" ] && [ "${mine%%$'\n'*}" = "$1() {" ] && [ "$mine" = "$theirs" ]; then + pass=$((pass + 1)); printf ' ok same %s() as %s\n' "$1" "${2##*/}" + else + fail=$((fail + 1)); printf ' FAIL %s() is missing here or differs from %s\n' "$1" "${2##*/}" + fi +} +same_fn split_segments "$here/guard-shared-stash.sh" +same_fn subst_end "$here/guard-main-checkout-bash.sh" + echo "== escape hatch ==" expect allow 'pkill -f vitest' OS_ALLOW_PROCESS_KILL=1 expect allow 'ps aux | grep node | xargs kill' OS_ALLOW_PROCESS_KILL=1 diff --git a/.claude/hooks/guard-shared-stash.selftest.sh b/.claude/hooks/guard-shared-stash.selftest.sh index b606d0f86c8..9eb4100be77 100755 --- a/.claude/hooks/guard-shared-stash.selftest.sh +++ b/.claude/hooks/guard-shared-stash.selftest.sh @@ -158,6 +158,43 @@ expect block 'echo "a \\" ; git stash pop' # quotes it would swallow the closing `'` and let the stash behind it through. expect block "echo 'a \\' ; git stash pop" +echo "== a \$(…), backtick or \${…} inside \"…\" is its own quoting context (#24901) ==" +# Reading the `"` inside the body as CLOSING the outer string split the body at its `;`, so a +# pure read became a stash. The first three were BLOCKED before subst_end() existed here; the +# fourth (both kinds nested) was allowed then only because two misread quotes cancelled out, +# and the single-level `echo "x; git stash pop"` was allowed throughout. +expect allow 'echo "a $(echo "x; git stash pop")"' +expect allow 'echo "a `echo "x; git stash pop"`"' +expect allow 'echo "${x:-"a; git stash pop"}"' +expect allow 'echo "${x:-"$(echo "a; git stash pop")"}"' +expect allow 'echo "x; git stash pop"' +# Recall: a stash behind or beside a quoted expansion is still seen. +expect block 'echo "${x:-"a"}" ; git stash pop' +expect block 'X="${Y:-"z"}" && git stash push -m wip' +expect block 'git stash push -m "${msg:-"wip; x"}"' +# The fail-OPEN mirror: the `"` inside the single quotes left the outer string hanging, so the +# separator behind it went inert and the real stash rode through as text. Both were ALLOWED +# before subst_end() existed here. +expect block "echo \"\$(printf '\"')\" ; git stash pop" +expect block "echo \"\${x:-\$(printf '\"')}\" ; git stash pop" +# Deliberately not claimed, as before: a stash INSIDE a quoted substitution is one inert word. +expect allow 'echo "$(git stash pop)"' + +echo "== the shared quoting rule is guard-main-checkout-bash.sh's, byte for byte (#24901) ==" +# subst_end() lives in four hooks. A copy that drifts is a quoting rule the guards no longer +# share, and nothing else notices: each hook's own cases keep passing on its own dialect. +fn_text() { sed -n "/^$1() {\$/,/^}\$/p" "$2"; } +same_fn() { # same_fn + local mine theirs + mine="$(fn_text "$1" "$hook")"; theirs="$(fn_text "$1" "$2")" + if [ -n "$mine" ] && [ "${mine%%$'\n'*}" = "$1() {" ] && [ "$mine" = "$theirs" ]; then + pass=$((pass + 1)); printf ' ok same %s() as %s\n' "$1" "${2##*/}" + else + fail=$((fail + 1)); printf ' FAIL %s() is missing here or differs from %s\n' "$1" "${2##*/}" + fi +} +same_fn subst_end "$here/guard-main-checkout-bash.sh" + echo "== escape hatch ==" expect allow 'git stash pop' OS_ALLOW_STASH=1 diff --git a/.claude/hooks/guard-tree-enum.selftest.sh b/.claude/hooks/guard-tree-enum.selftest.sh index 507b6dc8d38..c047418d37c 100755 --- a/.claude/hooks/guard-tree-enum.selftest.sh +++ b/.claude/hooks/guard-tree-enum.selftest.sh @@ -119,6 +119,43 @@ expect allow 'echo "never feed a working-tree glob into git show origin/main:"' expect allow 'git grep -n "ls-tree --name-only origin/main"' expect allow 'cat .claude/hooks/guard-tree-enum.sh' +echo "== a \"…\" string ends only where bash ends it (#24901) ==" +# Two shapes inside "…" whose `"` does NOT close the outer string, and this pass read both as +# closing it, so a pure read split into an enumeration segment and an origin/main read. Every +# allow-case below was BLOCKED before the fix; the single-level forms next door never were. +# (1) an escaped `\"`, the #11804 repair its two siblings already carried: +expect allow 'echo "say \"; ls *.yml; git show origin/main:x\" ok"' +expect allow 'echo "say ; ls *.yml; git show origin/main:x ok"' +# (2) a `"` inside a `$(…)`, a backtick or a `${…}`, which opens a context of its own: +expect allow 'echo "a $(echo "x; ls *.yml")" && git show origin/main:README.md' +expect allow 'echo "a `echo "; find ."`"; git grep foo origin/main' +expect allow 'echo "${x:-"; ls *.yml"}" && git show origin/main:README.md' +expect allow 'echo "x; ls *.yml" && git show origin/main:README.md' +# Recall: the sweep this guard exists for is still caught when a quoted expansion sits in it. +expect block 'for f in "${dir:-"docs"}"/*.md; do git show "origin/main:$f"; done' +expect block 'for f in .github/workflows/*.yml; do git show "origin/main:${f}"; done' +expect block 'echo "${x:-"a"}" ; for f in *.yml; do git show "origin/main:$f"; done' +# The fail-OPEN mirror of (1): the escaped quotes paired up and left the region hanging, so +# the real sweep behind them rode through as text. ALLOWED before the fix. +expect block 'echo "he said \"x\"" ; for f in *.yml; do git show "origin/main:$f"; done' + +echo "== the shared quoting rules are copies, byte for byte (#24901) ==" +# split_segments() is guard-shared-stash.sh's and subst_end() is guard-main-checkout-bash.sh's. +# This pass's header called it identical while it lacked the in-quote escape rule — exactly +# the drift a prose claim cannot catch and this pin does. +fn_text() { sed -n "/^$1() {\$/,/^}\$/p" "$2"; } +same_fn() { # same_fn + local mine theirs + mine="$(fn_text "$1" "$hook")"; theirs="$(fn_text "$1" "$2")" + if [ -n "$mine" ] && [ "${mine%%$'\n'*}" = "$1() {" ] && [ "$mine" = "$theirs" ]; then + pass=$((pass + 1)); printf ' ok same %s() as %s\n' "$1" "${2##*/}" + else + fail=$((fail + 1)); printf ' FAIL %s() is missing here or differs from %s\n' "$1" "${2##*/}" + fi +} +same_fn split_segments "$here/guard-shared-stash.sh" +same_fn subst_end "$here/guard-main-checkout-bash.sh" + echo "== unrelated commands are untouched ==" expect allow 'pnpm --filter @objectstack/spec test' expect allow 'git status'