Skip to content

Commit 338297e

Browse files
fix(hooks): read $(…), backticks and ${…} inside "…" as their own quoting context in every Bash guard (#25025)
Fixes #24901 Clause-②: no ## What Every quote-aware segmenter in `.claude/hooks/` now reads a `$(…)`, a backtick or a `${…}` met inside a double-quoted word as a quoting context of its own, through one function, `subst_end()`. A `"` inside such a body nests; it no longer closes the outer string. Before this, the inner `"` was read as the closer, the rest of the body became bare shell, and a pure read was blocked. - `guard-main-checkout-bash.sh`: `subst_end()` (added by PR #24886 for `$(…)` and backticks) now also answers where a quoted `${…}` ends, and decides by itself whether the character in front of it opens such a context. Its three call sites (the heredoc comment scan, segmentation, tokenisation) now share that trigger too, so the passes cannot disagree on which characters open a context. - `guard-shared-stash.sh`, `guard-process-kill.sh`, `guard-tree-enum.sh`: each carries a byte-identical copy of `subst_end()`, and `split_segments()` delegates to it from its in-quote branch. The three `split_segments()` are now byte-identical as well. - `guard-tree-enum.sh` also gains the in-quote backslash branch (`\"` inside "…" leaves the region open) that its two siblings already had. Its header already said its pass was "kept case-for-case identical to guard-shared-stash.sh's", but it was not. This is the in-place fix described under Acceptance notes. Diff: 8 files, +392 / -34, all under `.claude/hooks/` (Tier S). ## Measurement: every row of the card's enumeration table On `62bc12930`, the dispatch base (pre-fix), and on this PR's head. Exit 2 = blocked, 0 = allowed. Every command below is a pure read. | hook | command | pre-fix | this PR | |:--|:--|:--|:--| | main-checkout-bash | `echo "${x:-"a > b"}"` | 2, `target: b}` | 0 | | main-checkout-bash | `echo "${x#"a > b"}"` | 2, `target: b}` | 0 | | main-checkout-bash | `echo "${x:-"a; sed -i s/a/b/ pkg/x.ts"}"` | 2 | 0 | | main-checkout-bash (heredoc scan) | a quoted `${x:-" #"}` on a heredoc's introducing line | 2 (the ` #` read as a comment, so the heredoc body was analysed) | 0 | | shared-stash | `echo "a $(echo "x; git stash pop")"` (card position 1) | 2 | 0 | | shared-stash | ``echo "a `echo "x; git stash pop"`"`` | 2 | 0 | | shared-stash | `echo "${x:-"a; git stash pop"}"` | 2 | 0 | | process-kill | `echo "a $(echo "x; pkill -f node")"` | 2 | 0 | | process-kill | `echo "${x:-"a; pkill -f node"}"` | 2 | 0 | | tree-enum | `echo "a $(echo "x; ls *.yml")" && git show origin/main:README.md` | 2 | 0 | | tree-enum | `echo "${x:-"; ls *.yml"}" && git show origin/main:README.md` | 2 | 0 | | tree-enum | `echo "say \"; ls *.yml; git show origin/main:x\" ok"` | 2 | 0 | So H4 is answered: both NOT MEASURED rows (`guard-process-kill.sh`, `guard-tree-enum.sh`) carried the defect, and both are fixed here rather than pinned clean. Recall controls, unchanged (each blocked by its own hook before and after): `echo hi > out.txt`, `sed -i "s/a/${x}/" pkg/x.ts`, `sed -i "s/a/${x:-"b"}/" pkg/x.ts`, `echo "${HOME}" > pkg/x.ts`, `echo "${x:-'a'}" ; sed -i s/a/b/ pkg/x.ts`, `X="${Y:-"z"}" && git stash push -m wip`, `git stash push -m "${msg:-"wip; x"}"`, `kill $(pgrep -f "${pat:-"node"}")`, `pkill -f "${pat:-"vitest; x"}"`, `for f in "${dir:-"docs"}"/*.md; do git show "origin/main:$f"; done`. Every pre-existing case in all five matrices passes unchanged. Four fail-open holes close as a side effect, because the outer string no longer hangs open. Each was ALLOWED before and is blocked now: `echo "$(printf '"')" ; git stash pop`, `echo "${x:-$(printf '"')}" ; git stash pop`, `echo "$(printf '"')" ; pkill -f vitest`, and in tree-enum `echo "he said \"x\"" ; for f in *.yml; do git show "origin/main:$f"; done`. ## H2: how one rule lives in four hooks, an identical copy pinned identical The hooks share no code today. Each one is a standalone script registered on its own in `.claude/settings.json`. The sharing that exists is by copy: `guard-process-kill.sh` says its pass is "Copied case-for-case from guard-shared-stash.sh's split_segments()", and `guard-tree-enum.sh` says its pass is "Kept case-for-case identical". So this PR keeps the copy form and adds the missing half, a pin. Each copying hook's self-test extracts `subst_end()` (and `split_segments()` where it is shared) from its hook and from the canonical file, and fails unless the two are byte-identical and non-empty. A sourced helper file was the alternative. It was not chosen, for three reasons: 1. It would make one file a single point of failure for all four guards at once, the same reason AGENTS.md gives against a shared self-test module. 2. It would add a failure mode the hooks do not have today: a missing or unreadable helper would leave `SUBST_END` at 0 inside a `for` loop, so it would need its own fail-open guard. 3. It would be a new file for the objectui mirror to carry, while the copies already mirror one-for-one. The prose claim alone had already drifted: `guard-tree-enum.sh` lacked the in-quote escape branch while its header called the pass identical. The pin catches exactly that, and it is ablated below. ## H3: a rule of its own for `${…}`, not failing open The two routes the card allows, measured on `guard-main-checkout-bash.sh`: | command (a real write into the primary checkout) | pre-fix | route A (this PR: track `${…}`) | route B (a quoted `${` makes the rest inert) | |:--|:--|:--|:--| | `sed -i "s/a/${x}/" pkg/x.ts` | 2 | 2 | 0 | | `sed -i "s/a/${x:-"b"}/" pkg/x.ts` | 2 | 2 | 0 | | `echo "${HOME}" > pkg/x.ts` | 2 | 2 | 0 | | `echo "${x:-"a"}" ; sed -i s/a/b/ pkg/x.ts` | 2 | 2 | 0 | | `echo "${x:-'a'}" ; sed -i s/a/b/ pkg/x.ts` | 2 | 2 | 0 | | `cp "${SRC}" pkg/x.ts` | 2 | 2 | 0 | Route B was prototyped as a one-line change, the `${` arm returning "rest inert". It gives up the reflexive in-place edit with a variable in its script, which is what this guard exists for. To narrow it to "only a `${…}` whose body holds a `"`", it would first have to find where the body ends, and that is route A's parse. Route A costs one more closer on the existing stack in `subst_end()`, about 15 lines in one function that all three passes already call. That is the least new parser surface on which both passes agree. **H3 premise, partly disproved by measurement.** The card says a `'` inside `"${…}"` is literal. That holds for the expansion's output, and for `bash --posix` and dash when finding the extent. It does not hold for bash 5.2's default mode, which reads `'…'` as a quoted run while looking for the closing brace: `x=; echo "${x:-'}'}"` prints `'}'` under bash 5.2 and `''}` under `bash --posix` and dash. In those two, the first `}` closes the expansion and `'}` is literal text after it. The two readings agree only when the run holds none of `"` `\` `$` `}` or a backtick. So such a run is skipped, and any other `'` there makes the rest of the input one inert run (fail open). That is the header's own contract: an extent that depends on the shell is not one the guard can read with confidence. Both one-reading-only variants were ablated (below), and each goes red. **What route A gives up.** The PM asked that a real write the pre-fix hook blocks stay blocked. I measured a 41-command corpus across the four hooks, pre-fix and with this PR. Every write, stash or kill the pre-fix hooks blocked stays blocked, except shapes the pre-fix reached only through the misreading itself: - An action nested inside a QUOTED substitution, reached through an inner string: `echo "${x:-"$(sed -i s/a/b/ pkg/x.ts)"}"`, `echo "${x:-"$(git stash pop)"}"`, `echo "a $(echo "$(git stash pop)")"`, and the `pkill` twins. The pre-fix blocked them only because the inner `"` exposed the body as bare shell, and the same misreading is what blocked the pure reads above. `echo "$(git stash pop)"` was allowed before and after. This is the class PR #24886 already made a deliberate fail-open in `guard-main-checkout-bash.sh` ("a command inside a QUOTED $(…)"). The `sed` shape is pinned in that hook's matrix. In the stash and kill matrices the class is pinned by `echo "$(git stash pop)"` and `echo "$(pkill -f node)"`. - A target that holds an expansion: `echo hi > "${x:-"a > b"}"`. The pre-fix blocked it on the wrong target, `b}`; it now fails open on its `$`, which is the header's existing promise. - A `'…'` run holding `}` or `"` inside a quoted `${…}`: `echo "${x:-'}'}" ; sed -i s/a/b/ pkg/x.ts`. Bash and dash end the expansion in different places. Its twin `echo "${x:-'}"; sed -i s/a/b/ pkg/x.ts'}"` is a pure read under bash and was a false block before. Keeping the first group blocked would mean analysing quoted substitution bodies as commands. That is a new claim with recursive parser surface, and it would diverge from `guard-main-checkout-bash.sh`'s documented fail-open. This PR does not do it, and the report puts it to the seat as an open question. **Boundary, written into the helper's header.** An unquoted `${…}` holding a `)` inside a quoted substitution (`echo "$(echo ${x:-a)b})"`, which bash prints as `a)b`) ends the body too early. The rest is then read as it was before PR #24886. This is the same direction as the existing `case`-pattern and comment boundary. ## H5: objectui port This PR changes `guard-main-checkout-bash.sh` again. `subst_end()` gains the `${…}` arm and the `'` rule, and now returns 1 for a non-opener. Its three call sites change from testing two-character prefixes to the shape `` { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i" ``. objectui#13260, the port of PR #24886 that mirrors this hook case-for-case, should carry this delta too, along with the stash, process-kill and tree-enum copies and their self-test pins. This PR changes no objectui file. ## Tests, ablations and gates Matrices and gates ran at head `1df8c6399`. The ablations ran at `d83fca2fb`, whose eight files are byte-identical to the head's: the merge touched nothing under `.claude/hooks/`. Hook matrices (`bash .claude/hooks/X.selftest.sh`): `guard-main-checkout-bash` 157 passed / 0 failed (141 on main, +16), `guard-shared-stash` 65/0 (53, +12), `guard-process-kill` 80/0 (69, +11), `guard-tree-enum` 50/0 (38, +12), `guard-main-checkout` 120/0 (unchanged). The new self-tests against the pre-fix hooks (each base hook copied beside its new matrix): main-checkout-bash 149 passed / 8 failed, shared-stash 59/6, process-kill 74/6, tree-enum 43/7. In main-checkout-bash the 8 are the `${…}` false blocks plus the four documented fail-open pins that the pre-fix blocked. In the three copies they are the false blocks, the fail-open mirrors and the identity pins. Ablations went through `scripts/ablation-replace.mjs` in WRAP mode, with the hook's matrix as the child. Each mutation was proven on disk (anchor 1 to 0, blob changed), and each restore was proven by blob equal to HEAD and an empty `git diff HEAD`: - A1 `${` arm removed from `subst_end()`: 8 red (every `${` form). - A2 heredoc comment scan delegation only: 2 red (the `$(…)` and `${…}` heredoc pins). - A3 segmentation delegation only: 8 red. - A4 tokenisation delegation only: 5 red (including `echo "${x:-"a > b"}"` and `echo "${x#"a > b"}"`). So no single pass fixes `${…}` alone. - A5a bash-only reading of `'` (the special-character check deleted): 1 red. A5b POSIX-only reading (the `'` arm deleted): 2 red. A5c a `'` always fails open: 1 red (`echo "${x:-'a'}" ; sed …`, the recall case). - A6 the stash guard's delegation removed: 5 red. A7 a behaviour-neutral edit to the stash copy of `subst_end()` (locals reordered): exactly 1 red, the identity pin. A8 the tree-enum in-quote escape branch deleted: 3 red (both `\"` cases and the `split_segments()` pin). Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 16 families, the same 16 as at dispatch. All 16 exited 0, each exit captured before any pipe. `--ran` printed: "16 derived, 16 run, 0 NOT-MEASURED, 0 UNRUN". `pnpm check:bash32-floor`: "19 constructs checked, floor bash 3.2". `check:doc-formula-expressions` first answered PREREQUISITE NOT MET (exit 3, unbuilt `@objectstack/formula` and `@objectstack/lint`). I built both under the verify lock, and it then exited 0. `pnpm check:pm-settings-deny-roster`: exit 0. Control-byte self-scan of the eight files: no match. Cost on a 1.9 KB, 25-line command of the card's probe shape: main-checkout-bash took 146 ms pre-fix and 163 ms with this PR, shared-stash 46 and 53 ms, process-kill 63 and 61 ms, tree-enum 63 and 55 ms. No changeset: `.claude/**` publishes nothing (`skip-changeset`). ## Acceptance notes - **In-place fix under the bounded exemption: the tree-enum in-quote escape branch.** It is the same defect class as this card: a `"` that bash does not read as closing the outer string was read as closing it. The fix is mechanical, a verbatim copy of the branch its two siblings carry, with its shape already pinned in their matrices. The file is inside this card's declared surface, and the change is in the same gate family. Measured before: `echo "say \"; ls *.yml; git show origin/main:x\" ok"` exit 2 (a pure read), and `echo "he said \"x\"" ; for f in *.yml; do git show "origin/main:$f"; done` exit 0 (a real sweep let through). - `guard-shared-stash.sh`'s header still says "Self-test (51 cases…)". It was 53 on main and is 65 now. That is a stale count in prose, left as is. - Merged `origin/main` (`05b28c199`, 4 commits, none under `.claude/hooks/`) before opening, per AGENTS.md §10. The union above was re-run on the merge head. ## 维护者速读(草稿) ### 改了什么 修了四个 Bash 守卫钩子的同一类误判:命令里双引号包着的 `$(…)`、反引号或 `${…}` 里如果又带了引号,钩子会把内层引号当成外层字符串的结束,结果把纯只读命令误拦下来(比如 `echo "${x:-"a > b"}"` 被当成写文件)。现在四个钩子共用同一个判断函数,各自持有一份逐字节相同的副本,并由自测锁定"副本必须与原件相同"。顺带补上 `guard-tree-enum.sh` 漏掉的一条转义引号规则,它的注释早就声称与兄弟钩子一致,实际并不一致。 ### 为什么改 这是 #24812 那次修复的同族收尾:上次只修了写保护钩子里的 `$(…)`,stash、杀进程、目录枚举三个钩子和 `${…}` 写法仍会误拦只读命令。钩子头部承诺"看不懂的一律放行,绝不误拦只读命令",误拦违背这个承诺,会诱使人把钩子关掉。 ### 风险与代价(含回滚) 真正的写入、stash、按名杀进程、目录枚举误用仍全部拦截(原有用例全部通过)。代价:少数原本"碰巧被拦"的形状现在放行,即藏在双引号子命令里的写操作,以及 bash 与 POSIX shell 对引号理解不一致的极端写法;这与上次修复时接受的取舍一致,并已写入自测。回滚就是 revert 本 PR,只涉及 `.claude/hooks/` 下 8 个文件。 ### 席位意见 ### 你要做的 无需操作:这是 Tier S,席位按契约档复核后自行落地。objectui 的同名钩子由另一张卡(objectui#13260)跟进,本 PR 不改 objectui。 --- _Generated by [Claude Code](https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c0cde8b commit 338297e

8 files changed

Lines changed: 392 additions & 34 deletions

‎.claude/hooks/guard-main-checkout-bash.selftest.sh‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,44 @@ expect block "echo \"\$(echo \"\$(printf '\"')\")\" ; sed -i s/a/b/ pkg/x.ts"
326326
# introducer behind it is found and the body stays prose.
327327
expect allow "$(printf 'echo "$(echo " #")" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
328328

329+
echo "== a \${…} inside \"…\" is its own quoting context too (#24901) =="
330+
CWD="$MAIN"
331+
# The measured false block: the `"` that opens the default was read as CLOSING the outer string,
332+
# so `b}` became a write target. Pure reads — nothing is written. Each one exercises a different
333+
# pass: tokenisation (the first two), segmentation (the `;`), nesting both ways (the third).
334+
expect allow 'echo "${x:-"a > b"}"'
335+
expect allow 'echo "${x#"a > b"}"'
336+
expect allow 'echo "${x:-"$(printf "a > b")"}"'
337+
expect allow 'echo "${x:-"a; sed -i s/a/b/ pkg/x.ts"}"'
338+
expect allow "echo \"\${x:-'a > b'}\"" # control: no nested `"`, allowed before and after
339+
# The heredoc scan agrees: the ` #` inside the expansion is no comment, so the introducer
340+
# behind it is found and the body stays prose.
341+
expect allow "$(printf 'echo "${x:-" #"}" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
342+
# Recall is untouched: a write behind, beside or inside the script of a quoted expansion. A
343+
# '…' run holding no character the shells disagree on is skipped, so the write behind it stays.
344+
expect block 'sed -i "s/a/${x:-"b"}/" pkg/x.ts'
345+
expect block 'sed -i "s/a/${x}/" pkg/x.ts'
346+
expect block 'echo "${HOME}" > pkg/x.ts'
347+
expect block 'echo "${x:-"a"}" ; sed -i s/a/b/ pkg/x.ts'
348+
expect block "echo \"\${x:-'a'}\" ; sed -i s/a/b/ pkg/x.ts"
349+
# A `$(…)` inside the expansion is read by the same function, quotes and all: the `"` inside
350+
# its single quotes closes nothing, so the separator behind the word still splits.
351+
expect block "echo \"\${x:-\$(printf '\"')}\" ; sed -i s/a/b/ pkg/x.ts"
352+
329353
echo "== shapes this guard deliberately does NOT claim (documented fail-open) =="
330354
CWD="$MAIN"
331355
expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\""
332356
expect allow 'echo pkg/x.ts | xargs sed -i s/a/b/'
333357
expect allow 'node -e "fs.writeFileSync(\"pkg/x.ts\", \"y\")"'
334358
expect allow 'python3 -c "open(\"pkg/x.ts\",\"w\").write(\"y\")"'
335359
expect allow 'echo "$(sed -i s/a/b/ pkg/x.ts)"' # a QUOTED $(…) body is one inert word
360+
expect allow 'echo "${x:-"$(sed -i s/a/b/ pkg/x.ts)"}"' # …and so is a QUOTED ${…} body
361+
expect allow 'echo hi > "${x:-"a > b"}"' # a target holding an expansion
362+
# A '…' run inside a quoted ${…} that holds `}` (or `"` `\` `$` and a backtick): bash 5.2 reads
363+
# it as quoted and POSIX shells as literal, so they end the expansion in different places and
364+
# everything after it is inert. Under bash the first is a pure read, under dash a real write.
365+
expect allow "echo \"\${x:-'}\"; sed -i s/a/b/ pkg/x.ts'}\""
366+
expect allow "echo \"\${x:-'}'}\" ; sed -i s/a/b/ pkg/x.ts"
336367
expect allow 'sed -i s/a/b/ $SOMEDIR/x.ts'
337368
expect allow 'sed -i s/a/b/ pkg/*.ts'
338369

‎.claude/hooks/guard-main-checkout-bash.sh‎

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
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
@@ -52,8 +55,8 @@
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;
@@ -121,35 +124,66 @@ fi
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.
139154
SUBST_END=0
140155
subst_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

‎.claude/hooks/guard-process-kill.selftest.sh‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,41 @@ echo "== an escaped \\\" INSIDE a double-quoted word does NOT close it (#10406 h
176176
expect allow 'grep -rn "he said \"pkill -f vitest\" once" .claude/'
177177
expect block 'echo "he said \"x\"" && pkill -f vitest'
178178

179+
echo "== a \$(…), backtick or \${…} inside \"…\" is its own quoting context (#24901) =="
180+
# Reading the `"` inside the body as CLOSING the outer string split the body at its `;`, so a
181+
# pure read became a name-matched kill. All four were BLOCKED before subst_end() existed here.
182+
expect allow 'echo "a $(echo "x; pkill -f node")"'
183+
expect allow 'echo "a `echo "x; pkill -f node"`"'
184+
expect allow 'echo "${x:-"a; pkill -f node"}"'
185+
expect allow 'echo "${x:-"; killall node"}"'
186+
# Recall: a kill behind a quoted expansion is still seen, and a pattern held in one is still a
187+
# pattern — the expansion is part of the word, not a way around the rule.
188+
expect block 'echo "${x:-"a"}" ; pkill -f node'
189+
expect block 'kill $(pgrep -f "${pat:-"node"}")'
190+
expect block 'pkill -f "${pat:-"vitest; x"}"'
191+
# The fail-OPEN mirror: the `"` inside the single quotes left the outer string hanging, so the
192+
# separator behind it went inert. ALLOWED before subst_end() existed here.
193+
expect block "echo \"\$(printf '\"')\" ; pkill -f vitest"
194+
# Deliberately not claimed, as before: a kill INSIDE a quoted substitution is one inert word.
195+
expect allow 'echo "$(pkill -f node)"'
196+
197+
echo "== the shared quoting rules are copies, byte for byte (#24901) =="
198+
# split_segments() is guard-shared-stash.sh's and subst_end() is guard-main-checkout-bash.sh's.
199+
# A copy that drifts is a quoting rule the guards no longer share, and nothing else notices:
200+
# each hook's own cases keep passing on its own dialect.
201+
fn_text() { sed -n "/^$1() {\$/,/^}\$/p" "$2"; }
202+
same_fn() { # same_fn <function> <file holding the copy this hook must equal>
203+
local mine theirs
204+
mine="$(fn_text "$1" "$hook")"; theirs="$(fn_text "$1" "$2")"
205+
if [ -n "$mine" ] && [ "${mine%%$'\n'*}" = "$1() {" ] && [ "$mine" = "$theirs" ]; then
206+
pass=$((pass + 1)); printf ' ok same %s() as %s\n' "$1" "${2##*/}"
207+
else
208+
fail=$((fail + 1)); printf ' FAIL %s() is missing here or differs from %s\n' "$1" "${2##*/}"
209+
fi
210+
}
211+
same_fn split_segments "$here/guard-shared-stash.sh"
212+
same_fn subst_end "$here/guard-main-checkout-bash.sh"
213+
179214
echo "== escape hatch =="
180215
expect allow 'pkill -f vitest' OS_ALLOW_PROCESS_KILL=1
181216
expect allow 'ps aux | grep node | xargs kill' OS_ALLOW_PROCESS_KILL=1

‎.claude/hooks/guard-process-kill.sh‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,63 @@ fi
142142
# (#11738 on the stash guard, #11131 on guard-main-checkout-bash.sh);
143143
# - INSIDE "…" the rule inverts: `\"` is a literal quote that leaves the region OPEN, and
144144
# reading it as closing splits where bash would not, turning the tail of a pure READ
145-
# into a segment judged on its own head word — a false BLOCK (#11804 / #10406).
145+
# into a segment judged on its own head word — a false BLOCK (#11804 / #10406);
146+
# - INSIDE "…" a `$(`, a backtick or a `${` opens a quoting context of its own, so a `"` in
147+
# its body nests instead of closing the outer string. Reading it as the closer split the
148+
# body: `echo "a $(echo "x; pkill -f node")"` and `echo "${x:-"a; killall node"}"` were
149+
# BLOCKED, measured on this repo's copy before the fix (#24901).
146150
#
147151
# Both characters are kept verbatim: this pass only SPLITS, and the classifiers below
148-
# re-read the words afterwards.
152+
# re-read the words afterwards. The self-test fails when this function differs from
153+
# guard-shared-stash.sh's, and when subst_end() differs from guard-main-checkout-bash.sh's,
154+
# whose comment above the function is its documentation.
155+
SUBST_END=0
156+
subst_end() {
157+
local s="$1" i="$2" n=${#1} st ch top run
158+
case "${s:i:2}" in
159+
'$(') st=')' ; i=$((i + 1)) ;;
160+
'${') st='}' ; i=$((i + 1)) ;;
161+
'`'*) st='`' ;;
162+
*) return 1 ;;
163+
esac
164+
for ((i = i + 1; i < n; i++)); do
165+
ch="${s:i:1}" ; top="${st:${#st}-1:1}"
166+
if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi
167+
if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi
168+
if [ "$top" = '"' ] || [ "$top" = '}' ]; then
169+
case "$ch" in
170+
'"') if [ "$top" = '"' ]; then st="${st%?}"; else st+='"'; fi ;;
171+
'`') st+='`' ;;
172+
'$')
173+
case "${s:i+1:1}" in
174+
'(') st+=')' ; i=$((i + 1)) ;;
175+
'{') st+='}' ; i=$((i + 1)) ;;
176+
esac ;;
177+
'}') [ "$top" = '}' ] && st="${st%?}" ;;
178+
"'")
179+
if [ "$top" = '}' ]; then
180+
run="${s:i+1}"
181+
case "$run" in *"'"*) ;; *) SUBST_END=$((n - 1)); return 0 ;; esac
182+
run="${run%%"'"*}"
183+
case "$run" in *[\"\\\`\$\}]*) SUBST_END=$((n - 1)); return 0 ;; esac
184+
i=$((i + ${#run} + 1))
185+
fi ;;
186+
esac
187+
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
188+
continue
189+
fi
190+
case "$ch" in
191+
"'" | '"') st+="$ch" ;;
192+
'(') st+=')' ;;
193+
')') [ "$top" = ')' ] && st="${st%?}" ;;
194+
'`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;;
195+
esac
196+
if [ -z "$st" ]; then SUBST_END=$i; return 0; fi
197+
done
198+
SUBST_END=$((n - 1))
199+
return 0
200+
}
201+
149202
segments=()
150203
split_segments() {
151204
local s="$1" seg="" q="" ch i n=${#1}
@@ -158,6 +211,9 @@ split_segments() {
158211
seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
159212
esac
160213
fi
214+
if [ "$q" = '"' ] && { [ "$ch" = '$' ] || [ "$ch" = '`' ]; } && subst_end "$s" "$i"; then
215+
seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue
216+
fi
161217
seg+="$ch"
162218
[ "$ch" = "$q" ] && q=""
163219
continue

‎.claude/hooks/guard-shared-stash.selftest.sh‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,43 @@ expect block 'echo "a \\" ; git stash pop'
158158
# quotes it would swallow the closing `'` and let the stash behind it through.
159159
expect block "echo 'a \\' ; git stash pop"
160160

161+
echo "== a \$(…), backtick or \${…} inside \"…\" is its own quoting context (#24901) =="
162+
# Reading the `"` inside the body as CLOSING the outer string split the body at its `;`, so a
163+
# pure read became a stash. The first three were BLOCKED before subst_end() existed here; the
164+
# fourth (both kinds nested) was allowed then only because two misread quotes cancelled out,
165+
# and the single-level `echo "x; git stash pop"` was allowed throughout.
166+
expect allow 'echo "a $(echo "x; git stash pop")"'
167+
expect allow 'echo "a `echo "x; git stash pop"`"'
168+
expect allow 'echo "${x:-"a; git stash pop"}"'
169+
expect allow 'echo "${x:-"$(echo "a; git stash pop")"}"'
170+
expect allow 'echo "x; git stash pop"'
171+
# Recall: a stash behind or beside a quoted expansion is still seen.
172+
expect block 'echo "${x:-"a"}" ; git stash pop'
173+
expect block 'X="${Y:-"z"}" && git stash push -m wip'
174+
expect block 'git stash push -m "${msg:-"wip; x"}"'
175+
# The fail-OPEN mirror: the `"` inside the single quotes left the outer string hanging, so the
176+
# separator behind it went inert and the real stash rode through as text. Both were ALLOWED
177+
# before subst_end() existed here.
178+
expect block "echo \"\$(printf '\"')\" ; git stash pop"
179+
expect block "echo \"\${x:-\$(printf '\"')}\" ; git stash pop"
180+
# Deliberately not claimed, as before: a stash INSIDE a quoted substitution is one inert word.
181+
expect allow 'echo "$(git stash pop)"'
182+
183+
echo "== the shared quoting rule is guard-main-checkout-bash.sh's, byte for byte (#24901) =="
184+
# subst_end() lives in four hooks. A copy that drifts is a quoting rule the guards no longer
185+
# share, and nothing else notices: each hook's own cases keep passing on its own dialect.
186+
fn_text() { sed -n "/^$1() {\$/,/^}\$/p" "$2"; }
187+
same_fn() { # same_fn <function> <file holding the copy this hook must equal>
188+
local mine theirs
189+
mine="$(fn_text "$1" "$hook")"; theirs="$(fn_text "$1" "$2")"
190+
if [ -n "$mine" ] && [ "${mine%%$'\n'*}" = "$1() {" ] && [ "$mine" = "$theirs" ]; then
191+
pass=$((pass + 1)); printf ' ok same %s() as %s\n' "$1" "${2##*/}"
192+
else
193+
fail=$((fail + 1)); printf ' FAIL %s() is missing here or differs from %s\n' "$1" "${2##*/}"
194+
fi
195+
}
196+
same_fn subst_end "$here/guard-main-checkout-bash.sh"
197+
161198
echo "== escape hatch =="
162199
expect allow 'git stash pop' OS_ALLOW_STASH=1
163200

0 commit comments

Comments
 (0)