Hooks: a quote past its backtick's end, and a ' in a double-quoted ${...}, are asked about (#579)

Merged
DO
2d ago
+156-1

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.

  1. 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.py was hidden in a word. Codex's example from #578 now asks. A quote that closes inside its backtick is read as before.
  2. 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 the rm.
    • 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.

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. main does the same.

Probe (both hooks)

Commandmainthis branch
Codex's quote past a backtick, then rm kernel/gate.pylet throughprotected asks
echo "$(echo "${?+'$(rm kernel/gate.py)'}")"let throughprotected asks, identity refuses
echo "${x:-'default'}" > notes.txtreadasked
cat <<EOF around $(echo "${x:-'default'}"; rm kernel/gate.py)protected asksprotected asks
echo "$x" ${y:-'z'}, git commit -m "msg" && echo ${FOO:-'x'}, echo "$(echo ${x:-'default'})", echo "`date`"; echo ${y:-'z'}readread
echo "${x:-default}", git log --format='%H', backtick scripts whose quotes close, heredocsreadread

Checks

  • make verify passes at bdf73d61, run alone on an export.
  • All 128 mutants of tools/hooks/shell.py are killed, in filtered runs of at most 40.

Reviews

ReaderAtReadDisposition
Claude2c8ff27aBlocker: 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 characterBoth fixed in 53633b3b
Astra2c8ff27a2 major: only a double quote was tested past a backtick; an outer ${...} skipped the checkBoth fixed in 53633b3b
verify53633b3bThe linear-time test failed: each held substitution's end was read as it openedFixed in c4c17d3f: its end is read only when needed
Astrac4c17d3f2 major: a held backtick was untested; echo "$(echo ${x:-'default'})" was asked aboutBoth fixed in 8850ea36
Codexc4c17d3fP2: 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 heldBoth fixed in 5204ac8c
CI5204ac8cDecision 0032's new row named no mutant (rule from #594)Fixed in e5221190
Codexe5221190P2: each ${...} re-read every held substitution, closed ones tooFixed in 56cb4029
Astrae5221190Blocking: a " in a nested comment is counted. Major: the same quadratic as Codex'sThe quadratic is fixed in 56cb4029. The comment case is code written to hide; proposed for a follow-up issue
Codex56cb4029P1: 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 endBoth fixed in b4cc2018
Astrab4cc2018Blocking: 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 UnreadThe 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