Hooks: a backtick that opens inside a word is read as a $( is (#410)

Merged
DO
3d ago
+55-2

Part of #410. This PR fixes one item, a spelling the readers let through ("fails open"). The other items stay listed on the issue.

The fix

When an unquoted backtick opens inside a word, as in tee >./echo kernel/gate.py``, the reader took the operand to be ./. That names a folder, so the write was let through. tools/hooks/shell.py now reads such a word up to the backtick the same way it reads ./$(...): the operand becomes ./$. No hook can read that word, so the protected hook asks about the write.

The reader keeps a count of unquoted backticks outside a ${...}. Only a backtick that opens is changed, and only when it does not begin a word. A backtick that begins a word is read as before. A comment inside a backtick now ends at the closing backtick: the first one that no backslash holds. Before, it ran to the end of its line, which threw the count out of step (found in the Claude review).

Probe, through the protected hook

CommandBeforeNow
tee >./`echo kernel/gate.py` let throughasked
tee >x`echo y` let throughasked
echo `date #x` on one line, then echo `git push origin HEAD:main` on the nextasked (main)asked
x=`date`; echo "$x" > notes.txtlet throughlet through
echo `date` > notes.txt, echo a`date`b > notes.txtlet throughlet through
cat `ls *.md` > all.txtlet throughlet through

make -jnproc verify is now asked about, as -j$(nproc) already was.

Record

There is no new decision record. Decision 0032 gains one row, added and not edited: a backtick that opens inside a word ends what is read of the word there, as $( does, and a comment in a backtick ends at the backtick. The row is held by tests/test_hooks.py::test_a_backtick_glued_to_a_redirection_and_a_substitution_in_a_nested_expansion_are_read.

Mutants

The eight new rows in tools/mutants.py all target that test, and each was checked to fail it:

  • the $ is not added;
  • a backtick that closes is read as one that opens;
  • a backtick that begins a word is read as one inside a word;
  • a backtick that closes is not counted;
  • a comment in a backtick runs past it;
  • a backslash in such a comment does not hold the backtick after it;
  • a # that begins a backtick's script is read as a word;
  • a # after a backtick that closes is read as a comment.

Checks

  • make verify passes at 97e0c725.
  • At 97e0c725, every one of the 111 mutants of tools/hooks/shell.py is killed (three filtered runs of at most 40 each).
  • In the full python3 -m tools.mutants run at d55912eb, the baseline passed and 4157 mutants were killed with none surviving before it was stopped for Codex's fix. CI's shards run the full set.

Reviews

ReaderAtReadDisposition
Claudeb8a0632a2 minor: a comment in a backtick ate the closing backtick; the ticked = True mutant survivedBoth fixed in 605766ed
Argon (medium; high ran past its output budget)b8a0632a0 findingsClean
Codexd55912eb1 P1: an escaped backtick in a comment in a backtick was read as its end, which hid a later rm kernel/gate.pyFixed in 742ef0d2, with a test case and a mutant
Codex742ef0d21 P1: a # that begins a backtick's script was read as a word, which hid a later rm kernel/gate.pyFixed in 97e0c725, with two test cases and two mutants

Per the Builder, 97e0c725 is the last fix round. Any further spelling a reader misses goes into one follow-on issue and does not hold this PR. Only a hook that blocks legitimate work, or crashes, holds it.

Still open on #410

  • Words spelt by variables.
  • "${?+'$(…)'}", where bash and zsh disagree.

šŸ¤– Generated with Claude Code