Hooks: a substitution nested in a double-quoted one hides no command after it, and a quote past a body's backtick is asked about (#605)
Closes #605, "Hooks: two readings left from #604 that match main's existing behaviour". It fixes items (d) and (g); (a), (b) and (c) were fixed in #643, and (e) and (f) are closed on the issue as not worth a change.
The protected edits to tools/hooks/ are pre-approved by the Maintainer 2026-11-08, ruled by the Factory Builder. Hooks are mistake guards (decision 0038's triage rule), and this PR is held to that standard.
What changes
| Item | Before | Now |
|---|---|---|
| (g): protected hook | Read a command as it stands, so a "$(...)" nested in a "$(...)" ended the outer quote. echo "$(echo "$(date)"; rm kernel/gate.py)" was let through | Reads the command as main does, then again with each substitution spelt as the identity hook spells one (identity._quoted). Each reading starts from where the command began, and the hook asks what either reading finds, once. A spelt reading that fails leaves main's reading to stand, and a command longer than RESPELT_MOST (8 KB) is read once |
| (g): a backtick in double quotes, both hooks | Read flat, so "echo "a b"; git push" hid the push | Spelt as a $(...) is, ending at its next backtick no backslash holds. One that never closes, or whose quote or comment would run past its end, is read as main reads it |
| (d): a here-document's substitution | A backtick whose quote ran past its end left the whole script as data, though the shell fails only the backtick and runs the rest | shell.Unread: the hooks ask, as they do outside a here-document. Backticked prose at a body's top level is still data |
Each spelling from the issue, and each one the reviewers found, is now asked about or refused. Across the 353 spellings the reviewers gathered, nothing main asked about or refused goes unasked. Three spellings that main's identity hook refused because it misread them are now read as bash reads them, and no command in them is lost.
Known, and left as they are:
- A body's
$(...)that holds backticked prose with an apostrophe is now refused. With an unquoted delimiter the shell does run it, and garbles it; a body's top level is unchanged. - A
casein a double-quoted backtick is refused, as main already refuses one in a"$(...)". for f inls *.py`` is asked about aspath_unreadable, as main already asks aboutfor f in $(ls *.py).
Checks
tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it,::test_a_backtick_in_double_quotes_has_quotes_of_its_ownand::test_a_quote_past_a_backtick_in_a_here_documents_substitution_is_not_read_as_data. Each failed on main.- Decision 0032 gains two rows, naming 16 mutants. One older mutant follows the code.
- At the head:
make verifypasses,check_decisionsis clean, and every mutant oftools/hooks/shell.py,identity.pyandprotected.pyis killed.
Reviews
All read the first head, be69b974. The fixes are in d604ac9f and the commits after it.
| Reader | Findings | Disposition |
|---|---|---|
| Argon (gemini-4-argon-high) | 2 blocking: an open quote or comment in a double-quoted backtick hid a later $(...), a regression; and a $(...) in a body's backtick was read flat | Both fixed, with tests |
| Muse | Not run (quota, 429), owed | ā |
| Astra, standing in for Muse | 3 major: no test for the escaped $; \ # in a body's backtick; a false refusal of "$(printf '%s' '"')" in one | All fixed, with tests and mutants |
| Claude review | 2 major regressions in the protected hook: _quoted giving up lost a body's commands, and a quote past an unquoted backtick went unasked; 2 minor | Both majors fixed by the two readings. The minors are the first two known items above |
| Claude security pass | 1 high and 1 medium: the same give-up path, in each hook; 1 medium: quadratic time in the second reading | All fixed. The second reading is bounded at 8 KB, where it costs about a second over main. Identity's own quadratic time on that shape is the same on main |
š¤ Generated with Claude Code