Hooks: a backtick that opens inside a word is read as a $( is (#410)
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
| Command | Before | Now |
|---|---|---|
tee >./`echo kernel/gate.py` | let through | asked |
tee >x`echo y` | let through | asked |
echo `date #x` on one line, then echo `git push origin HEAD:main` on the next | asked (main) | asked |
x=`date`; echo "$x" > notes.txt | let through | let through |
echo `date` > notes.txt, echo a`date`b > notes.txt | let through | let through |
cat `ls *.md` > all.txt | let through | let 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 verifypasses at 97e0c725.- At 97e0c725, every one of the 111 mutants of
tools/hooks/shell.pyis killed (three filtered runs of at most 40 each). - In the full
python3 -m tools.mutantsrun 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
| Reader | At | Read | Disposition |
|---|---|---|---|
| Claude | b8a0632a | 2 minor: a comment in a backtick ate the closing backtick; the ticked = True mutant survived | Both fixed in 605766ed |
| Argon (medium; high ran past its output budget) | b8a0632a | 0 findings | Clean |
| Codex | d55912eb | 1 P1: an escaped backtick in a comment in a backtick was read as its end, which hid a later rm kernel/gate.py | Fixed in 742ef0d2, with a test case and a mutant |
| Codex | 742ef0d2 | 1 P1: a # that begins a backtick's script was read as a word, which hid a later rm kernel/gate.py | Fixed 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