4444# node -e, awk > (the target is not a shell token, so there is nothing to read);
4545# - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable
4646# without running it;
47+ # - everything after a `'` inside a QUOTED "${…}" unless the quoted run holds none of
48+ # `"` `\` `$` `}` or a backtick: bash and POSIX shells disagree on where such an expansion
49+ # ends (subst_end());
4750# - relative targets when the payload carries no cwd.
4851# Each of those is a hole by choice, not by accident: OS_ALLOW_MAIN_EDITS=1 already exists
4952# for anyone who means to write there, and the target of this guard is the reflexive
5255# Writing ABOUT the ban must never trip the ban (#4890's lesson). Four layers:
5356# 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is
5457# literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass;
55- # a `$(…)` or backtick inside "…" is a quoting context of its own, so a `"` in its body
56- # nests instead of closing the outer string (subst_end(), shared by every pass);
58+ # a `$(…)`, a backtick or a `${…}` inside "…" is a quoting context of its own, so a `"` in
59+ # its body nests instead of closing the outer string (subst_end(), shared by every pass);
5760# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF`
5861# body are documentation, not commands, and segmentation alone (which splits on
5962# newlines) would happily read them as such;
121124
122125[ -n " $cmd " ] || exit 0
123126
124- # --- a command substitution inside "…" is its own quoting context ---------------------
125- # Inside "…", `$(` or a backtick opens a NEW quoting context: a `"` in the body starts a string
126- # of its own and does not close the outer one. Each scanner below used to read that `"` as the
127- # closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was taken
128- # for a redirect whose "target" was the next fragment — a false BLOCK (#24812). All three
129- # quote-aware scanners hand such a body to this ONE function, so they agree on where it ends.
127+ # --- an expansion inside "…" is its own quoting context --------------------------------
128+ # Inside "…", `$(`, a backtick or `${` opens a NEW quoting context: a `"` in the body starts a
129+ # string of its own and does not close the outer one. Each scanner below used to read that `"`
130+ # as the closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was
131+ # taken for a redirect whose "target" was the next fragment — a false BLOCK (#24812); the same
132+ # reading of `echo "${x:-"a > b"}"` named `b}` as a target (#24901). All three quote-aware
133+ # scanners hand such a body to this ONE function, so they agree on where it ends.
134+ # guard-shared-stash.sh, guard-process-kill.sh and guard-tree-enum.sh segment commands the same
135+ # way and carry a byte-identical copy; their self-tests fail when a copy differs from this one.
130136#
131- # subst_end S I: S[I] is the `$` of a `$(`, or a backtick, met inside "…". Sets SUBST_END to
132- # the index of the character that closes the substitution, reading the body with its own
133- # quotes, escapes and nested `(…)` / `$(…)` / backticks; when the input ends first, to the
134- # last index — the rest is one inert run (bash refuses such a command anyway). The body is
135- # kept verbatim as part of the word, so a target holding it still fails open on its `$`.
136- # Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment) moves the end —
137- # too early, and the rest is read as it was before this function existed; too late, and the
138- # text swallowed with the body is inert, i.e. fails open.
137+ # subst_end S I: when S[I] opens such a context (the `$` of a `$(` or a `${`, or a backtick),
138+ # sets SUBST_END to the index of the character that closes it and returns 0; otherwise returns 1
139+ # and changes nothing. The body is read with its own quotes, escapes and nesting; when the input
140+ # ends first, SUBST_END is the last index — the rest is one inert run (bash refuses such a
141+ # command anyway). The body is kept verbatim as part of the word, so a target holding it still
142+ # fails open on its `$`.
143+ #
144+ # A `${…}` body reads like "…" with one more closer: `"`, `$(`, `${` and a backtick nest in it,
145+ # a backslash escapes, and `}` closes it. A `'` there is where shells part ways, measured: bash
146+ # 5.2 reads `'…'` as a quoted run while it looks for the `}` (`"${x:-'}'}"` prints `'}'`), and
147+ # `bash --posix` and dash read the `'` as a literal. The two readings agree only when the run
148+ # holds none of `"` `\` `$` `}` or a backtick, so such a run is skipped, and any other `'` makes
149+ # the rest one inert run: an extent that depends on the shell is not one this guard can read.
150+ #
151+ # Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment, an unquoted `${…}`
152+ # holding one) moves the end — too early, and the rest is read as it was before this function
153+ # existed; too late, and the text swallowed with the body is inert, i.e. fails open.
139154SUBST_END=0
140155subst_end () {
141- local s=" $1 " i=" $2 " n=${# 1} st ch top
142- if [ " ${s: i: 1} " = ' `' ]; then st=' `' ; else st=' )' ; i=$(( i + 1 )) ; fi
156+ local s=" $1 " i=" $2 " n=${# 1} st ch top run
157+ case " ${s: i: 2} " in
158+ ' $(' ) st=' )' ; i=$(( i + 1 )) ;;
159+ ' ${' ) st=' }' ; i=$(( i + 1 )) ;;
160+ ' `' * ) st=' `' ;;
161+ * ) return 1 ;;
162+ esac
143163 for (( i = i + 1 ; i < n; i++ )) ; do
144164 ch=" ${s: i: 1} " ; top=" ${st: ${# st} -1: 1} "
145165 if [ " $top " = " '" ]; then [ " $ch " = " '" ] && st=" ${st% ?} " ; continue ; fi
146166 if [ " $ch " = ' \' ]; then i=$(( i + 1 )) ; continue ; fi
147- if [ " $top " = ' "' ]; then
167+ if [ " $top " = ' "' ] || [ " $top " = ' } ' ] ; then
148168 case " $ch " in
149- ' "' ) st=" ${st% ?} " ;;
169+ ' "' ) if [ " $top " = ' " ' ] ; then st=" ${st% ?} " ; else st+= ' " ' ; fi ;;
150170 ' `' ) st+=' `' ;;
151- ' $' ) if [ " ${s: i+1: 1} " = ' (' ]; then st+=' )' ; i=$(( i + 1 )) ; fi ;;
171+ ' $' )
172+ case " ${s: i+1: 1} " in
173+ ' (' ) st+=' )' ; i=$(( i + 1 )) ;;
174+ ' {' ) st+=' }' ; i=$(( i + 1 )) ;;
175+ esac ;;
176+ ' }' ) [ " $top " = ' }' ] && st=" ${st% ?} " ;;
177+ " '" )
178+ if [ " $top " = ' }' ]; then
179+ run=" ${s: i+1} "
180+ case " $run " in * " '" * ) ;; * ) SUBST_END=$(( n - 1 )) ; return 0 ;; esac
181+ run=" ${run%% " '" * } "
182+ case " $run " in * [\"\\\`\$\} ]* ) SUBST_END=$(( n - 1 )) ; return 0 ;; esac
183+ i=$(( i + ${# run} + 1 ))
184+ fi ;;
152185 esac
186+ if [ -z " $st " ]; then SUBST_END=$i ; return 0; fi
153187 continue
154188 fi
155189 case " $ch " in
@@ -158,9 +192,10 @@ subst_end() {
158192 ' )' ) [ " $top " = ' )' ] && st=" ${st% ?} " ;;
159193 ' `' ) if [ " $top " = ' `' ]; then st=" ${st% ?} " ; else st+=' `' ; fi ;;
160194 esac
161- if [ -z " $st " ]; then SUBST_END=$i ; return ; fi
195+ if [ -z " $st " ]; then SUBST_END=$i ; return 0 ; fi
162196 done
163197 SUBST_END=$(( n - 1 ))
198+ return 0
164199}
165200
166201# --- heredoc bodies are text, not commands ---------------------------------------------
@@ -193,8 +228,8 @@ strip_line_comment() {
193228 ch=" ${s: i: 1} "
194229 if [ -n " $q " ]; then
195230 if [ " $q " = ' "' ] && [ " $ch " = ' \' ] && [ $(( i + 1 )) -lt " $n " ]; then i=$(( i + 1 )) ; continue ; fi
196- if [ " $q " = ' "' ] && { [ " $ch " = ' ` ' ] || [ " ${s : i : 2} " = ' $( ' ]; }; then
197- subst_end " $s " " $i " ; i=$SUBST_END ; continue
231+ if [ " $q " = ' "' ] && { [ " $ch " = ' $ ' ] || [ " $ch " = ' ` ' ]; } && subst_end " $s " " $i " ; then
232+ i=$SUBST_END ; continue
198233 fi
199234 [ " $ch " = " $q " ] && q=" "
200235 continue
@@ -265,8 +300,8 @@ split_segments() {
265300 seg+=" $ch " ; i=$(( i + 1 )) ; seg+=" ${s: i: 1} " ; continue ;;
266301 esac
267302 fi
268- if [ " $q " = ' "' ] && { [ " $ch " = ' ` ' ] || [ " ${s : i : 2} " = ' $( ' ]; }; then
269- subst_end " $s " " $i " ; seg+=" ${s: i: SUBST_END-i+1} " ; i=$SUBST_END ; continue
303+ if [ " $q " = ' "' ] && { [ " $ch " = ' $ ' ] || [ " $ch " = ' ` ' ]; } && subst_end " $s " " $i " ; then
304+ seg+=" ${s: i: SUBST_END-i+1} " ; i=$SUBST_END ; continue
270305 fi
271306 seg+=" $ch "
272307 [ " $ch " = " $q " ] && q=" "
@@ -325,8 +360,8 @@ tokenize() {
325360 i=$(( i + 1 )) ; tok+=" ${s: i: 1} " ; have=1 ; continue ;;
326361 esac
327362 fi
328- if [ " $q " = ' "' ] && { [ " $ch " = ' ` ' ] || [ " ${s : i : 2} " = ' $( ' ]; }; then
329- subst_end " $s " " $i " ; tok+=" ${s: i: SUBST_END-i+1} " ; i=$SUBST_END ; have=1 ; continue
363+ if [ " $q " = ' "' ] && { [ " $ch " = ' $ ' ] || [ " $ch " = ' ` ' ]; } && subst_end " $s " " $i " ; then
364+ tok+=" ${s: i: SUBST_END-i+1} " ; i=$SUBST_END ; have=1 ; continue
330365 fi
331366 if [ " $ch " = " $q " ]; then q=" " ; else tok+=" $ch " ; fi
332367 have=1
0 commit comments