From 237592da1da617712526b47637f1e75ded6ac0cb Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:57:22 -0600 Subject: [PATCH 1/2] Hooks: Muse's owed reads of #643 and #652, a comment in a nested backtick is tested and the spelt reading asks about a session Muse read #643 and #652 after they merged (the merged-early rule). On #643: the line that ends a nested backtick at a comment's backtick had no test that killed it; the spelling `echo "$(echo "`echo #"`" ...)"` now holds it, with a mutant. Its two other findings, braces reset and restored across a nested substitution, cannot be reached from _held: a ${...} in a held substitution is refused at its start or skipped whole, so _inside never begins inside a nested one with braces open. On #652: the protected hook's spelt reading never reached sessions(), so a Claude Code session after a nested substitution was not asked about; it is now, with a test and a mutant. Its two evasion-only findings are #665. Co-Authored-By: Claude Opus 5.5 --- ...ook-reads-a-write-where-and-when-the-shell-makes-it.md | 1 + tests/test_hooks.py | 8 +++++++- tools/hooks/protected.py | 1 + tools/mutants.py | 6 ++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md b/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md index d2f1676e..5cba36ff 100644 --- a/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md +++ b/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md @@ -70,3 +70,4 @@ into a message's substitution. | Whether a command defines a function of a name that only prints is searched once a name, not once a command, so a command of many short ones is read in linear time | `tests/test_hooks.py::test_whether_a_command_defines_a_printers_name_is_searched_once_a_name`, `tools/hooks/identity.py`, mutant: "each printer's name is searched for again", mutant: "each command that prints searches the whole text again" | | A double-quoted substitution or backtick nested in a double-quoted substitution has quotes of its own, to the protected hook as to the identity hook: its quotes do not end the outer one, so a command after it is read. The protected hook reads the command as it stands and again so spelt, each from where the command began, and asks what either reading finds; a spelt reading that fails, or one of a command longer than it reads again, leaves the first to stand. A backtick in double quotes is spelt as a `$(...)` is: it ends at its next backtick no backslash holds, and a backslash before a double quote, a backtick, a dollar sign or a backslash in it is taken out; one that never closes, or whose quote or comment would run past its end, is read as it stands | `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tests/test_hooks.py::test_a_backtick_in_double_quotes_has_quotes_of_its_own`, `tools/hooks/protected.py`, `tools/hooks/identity.py`, mutant: "the protected hook reads a command once, as it stands", mutant: "the protected hook's second reading begins where the first ended", mutant: "a spelt reading that fails is the protected hook's failure", mutant: "a command of any length is read again, spelt", mutant: "a process substitution is not spelt again", mutant: "a backtick in double quotes is read flat", mutant: "a backtick's escaped double quote in double quotes keeps its backslash", mutant: "a backtick's escaped dollar in double quotes keeps its backslash", mutant: "a backtick in double quotes that never closes is spelt", mutant: "a backtick in double quotes whose quote or comment runs past it is spelt" | | In a here-document's substitution, a backtick whose quote is still open at its end is a script the hook cannot read, not data: the hooks ask, as they do outside a here-document. A quote in a comment there is none, nor one in a `$(...)` in it, which has quotes of its own; a # after a character a backslash holds begins no comment | `tests/test_hooks.py::test_a_quote_past_a_backtick_in_a_here_documents_substitution_is_not_read_as_data`, `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tools/hooks/shell.py`, mutant: "a quote past a backtick in a body's substitution is data", mutant: "a quote in a comment in a body's backtick is counted", mutant: "a # after a character a backslash holds in a body's backtick begins a comment", mutant: "a substitution in a body's backtick is scanned flat", mutant: "a backslash in a body's backtick holds no quote", mutant: "a backslash in single quotes in a body's backtick holds a quote" | +| A comment in a backtick nested in a held substitution ends that backtick, so the held one's quote is read past it; and the protected hook's second, spelt reading asks about a Claude Code session started with no one asked, as the first does | `tests/test_hooks.py::test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them`, `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tools/hooks/shell.py`, `tools/hooks/protected.py`, mutant: "a comment in a nested backtick leaves the backtick's end where it was", mutant: "the protected hook's spelt reading asks about no session" | diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 30ca0c98..7c2bf6cf 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1593,7 +1593,10 @@ def test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them(self): # i # comment, and a comment in a backtick ends at it (Argon). 'echo "$(echo \\ # "' + rm + '"\n)"', 'echo "`# "\necho "' + rm + '"`"', - 'echo "$(echo "`echo ${x:-\'a\'} #` ' + rm + '")"'): + 'echo "$(echo "`echo ${x:-\'a\'} #` ' + rm + '")"', + # A comment in a nested backtick ends the backtick, so the held one's quote is read past it (Muse's + # read of #643). + 'echo "$(echo "`echo #"`" "' + rm + '")"'): with self.assertRaises(shell.Unread, msg=text): shell.commands(text) # One that a nested substitution's own quotes leave unquoted is read as before. @@ -3168,6 +3171,9 @@ def test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_af self.assertEqual(self.run_('x="`case $1 in a) echo 1;; esac`"'), "") self.assertEqual(self.run_('touch x.json; cd lifecycle; echo "$(echo "$(date)")"'), "") self.assertEqual(self.run_('rm kernel/gate.py; echo "$(echo "$(date)"; true)"').count("protected_path"), 1) + # A Claude Code session after a nested one is asked about, as a write is (Muse's read of #652). + self.asked(self.run_('echo "$(echo "$(date)"; claude --dangerously-skip-permissions -p hi)"'), "unasked_session", + "a session after a nested substitution") # Past RESPELT_MOST characters the command is read once, as main reads it, in linear time (the security pass). long = 'echo %s; echo "$(echo "$(date)"; rm kernel/gate.py)"' % ("a" * protected.RESPELT_MOST) self.assertEqual(self.run_(long), "") diff --git a/tools/hooks/protected.py b/tools/hooks/protected.py index da172ed0..843dad3c 100644 --- a/tools/hooks/protected.py +++ b/tools/hooks/protected.py @@ -528,6 +528,7 @@ def read(self) -> list: # either asks about is asked (check gives each ask once). self.here = start self.asks_of(again) + self.sessions(again) # a Claude Code session the first reading missed is asked about too (Muse) return self.found def asks_of(self, commands: list): diff --git a/tools/mutants.py b/tools/mutants.py index 2964fd16..ebfa123b 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -14792,6 +14792,12 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: 'elif c == "#" and not braces and j != escaped and (', 'elif c == "#" and not braces and (', (IDENTITY_TESTS + 'test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them',)), ("a # that begins a backtick's script is a word", 'tools/hooks/shell.py', 'text[j - 1] in COMMENTS_AFTER or j - 1 == script):', 'text[j - 1] in COMMENTS_AFTER):', (IDENTITY_TESTS + 'test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them',)), + ("a comment in a nested backtick leaves the backtick's end where it was", 'tools/hooks/shell.py', + ' nested[-1] = (min(nested[-1][0], close + 1),) + nested[-1][1:]', ' pass', + (IDENTITY_TESTS + 'test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them',)), + ("the protected hook's spelt reading asks about no session", 'tools/hooks/protected.py', + ' self.sessions(again) # a Claude Code session', ' pass # a Claude Code session', + (PROTECTED_NESTED_IN_ONE,)), ("a comment in a backtick in a substitution runs past it", 'tools/hooks/shell.py', ' if text[script] == "`": # a comment in a backtick', ' if False: # a comment in a backtick', (IDENTITY_TESTS + 'test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them',)), From bd4834de3cfded1d53e41fec3ab970a4ac0697c8 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:41:39 -0600 Subject: [PATCH 2/2] Hooks: with $(which claude) in the command, the spelt reading's sessions read the rewritten text spelt (Muse, the review) sessions() read a text WHICH rewrote flat, whatever reading it was given, so with $(which claude) anywhere in the command the spelt reading's session after a nested substitution went unasked. It reads it spelt for the spelt reading, with a test and a mutant. This closes #665's item (c). Co-Authored-By: Claude Opus 5.5 --- ...ok-reads-a-write-where-and-when-the-shell-makes-it.md | 2 +- tests/test_hooks.py | 5 +++-- tools/hooks/protected.py | 9 +++++---- tools/mutants.py | 5 ++++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md b/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md index 5cba36ff..f9557a7d 100644 --- a/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md +++ b/docs/decisions/0032-the-identity-hook-reads-a-write-where-and-when-the-shell-makes-it.md @@ -70,4 +70,4 @@ into a message's substitution. | Whether a command defines a function of a name that only prints is searched once a name, not once a command, so a command of many short ones is read in linear time | `tests/test_hooks.py::test_whether_a_command_defines_a_printers_name_is_searched_once_a_name`, `tools/hooks/identity.py`, mutant: "each printer's name is searched for again", mutant: "each command that prints searches the whole text again" | | A double-quoted substitution or backtick nested in a double-quoted substitution has quotes of its own, to the protected hook as to the identity hook: its quotes do not end the outer one, so a command after it is read. The protected hook reads the command as it stands and again so spelt, each from where the command began, and asks what either reading finds; a spelt reading that fails, or one of a command longer than it reads again, leaves the first to stand. A backtick in double quotes is spelt as a `$(...)` is: it ends at its next backtick no backslash holds, and a backslash before a double quote, a backtick, a dollar sign or a backslash in it is taken out; one that never closes, or whose quote or comment would run past its end, is read as it stands | `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tests/test_hooks.py::test_a_backtick_in_double_quotes_has_quotes_of_its_own`, `tools/hooks/protected.py`, `tools/hooks/identity.py`, mutant: "the protected hook reads a command once, as it stands", mutant: "the protected hook's second reading begins where the first ended", mutant: "a spelt reading that fails is the protected hook's failure", mutant: "a command of any length is read again, spelt", mutant: "a process substitution is not spelt again", mutant: "a backtick in double quotes is read flat", mutant: "a backtick's escaped double quote in double quotes keeps its backslash", mutant: "a backtick's escaped dollar in double quotes keeps its backslash", mutant: "a backtick in double quotes that never closes is spelt", mutant: "a backtick in double quotes whose quote or comment runs past it is spelt" | | In a here-document's substitution, a backtick whose quote is still open at its end is a script the hook cannot read, not data: the hooks ask, as they do outside a here-document. A quote in a comment there is none, nor one in a `$(...)` in it, which has quotes of its own; a # after a character a backslash holds begins no comment | `tests/test_hooks.py::test_a_quote_past_a_backtick_in_a_here_documents_substitution_is_not_read_as_data`, `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tools/hooks/shell.py`, mutant: "a quote past a backtick in a body's substitution is data", mutant: "a quote in a comment in a body's backtick is counted", mutant: "a # after a character a backslash holds in a body's backtick begins a comment", mutant: "a substitution in a body's backtick is scanned flat", mutant: "a backslash in a body's backtick holds no quote", mutant: "a backslash in single quotes in a body's backtick holds a quote" | -| A comment in a backtick nested in a held substitution ends that backtick, so the held one's quote is read past it; and the protected hook's second, spelt reading asks about a Claude Code session started with no one asked, as the first does | `tests/test_hooks.py::test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them`, `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tools/hooks/shell.py`, `tools/hooks/protected.py`, mutant: "a comment in a nested backtick leaves the backtick's end where it was", mutant: "the protected hook's spelt reading asks about no session" | +| A comment in a backtick nested in a held substitution ends that backtick, so the held one's quote is read past it; and the protected hook's second, spelt reading asks about a Claude Code session started with no one asked, as the first does | `tests/test_hooks.py::test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them`, `tests/test_hooks.py::test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_after_it`, `tools/hooks/shell.py`, `tools/hooks/protected.py`, mutant: "a comment in a nested backtick leaves the backtick's end where it was", mutant: "the protected hook's spelt reading asks about no session", mutant: "a rewritten text is read flat for the spelt reading's sessions" | diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 7c2bf6cf..e5dd8911 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -3172,8 +3172,9 @@ def test_a_double_quoted_substitution_in_a_double_quoted_one_hides_no_command_af self.assertEqual(self.run_('touch x.json; cd lifecycle; echo "$(echo "$(date)")"'), "") self.assertEqual(self.run_('rm kernel/gate.py; echo "$(echo "$(date)"; true)"').count("protected_path"), 1) # A Claude Code session after a nested one is asked about, as a write is (Muse's read of #652). - self.asked(self.run_('echo "$(echo "$(date)"; claude --dangerously-skip-permissions -p hi)"'), "unasked_session", - "a session after a nested substitution") + for command in ('echo "$(echo "$(date)"; claude --dangerously-skip-permissions -p hi)"', + 'echo $(which claude); echo "$(echo "$(date)"; claude --dangerously-skip-permissions -p hi)"'): + self.asked(self.run_(command), "unasked_session", command) # Past RESPELT_MOST characters the command is read once, as main reads it, in linear time (the security pass). long = 'echo %s; echo "$(echo "$(date)"; rm kernel/gate.py)"' % ("a" * protected.RESPELT_MOST) self.assertEqual(self.run_(long), "") diff --git a/tools/hooks/protected.py b/tools/hooks/protected.py index 843dad3c..b689fe6a 100644 --- a/tools/hooks/protected.py +++ b/tools/hooks/protected.py @@ -528,7 +528,7 @@ def read(self) -> list: # either asks about is asked (check gives each ask once). self.here = start self.asks_of(again) - self.sessions(again) # a Claude Code session the first reading missed is asked about too (Muse) + self.sessions(again, spelt=True) # a Claude Code session the first reading missed is asked about too (Muse) return self.found def asks_of(self, commands: list): @@ -934,7 +934,7 @@ def github(self, words: list, redirections: list): # -- a session that asks nobody - def sessions(self, commands: list): + def sessions(self, commands: list, spelt: bool = False): """Ask about each command that runs Claude Code with no one asked (decision 0021). Claude Code runs when the program is one config/hooks.json names, by its name, its path or its package, with @@ -955,8 +955,9 @@ def sessions(self, commands: list): # $(which claude) and `which claude` stand for Claude Code's path: read each as the name it looks up. text = shell.WHICH.sub(lambda m: m.group(1) if self._claude(m.group(1), claude) else m.group(0), self.text) try: - listed = [self._expanded(words) for words, _ in (_split(text, self.spend) if text != self.text - else commands) if words] + # Rewritten, the text is read again as `commands` was read: flat, or spelt (Muse on the #652 follow-on). + again = (_respelt(text, self.spend) if spelt else _split(text, self.spend)) if text != self.text else commands + listed = [self._expanded(words) for words, _ in again if words] functions = shell.functions(text, self.spend) # in each script it runs, too except (ValueError, RecursionError): self.ask("command_unreadable", "the command cannot be split into words") diff --git a/tools/mutants.py b/tools/mutants.py index ebfa123b..813b5477 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -14796,8 +14796,11 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ' nested[-1] = (min(nested[-1][0], close + 1),) + nested[-1][1:]', ' pass', (IDENTITY_TESTS + 'test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them',)), ("the protected hook's spelt reading asks about no session", 'tools/hooks/protected.py', - ' self.sessions(again) # a Claude Code session', ' pass # a Claude Code session', + ' self.sessions(again, spelt=True) # a Claude Code session', ' pass # a Claude Code session', (PROTECTED_NESTED_IN_ONE,)), + ("a rewritten text is read flat for the spelt reading's sessions", 'tools/hooks/protected.py', + ' again = (_respelt(text, self.spend) if spelt else _split(text, self.spend)) if text != self.text else commands', + ' again = _split(text, self.spend) if text != self.text else commands', (PROTECTED_NESTED_IN_ONE,)), ("a comment in a backtick in a substitution runs past it", 'tools/hooks/shell.py', ' if text[script] == "`": # a comment in a backtick', ' if False: # a comment in a backtick', (IDENTITY_TESTS + 'test_a_substitutions_own_quotes_are_read_as_the_shell_reads_them',)),