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)

Merged
DO
12h ago
+185-1

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

ItemBeforeNow
(g): protected hookRead a command as it stands, so a "$(...)" nested in a "$(...)" ended the outer quote. echo "$(echo "$(date)"; rm kernel/gate.py)" was let throughReads 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 hooksRead flat, so "echo "a b"; git push" hid the pushSpelt 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 substitutionA backtick whose quote ran past its end left the whole script as data, though the shell fails only the backtick and runs the restshell.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 case in a double-quoted backtick is refused, as main already refuses one in a "$(...)".
  • for f in ls *.py`` is asked about as path_unreadable, as main already asks about for 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_own and ::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 verify passes, check_decisions is clean, and every mutant of tools/hooks/shell.py, identity.py and protected.py is killed.

Reviews

All read the first head, be69b974. The fixes are in d604ac9f and the commits after it.

ReaderFindingsDisposition
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 flatBoth fixed, with tests
MuseNot run (quota, 429), owed—
Astra, standing in for Muse3 major: no test for the escaped $; \ # in a body's backtick; a false refusal of "$(printf '%s' '"')" in oneAll fixed, with tests and mutants
Claude review2 major regressions in the protected hook: _quoted giving up lost a body's commands, and a quote past an unquoted backtick went unasked; 2 minorBoth majors fixed by the two readings. The minors are the first two known items above
Claude security pass1 high and 1 medium: the same give-up path, in each hook; 1 medium: quadratic time in the second readingAll 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