Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,44 @@ 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 " #")" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"

echo "== a \${…} inside \"…\" is its own quoting context too (#24901) =="
CWD="$MAIN"
# The measured false block: the `"` that opens the default was read as CLOSING the outer string,
# so `b}` became a write target. Pure reads — nothing is written. Each one exercises a different
# pass: tokenisation (the first two), segmentation (the `;`), nesting both ways (the third).
expect allow 'echo "${x:-"a > 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:-" #"}" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
# Recall is untouched: a write behind, beside or inside the script of a quoted expansion. A
# '…' run holding no character the shells disagree on is skipped, so the write behind it stays.
expect block 'sed -i "s/a/${x:-"b"}/" pkg/x.ts'
expect block 'sed -i "s/a/${x}/" pkg/x.ts'
expect block 'echo "${HOME}" > 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\""
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'

Expand Down
91 changes: 63 additions & 28 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <<EOF`
# body are documentation, not commands, and segmentation alone (which splits on
# newlines) would happily read them as such;
Expand Down Expand Up @@ -121,35 +124,66 @@ fi

[ -n "$cmd" ] || exit 0

# --- a command substitution inside "…" is its own quoting context ---------------------
# Inside "…", `$(` or a backtick 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). 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
Expand All @@ -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 ---------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=""
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions .claude/hooks/guard-process-kill.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <function> <file holding the copy this hook must equal>
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
Expand Down
60 changes: 58 additions & 2 deletions .claude/hooks/guard-process-kill.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions .claude/hooks/guard-shared-stash.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <function> <file holding the copy this hook must equal>
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

Expand Down
Loading
Loading