Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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", mutant: "a rewritten text is read flat for the spelt reading's sessions" |
9 changes: 8 additions & 1 deletion tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -3168,6 +3171,10 @@ 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).
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), "")
Expand Down
8 changes: 5 additions & 3 deletions tools/hooks/protected.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, spelt=True) # a Claude Code session the first reading missed is asked about too (Muse)
return self.found

def asks_of(self, commands: list):
Expand Down Expand Up @@ -933,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
Expand All @@ -954,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")
Expand Down
9 changes: 9 additions & 0 deletions tools/mutants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14792,6 +14792,15 @@ 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, 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',)),
Expand Down
Loading