|
38 | 38 | # |
39 | 39 | # Fails open, deliberately, on: |
40 | 40 | # - wrapped invocations: bash -c '…', xargs, ssh host '…', make, a shell script that |
41 | | -# writes (the target is one indirection away from any token this hook can see); |
| 41 | +# writes, a command inside a QUOTED "$(…)" (the target is one indirection away from any |
| 42 | +# token this hook can see); |
42 | 43 | # - programs whose writes live inside their own source text: python -c "open(p,'w')", |
43 | 44 | # node -e, awk > (the target is not a shell token, so there is nothing to read); |
44 | 45 | # - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable |
|
51 | 52 | # Writing ABOUT the ban must never trip the ban (#4890's lesson). Four layers: |
52 | 53 | # 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is |
53 | 54 | # 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); |
54 | 57 | # 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF` |
55 | 58 | # body are documentation, not commands, and segmentation alone (which splits on |
56 | 59 | # newlines) would happily read them as such; |
|
118 | 121 |
|
119 | 122 | [ -n "$cmd" ] || exit 0 |
120 | 123 |
|
| 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. |
| 130 | +# |
| 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, and |
| 137 | +# the text after it is then read exactly as before this function existed. |
| 138 | +SUBST_END=0 |
| 139 | +subst_end() { |
| 140 | + local s="$1" i="$2" n=${#1} st ch top |
| 141 | + if [ "${s:i:1}" = '`' ]; then st='`'; else st=')' ; i=$((i + 1)); fi |
| 142 | + for ((i = i + 1; i < n; i++)); do |
| 143 | + ch="${s:i:1}" ; top="${st:${#st}-1:1}" |
| 144 | + if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi |
| 145 | + if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi |
| 146 | + if [ "$top" = '"' ]; then |
| 147 | + case "$ch" in |
| 148 | + '"') st="${st%?}" ;; |
| 149 | + '`') st+='`' ;; |
| 150 | + '$') if [ "${s:i+1:1}" = '(' ]; then st+=')' ; i=$((i + 1)); fi ;; |
| 151 | + esac |
| 152 | + continue |
| 153 | + fi |
| 154 | + case "$ch" in |
| 155 | + "'" | '"') st+="$ch" ;; |
| 156 | + '(') st+=')' ;; |
| 157 | + ')') [ "$top" = ')' ] && st="${st%?}" ;; |
| 158 | + '`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;; |
| 159 | + esac |
| 160 | + if [ -z "$st" ]; then SUBST_END=$i; return; fi |
| 161 | + done |
| 162 | + SUBST_END=$((n - 1)) |
| 163 | +} |
| 164 | + |
121 | 165 | # --- heredoc bodies are text, not commands --------------------------------------------- |
122 | 166 | # `cat > /tmp/notes.md <<EOF` … `EOF` carries prose over several lines. Segmentation splits |
123 | 167 | # on newlines, so without this pass a documented example inside the body ("sed -i … main |
@@ -148,6 +192,9 @@ strip_line_comment() { |
148 | 192 | ch="${s:i:1}" |
149 | 193 | if [ -n "$q" ]; then |
150 | 194 | if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi |
| 195 | + if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then |
| 196 | + subst_end "$s" "$i" ; i=$SUBST_END ; continue |
| 197 | + fi |
151 | 198 | [ "$ch" = "$q" ] && q="" |
152 | 199 | continue |
153 | 200 | fi |
@@ -217,6 +264,9 @@ split_segments() { |
217 | 264 | seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;; |
218 | 265 | esac |
219 | 266 | fi |
| 267 | + if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then |
| 268 | + subst_end "$s" "$i" ; seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue |
| 269 | + fi |
220 | 270 | seg+="$ch" |
221 | 271 | [ "$ch" = "$q" ] && q="" |
222 | 272 | continue |
@@ -274,6 +324,9 @@ tokenize() { |
274 | 324 | i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;; |
275 | 325 | esac |
276 | 326 | fi |
| 327 | + if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then |
| 328 | + subst_end "$s" "$i" ; tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue |
| 329 | + fi |
277 | 330 | if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi |
278 | 331 | have=1 |
279 | 332 | continue |
|
0 commit comments