Hooks: a quote past its backtick's end, and a ' in a double-quoted ${...}, are asked about (#579)
Merged
DO
2d ago+156+156+012345678901234567890123456789-1-1-0123456789
Part of #579. Two of its three spellings now make the hooks ask; the third is left open on the issue.
What changed
tools/hooks/shell.py's reader now refuses two spellings it cannot read as the shell does. On a refusal the protected hook asks and the identity hook refuses.
- A quote in a backtick that runs past the backtick's end. bash ends a backtick at its next backtick before it reads the quotes inside it, so an open quote there is an error, and bash runs what follows the backtick. The reader followed the quote instead, so a later line's
rm kernel/gate.pywas hidden in a word. Codex's example from #578 now asks. A quote that closes inside its backtick is read as before. - A
'in a${...}that double quotes hold, directly, or inside a$(or backtick they hold, or inside an outer${...}. Example:echo "$(echo "${?+'$(rm kernel/gate.py)'}")". The shell reads that'as a character; the reader would take it as a quote, which hid therm.- The reader keeps where each substitution that double quotes hold opens. It reads where one ends only when such a
${...}needs it, and drops one that has closed, so readings stay linear. - Inside such a substitution, a
${...}counts as double-quoted only where the substitution's own double quotes hold it:echo "$(echo ${x:-'default'})"is read as before. - A
${...}with a'that no double quote holds is read as before, even after a double quote that has closed:echo "$x" ${y:-'z'}. echo "${x:-'default'}"is now asked about. That spelling is rare in session commands.- In a here-document's body, a substitution the reader cannot read is still data when the shell would fail it (decision 0040). These two refusals are
shell.Unread, which the body passes on, since the shell runs what they hide.
- The reader keeps where each substitution that double quotes hold opens. It reads where one ends only when such a
Decision 0032 gains a row. Seventeen mutants hold it.
Left open on #579
Command words spelt by variables and written into a script that is then run: a=push; b=git; echo "$b" "$a" > x.sh; bash x.sh. Only code written to hide a command spells it that way. The hooks are mistake guards, and review sees such code.
Filed as #605, low priority, since main behaves the same:
- A
"inside a comment in a nested substitution is counted as a quote, so a${...}after it is not seen as double-quoted (Astra at e5221190). Only a command written to hide its words has that shape. - The identity check reads each substitution's calls across the whole command, so a command with thousands of substitutions reads in time on the square of their number.
maindoes the same.
Probe (both hooks)
| Command | main | this branch |
|---|---|---|
Codex's quote past a backtick, then rm kernel/gate.py | let through | protected asks |
echo "$(echo "${?+'$(rm kernel/gate.py)'}")" | let through | protected asks, identity refuses |
echo "${x:-'default'}" > notes.txt | read | asked |
cat <<EOF around $(echo "${x:-'default'}"; rm kernel/gate.py) | protected asks | protected asks |
echo "$x" ${y:-'z'}, git commit -m "msg" && echo ${FOO:-'x'}, echo "$(echo ${x:-'default'})", echo "`date`"; echo ${y:-'z'} | read | read |
echo "${x:-default}", git log --format='%H', backtick scripts whose quotes close, heredocs | read | read |
Checks
make verifypasses at bdf73d61, run alone on an export.- All 128 mutants of
tools/hooks/shell.pyare killed, in filtered runs of at most 40.
Reviews
| Reader | At | Read | Disposition |
|---|---|---|---|
| Claude | 2c8ff27a | Blocker: any " earlier in the command made a later ${...'...'} unreadable, so ordinary commands asked. Other: the stated reason was wrong, since both shells read the ' as a character | Both fixed in 53633b3b |
| Astra | 2c8ff27a | 2 major: only a double quote was tested past a backtick; an outer ${...} skipped the check | Both fixed in 53633b3b |
| verify | 53633b3b | The linear-time test failed: each held substitution's end was read as it opened | Fixed in c4c17d3f: its end is read only when needed |
| Astra | c4c17d3f | 2 major: a held backtick was untested; echo "$(echo ${x:-'default'})" was asked about | Both fixed in 8850ea36 |
| Codex | c4c17d3f | P2: a quote in a backtick was read to the backtick's end, so the reading went quadratic. P2: a backtick that closed in double quotes was kept as held | Both fixed in 5204ac8c |
| CI | 5204ac8c | Decision 0032's new row named no mutant (rule from #594) | Fixed in e5221190 |
| Codex | e5221190 | P2: each ${...} re-read every held substitution, closed ones too | Fixed in 56cb4029 |
| Astra | e5221190 | Blocking: a " in a nested comment is counted. Major: the same quadratic as Codex's | The quadratic is fixed in 56cb4029. The comment case is code written to hide; proposed for a follow-up issue |
| Codex | 56cb4029 | P1: in a here-document a newly refused substitution was left as data, so a rm in it was let through, which main asks about. P2: each nested "${...}" was read to its end | Both fixed in b4cc2018 |
| Astra | b4cc2018 | Blocking: a " in single quotes is counted as the substitution's own. Major: nested "${...}"s with a ' in a substitution re-read. Major: the backtick refusal not tested as Unread | The two majors are fixed in bdf73d61. The blocker is code written to hide and the protected hook still asks about it; added to #605 with a here-document gap main shares |
š¤ Generated with Claude Code