claude-dev-env 8.32.2 → 8.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/advisory/pr_done_reminder.py +16 -75
- package/hooks/advisory/test_pr_done_reminder.py +19 -0
- package/hooks/blocking/test_verify_before_acting.py +655 -0
- package/hooks/blocking/verify_before_acting.py +323 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +78 -6
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -8
- package/hooks/hooks_constants/pytest_invocation.py +60 -22
- package/hooks/hooks_constants/shell_command_mutation.py +202 -0
- package/hooks/hooks_constants/shell_command_segments.py +39 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +68 -0
- package/hooks/hooks_constants/test_pr_done_reminder_constants.py +0 -11
- package/hooks/hooks_constants/test_pytest_invocation.py +6 -0
- package/hooks/hooks_constants/test_shell_command_mutation.py +37 -0
- package/hooks/hooks_constants/test_shell_command_segments.py +29 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +61 -0
- package/hooks/hooks_constants/verify_before_acting_constants.py +170 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +4 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Decide whether a shell command writes, sends, or changes state.
|
|
2
|
+
|
|
3
|
+
Every command string a wrapper runs is scanned, and each segment is read as
|
|
4
|
+
its program plus arguments after the wrappers in front of it::
|
|
5
|
+
|
|
6
|
+
sudo git push writes
|
|
7
|
+
bash -c "gh pr merge 12" writes
|
|
8
|
+
pwsh -Command git push; echo done writes
|
|
9
|
+
rg 'gh pr merge' docs reads
|
|
10
|
+
grep "a|cp b" notes.md reads
|
|
11
|
+
|
|
12
|
+
A write counts only when the POSIX tokenization and the raw tokenization of a
|
|
13
|
+
line agree on it, so a quoted literal never reads as a command.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import itertools
|
|
19
|
+
|
|
20
|
+
from hooks_constants.pytest_invocation import unquoted_token
|
|
21
|
+
from hooks_constants.shell_command_pipeline import (
|
|
22
|
+
all_operator_aware_tokenizations,
|
|
23
|
+
join_line_continuations,
|
|
24
|
+
scannable_command_lines,
|
|
25
|
+
segments_with_following_operator,
|
|
26
|
+
)
|
|
27
|
+
from hooks_constants.shell_command_segments import git_subcommand_tokens, token_basename
|
|
28
|
+
from hooks_constants.shell_command_wrappers import (
|
|
29
|
+
all_wrapped_command_texts,
|
|
30
|
+
segment_program_and_arguments,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.verify_before_acting_constants import (
|
|
33
|
+
ALL_FILE_WRITING_PROGRAM_NAMES,
|
|
34
|
+
ALL_GH_API_METHOD_OPTIONS,
|
|
35
|
+
ALL_HTTP_WRITE_METHODS,
|
|
36
|
+
ALL_MUTATING_GH_SUBCOMMANDS,
|
|
37
|
+
ALL_MUTATING_GIT_SUBCOMMAND_PREFIXES,
|
|
38
|
+
ALL_NON_FILE_REDIRECTION_TARGETS,
|
|
39
|
+
ALL_POWERSHELL_WRITE_CMDLET_NAMES,
|
|
40
|
+
ALL_PULL_REQUEST_SCRIPT_WRITE_ACTIONS,
|
|
41
|
+
ALL_WRITE_REDIRECTION_OPERATORS,
|
|
42
|
+
DESCRIPTOR_CLOSE_TARGET,
|
|
43
|
+
DESCRIPTOR_DUPLICATION_OPERATOR,
|
|
44
|
+
GH_API_ATTACHED_METHOD_PATTERN,
|
|
45
|
+
GH_API_FIELD_OPTION_PATTERN,
|
|
46
|
+
GH_API_SUBCOMMAND,
|
|
47
|
+
GH_PROGRAM_NAME,
|
|
48
|
+
GH_SUBCOMMAND_DEPTH,
|
|
49
|
+
GIT_PROGRAM_NAME,
|
|
50
|
+
POWERSHELL_SCRIPT_BLOCK_OPENER,
|
|
51
|
+
POWERSHELL_WORD_BRACKETS,
|
|
52
|
+
PULL_REQUEST_SCRIPT_NAME,
|
|
53
|
+
REDIRECTION_TARGET_QUOTES,
|
|
54
|
+
SED_IN_PLACE_OPTION_PATTERN,
|
|
55
|
+
SED_PROGRAM_NAME,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = ["is_mutating_shell_command"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_mutating_git_arguments(all_arguments: list[str]) -> bool:
|
|
62
|
+
all_subcommand_tokens = git_subcommand_tokens(all_arguments)
|
|
63
|
+
return any(
|
|
64
|
+
tuple(all_subcommand_tokens[: len(each_prefix)]) == each_prefix
|
|
65
|
+
for each_prefix in ALL_MUTATING_GIT_SUBCOMMAND_PREFIXES
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _gh_api_method(all_api_arguments: list[str]) -> str | None:
|
|
70
|
+
for each_option, each_method_argument in itertools.pairwise(all_api_arguments):
|
|
71
|
+
if each_option in ALL_GH_API_METHOD_OPTIONS:
|
|
72
|
+
return each_method_argument.upper()
|
|
73
|
+
for each_argument in all_api_arguments:
|
|
74
|
+
attached_match = GH_API_ATTACHED_METHOD_PATTERN.fullmatch(each_argument)
|
|
75
|
+
if attached_match is not None:
|
|
76
|
+
return attached_match.group("method").upper()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_mutating_gh_api(all_api_arguments: list[str]) -> bool:
|
|
81
|
+
"""Return True when ``gh api`` sends a write method, or fields or ``--input`` with no method named."""
|
|
82
|
+
method = _gh_api_method(all_api_arguments)
|
|
83
|
+
if method is not None:
|
|
84
|
+
return method in ALL_HTTP_WRITE_METHODS
|
|
85
|
+
return any(
|
|
86
|
+
GH_API_FIELD_OPTION_PATTERN.fullmatch(each_argument) for each_argument in all_api_arguments
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_mutating_gh_arguments(all_arguments: list[str]) -> bool:
|
|
91
|
+
if tuple(all_arguments[:GH_SUBCOMMAND_DEPTH]) in ALL_MUTATING_GH_SUBCOMMANDS:
|
|
92
|
+
return True
|
|
93
|
+
return all_arguments[:1] == [GH_API_SUBCOMMAND] and _is_mutating_gh_api(all_arguments[1:])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _program_mutates(program_name: str, all_arguments: list[str]) -> bool:
|
|
97
|
+
if program_name in ALL_FILE_WRITING_PROGRAM_NAMES:
|
|
98
|
+
return True
|
|
99
|
+
if program_name == SED_PROGRAM_NAME:
|
|
100
|
+
return any(
|
|
101
|
+
SED_IN_PLACE_OPTION_PATTERN.fullmatch(each_argument) for each_argument in all_arguments
|
|
102
|
+
)
|
|
103
|
+
if program_name == GIT_PROGRAM_NAME:
|
|
104
|
+
return _is_mutating_git_arguments(all_arguments)
|
|
105
|
+
return program_name == GH_PROGRAM_NAME and _is_mutating_gh_arguments(all_arguments)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _runs_a_pull_request_script_write(all_segment_tokens: list[str]) -> bool:
|
|
109
|
+
for each_index, each_token in enumerate(all_segment_tokens):
|
|
110
|
+
if token_basename(unquoted_token(each_token)) == PULL_REQUEST_SCRIPT_NAME:
|
|
111
|
+
return any(
|
|
112
|
+
unquoted_token(each_later_token) in ALL_PULL_REQUEST_SCRIPT_WRITE_ACTIONS
|
|
113
|
+
for each_later_token in all_segment_tokens[each_index + 1 :]
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _powershell_command_words(program_name: str, all_segment_tokens: list[str]) -> list[str]:
|
|
119
|
+
"""Return the words a segment runs as commands: its program and each script block's first word.
|
|
120
|
+
|
|
121
|
+
``ForEach-Object { Remove-Item $_ }`` runs ``Remove-Item`` as a command, while
|
|
122
|
+
``Get-Command Remove-Item`` passes it as an argument.
|
|
123
|
+
"""
|
|
124
|
+
all_command_words = [program_name]
|
|
125
|
+
for each_previous_token, each_token in itertools.pairwise(all_segment_tokens):
|
|
126
|
+
if each_previous_token.endswith(POWERSHELL_SCRIPT_BLOCK_OPENER):
|
|
127
|
+
all_command_words.append(each_token)
|
|
128
|
+
all_command_words.extend(
|
|
129
|
+
each_token.rpartition(POWERSHELL_SCRIPT_BLOCK_OPENER)[2]
|
|
130
|
+
for each_token in all_segment_tokens
|
|
131
|
+
if POWERSHELL_SCRIPT_BLOCK_OPENER in each_token
|
|
132
|
+
)
|
|
133
|
+
return all_command_words
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _runs_a_powershell_write_cmdlet(program_name: str, all_segment_tokens: list[str]) -> bool:
|
|
137
|
+
return any(
|
|
138
|
+
each_word.strip(POWERSHELL_WORD_BRACKETS).lower() in ALL_POWERSHELL_WRITE_CMDLET_NAMES
|
|
139
|
+
for each_word in _powershell_command_words(program_name, all_segment_tokens)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _redirect_target_is_a_file(operator: str, target: str) -> bool:
|
|
144
|
+
"""Return True when a redirect writes a file.
|
|
145
|
+
|
|
146
|
+
A digit or ``-`` after ``>&`` duplicates or closes a descriptor, as in
|
|
147
|
+
``2>&1`` and ``>&-``. After any other operator it names a file, so
|
|
148
|
+
``echo hi > 1`` writes one.
|
|
149
|
+
"""
|
|
150
|
+
target_text = target.strip(REDIRECTION_TARGET_QUOTES).lower()
|
|
151
|
+
if target_text in ALL_NON_FILE_REDIRECTION_TARGETS:
|
|
152
|
+
return False
|
|
153
|
+
return not (
|
|
154
|
+
operator == DESCRIPTOR_DUPLICATION_OPERATOR
|
|
155
|
+
and (target_text.isdigit() or target_text == DESCRIPTOR_CLOSE_TARGET)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _segment_redirects_into_a_file(all_segment_tokens: list[str]) -> bool:
|
|
160
|
+
return any(
|
|
161
|
+
each_operator in ALL_WRITE_REDIRECTION_OPERATORS
|
|
162
|
+
and _redirect_target_is_a_file(each_operator, each_target)
|
|
163
|
+
for each_operator, each_target in itertools.pairwise(all_segment_tokens)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _segment_is_mutating(all_segment_tokens: list[str]) -> bool:
|
|
168
|
+
program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
169
|
+
return (
|
|
170
|
+
_program_mutates(program_name, all_arguments)
|
|
171
|
+
or _runs_a_pull_request_script_write(all_segment_tokens)
|
|
172
|
+
or _runs_a_powershell_write_cmdlet(program_name, all_segment_tokens)
|
|
173
|
+
or _segment_redirects_into_a_file(all_segment_tokens)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _line_is_mutating(command_line: str) -> bool:
|
|
178
|
+
"""Return True when every quote-aware tokenization of the line has a mutating segment.
|
|
179
|
+
|
|
180
|
+
The POSIX tokenization strips the quotes from ``rg 'Remove-Item' docs`` and
|
|
181
|
+
the raw one splits ``--format='%h > %s'`` mid-quote, so a write counts only
|
|
182
|
+
when both spellings agree on it.
|
|
183
|
+
"""
|
|
184
|
+
all_tokenizations = all_operator_aware_tokenizations(command_line)
|
|
185
|
+
return bool(all_tokenizations) and all(
|
|
186
|
+
any(
|
|
187
|
+
_segment_is_mutating(each_segment)
|
|
188
|
+
for each_segment, _following_operator in segments_with_following_operator(
|
|
189
|
+
each_tokenization
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
for each_tokenization in all_tokenizations
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_mutating_shell_command(command: str) -> bool:
|
|
197
|
+
"""Return True when any line of the command, or of a command string it wraps, writes."""
|
|
198
|
+
return any(
|
|
199
|
+
_line_is_mutating(each_line)
|
|
200
|
+
for each_command_text in all_wrapped_command_texts(command)
|
|
201
|
+
for each_line in scannable_command_lines(join_line_continuations(each_command_text))
|
|
202
|
+
)
|
|
@@ -14,21 +14,28 @@ bodies dropped, parenthesis groups joined — lives in
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import re
|
|
17
|
+
import shlex
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
19
20
|
"ALL_LAUNCHER_WRAPPER_COMMANDS",
|
|
20
21
|
"ALL_SHELL_CONTROL_OPERATOR_TOKENS",
|
|
22
|
+
"ALL_GIT_GLOBAL_OPTIONS_WITH_VALUE",
|
|
21
23
|
"CONTROL_OPERATOR_SPLIT_PATTERN",
|
|
22
24
|
"LEADING_ASSIGNMENT_PATTERN",
|
|
23
25
|
"LAUNCHER_DURATION_PATTERN",
|
|
24
26
|
"token_basename",
|
|
25
27
|
"split_into_segments",
|
|
26
28
|
"effective_leading_program",
|
|
29
|
+
"command_tokens",
|
|
30
|
+
"git_subcommand_tokens",
|
|
27
31
|
]
|
|
28
32
|
|
|
29
33
|
ALL_LAUNCHER_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
30
34
|
{"timeout", "nohup", "nice", "stdbuf", "setsid", "env", "time"}
|
|
31
35
|
)
|
|
36
|
+
ALL_GIT_GLOBAL_OPTIONS_WITH_VALUE: frozenset[str] = frozenset(
|
|
37
|
+
{"-C", "-c", "--git-dir", "--work-tree", "--namespace"}
|
|
38
|
+
)
|
|
32
39
|
ALL_SHELL_CONTROL_OPERATOR_TOKENS: frozenset[str] = frozenset(
|
|
33
40
|
{"&&", "||", ";", "|", "&", "|&"}
|
|
34
41
|
)
|
|
@@ -85,3 +92,35 @@ def effective_leading_program(all_segment_tokens: list[str]) -> str | None:
|
|
|
85
92
|
continue
|
|
86
93
|
return each_token
|
|
87
94
|
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def command_tokens(command: str) -> list[str]:
|
|
98
|
+
"""Return the POSIX shlex tokens of a command, or its whitespace split when quoting is unbalanced."""
|
|
99
|
+
try:
|
|
100
|
+
return shlex.split(command, posix=True)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return command.split()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def git_subcommand_tokens(all_arguments: list[str]) -> list[str]:
|
|
106
|
+
"""Return git's subcommand and the tokens after it, past git's global options.
|
|
107
|
+
|
|
108
|
+
``git -C <path> push`` and ``git --git-dir <path> push`` carry an option
|
|
109
|
+
value before the subcommand; ``--git-dir=<path>`` and ``--no-pager`` carry
|
|
110
|
+
none. The first token left after those starts the result, so
|
|
111
|
+
``git stash push`` yields ``["stash", "push"]`` and ``git log --grep push``
|
|
112
|
+
yields ``["log", "--grep", "push"]``. A command with no subcommand yields
|
|
113
|
+
an empty list.
|
|
114
|
+
"""
|
|
115
|
+
should_skip_next_token = False
|
|
116
|
+
for each_index, each_argument in enumerate(all_arguments):
|
|
117
|
+
if should_skip_next_token:
|
|
118
|
+
should_skip_next_token = False
|
|
119
|
+
continue
|
|
120
|
+
if each_argument in ALL_GIT_GLOBAL_OPTIONS_WITH_VALUE:
|
|
121
|
+
should_skip_next_token = True
|
|
122
|
+
continue
|
|
123
|
+
if each_argument.startswith("-"):
|
|
124
|
+
continue
|
|
125
|
+
return all_arguments[each_index:]
|
|
126
|
+
return []
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Step over the wrappers in front of a shell command to reach the command it runs.
|
|
2
|
+
|
|
3
|
+
A wrapper either runs its own operands, as ``sudo``, ``xargs``, ``env``, and
|
|
4
|
+
``timeout`` do, or runs a command string, as ``bash -c``, ``sh -lc``, and
|
|
5
|
+
``pwsh -Command`` do::
|
|
6
|
+
|
|
7
|
+
sudo git push git, ["push"]
|
|
8
|
+
env GIT_DIR=x git push git, ["push"]
|
|
9
|
+
echo origin | xargs git push git, ["push"]
|
|
10
|
+
bash -lc "git commit -F m.txt" "git commit -F m.txt" unwraps in turn
|
|
11
|
+
|
|
12
|
+
``pytest_invocation`` owns the wrapper and string-exec parsing, and this module
|
|
13
|
+
composes it into the two answers a hook asks about any program.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from hooks_constants.piped_pytest_blocker_constants import WINDOWS_EXECUTABLE_SUFFIX
|
|
19
|
+
from hooks_constants.pytest_invocation import (
|
|
20
|
+
all_tokens_after_wrappers,
|
|
21
|
+
string_exec_inner_command,
|
|
22
|
+
unquoted_token,
|
|
23
|
+
)
|
|
24
|
+
from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
|
|
25
|
+
from hooks_constants.shell_command_segments import (
|
|
26
|
+
effective_leading_program,
|
|
27
|
+
token_basename,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"segment_program_and_arguments",
|
|
32
|
+
"all_wrapped_command_texts",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def segment_program_and_arguments(all_segment_tokens: list[str]) -> tuple[str, list[str]]:
|
|
37
|
+
"""Return a segment's program basename past its wrappers and the tokens after it.
|
|
38
|
+
|
|
39
|
+
The basename drops a ``.exe`` suffix, so ``git.exe push`` reads as ``git``.
|
|
40
|
+
Returns an empty pair when the segment names no program.
|
|
41
|
+
"""
|
|
42
|
+
all_unwrapped_tokens = all_tokens_after_wrappers(all_segment_tokens)
|
|
43
|
+
program_token = effective_leading_program(all_unwrapped_tokens)
|
|
44
|
+
if program_token is None:
|
|
45
|
+
return "", []
|
|
46
|
+
program_index = all_unwrapped_tokens.index(program_token)
|
|
47
|
+
program_name = token_basename(unquoted_token(program_token)).removesuffix(
|
|
48
|
+
WINDOWS_EXECUTABLE_SUFFIX
|
|
49
|
+
)
|
|
50
|
+
return program_name, all_unwrapped_tokens[program_index + 1 :]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def all_wrapped_command_texts(command: str) -> list[str]:
|
|
54
|
+
"""Return the command plus the command string every shell wrapper inside it runs.
|
|
55
|
+
|
|
56
|
+
The segments come from the quote-aware pipeline parser, so the quoted
|
|
57
|
+
script in ``powershell -Command "git add -A; git push"`` stays whole until
|
|
58
|
+
its own turn. Nested wrappers unwrap in turn.
|
|
59
|
+
"""
|
|
60
|
+
all_inner_commands = [
|
|
61
|
+
string_exec_inner_command(each_segment)
|
|
62
|
+
for each_segment, _following_operator in pipeline_segments_for_command(command)
|
|
63
|
+
]
|
|
64
|
+
all_command_texts = [command]
|
|
65
|
+
for each_inner_command in all_inner_commands:
|
|
66
|
+
if each_inner_command is not None:
|
|
67
|
+
all_command_texts.extend(all_wrapped_command_texts(each_inner_command))
|
|
68
|
+
return all_command_texts
|
|
@@ -12,17 +12,6 @@ def test_gh_probe_asks_for_every_field_the_checklist_reads() -> None:
|
|
|
12
12
|
assert each_field in all_requested_fields
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def test_git_global_options_that_take_a_value_are_named() -> None:
|
|
16
|
-
assert constants.ALL_GIT_OPTIONS_WITH_VALUE == frozenset({"-C", "-c"})
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_powershell_wrappers_and_their_command_flags_are_named() -> None:
|
|
20
|
-
assert "pwsh" in constants.ALL_POWERSHELL_PROGRAM_NAMES
|
|
21
|
-
assert "powershell" in constants.ALL_POWERSHELL_PROGRAM_NAMES
|
|
22
|
-
assert "-command" in constants.ALL_POWERSHELL_COMMAND_FLAGS
|
|
23
|
-
assert "-c" in constants.ALL_POWERSHELL_COMMAND_FLAGS
|
|
24
|
-
|
|
25
|
-
|
|
26
15
|
def test_checklist_lines_join_on_a_single_newline() -> None:
|
|
27
16
|
assert constants.REMINDER_LINE_SEPARATOR == "\n"
|
|
28
17
|
|
|
@@ -36,6 +36,12 @@ def _first_segment_tokens(command: str) -> list[str]:
|
|
|
36
36
|
["sudo", "pytest", "tests"],
|
|
37
37
|
["sudo", "-u", "ci", "pytest", "tests"],
|
|
38
38
|
["sudo", "-nu", "ci", "pytest", "tests"],
|
|
39
|
+
["xargs", "pytest"],
|
|
40
|
+
["xargs", "-0", "pytest"],
|
|
41
|
+
["xargs", "-r", "pytest"],
|
|
42
|
+
["xargs", "-n", "1", "pytest"],
|
|
43
|
+
["env", "-u", "HOME", "pytest"],
|
|
44
|
+
["timeout", "--signal", "KILL", "60", "pytest"],
|
|
39
45
|
["uv", "run", "pytest", "tests"],
|
|
40
46
|
["uv", "run", "--frozen", "pytest", "tests"],
|
|
41
47
|
["uv", "tool", "run", "pytest", "tests"],
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pin which shell commands the mutation check reads as writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hooks_constants.shell_command_mutation import is_mutating_shell_command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
"command",
|
|
12
|
+
[
|
|
13
|
+
"sudo git push",
|
|
14
|
+
'bash -c "git commit -F m.txt"',
|
|
15
|
+
"pwsh -Command git push origin HEAD; echo done",
|
|
16
|
+
"gh api repos/o/r/issues -f title=x",
|
|
17
|
+
"Get-ChildItem | ForEach-Object { Remove-Item $_ }",
|
|
18
|
+
"echo hi > notes.txt",
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
def test_should_read_a_write_as_mutating(command: str) -> None:
|
|
22
|
+
assert is_mutating_shell_command(command) is True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize(
|
|
26
|
+
"command",
|
|
27
|
+
[
|
|
28
|
+
"rg 'gh pr merge' docs",
|
|
29
|
+
"rg 'Remove-Item' docs",
|
|
30
|
+
'grep "a|cp b" notes.md',
|
|
31
|
+
"gh api -X GET search/issues -f q=x",
|
|
32
|
+
'bash -c "git status"',
|
|
33
|
+
"git log --format='%h > %s'",
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
def test_should_read_a_check_as_not_mutating(command: str) -> None:
|
|
37
|
+
assert is_mutating_shell_command(command) is False
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Pin git global-option parsing in the shared segment helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hooks_constants import shell_command_segments
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
("all_arguments", "all_expected_tokens"),
|
|
12
|
+
[
|
|
13
|
+
(["push", "origin", "HEAD"], ["push", "origin", "HEAD"]),
|
|
14
|
+
(["-C", "/repo", "push"], ["push"]),
|
|
15
|
+
(["-c", "user.name=x", "commit", "-F", "m.txt"], ["commit", "-F", "m.txt"]),
|
|
16
|
+
(["--git-dir=/repo/.git", "push"], ["push"]),
|
|
17
|
+
(["--git-dir", "/repo/.git", "push"], ["push"]),
|
|
18
|
+
(["--work-tree", "/repo", "commit"], ["commit"]),
|
|
19
|
+
(["--namespace", "n", "push"], ["push"]),
|
|
20
|
+
(["--no-pager", "log"], ["log"]),
|
|
21
|
+
(["stash", "push", "-u"], ["stash", "push", "-u"]),
|
|
22
|
+
(["log", "--grep", "push"], ["log", "--grep", "push"]),
|
|
23
|
+
(["--version"], []),
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
def test_should_return_the_subcommand_tokens_past_global_options(
|
|
27
|
+
all_arguments: list[str], all_expected_tokens: list[str]
|
|
28
|
+
) -> None:
|
|
29
|
+
assert shell_command_segments.git_subcommand_tokens(all_arguments) == all_expected_tokens
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Pin wrapper stepping and command-string unwrapping in the shared wrapper helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hooks_constants import shell_command_wrappers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
("command", "inner_command"),
|
|
12
|
+
[
|
|
13
|
+
('pwsh -NoProfile -Command "git add -A; git push"', "git add -A; git push"),
|
|
14
|
+
("pwsh -Command git push origin HEAD; echo done", "git push origin HEAD"),
|
|
15
|
+
('bash -c "git push"', "git push"),
|
|
16
|
+
("sh -c 'git commit -F m.txt'", "git commit -F m.txt"),
|
|
17
|
+
("bash -lc 'git push'", "git push"),
|
|
18
|
+
("sudo bash -c 'git push'", "git push"),
|
|
19
|
+
("bash -c \"pwsh -Command 'git push'\"", "git push"),
|
|
20
|
+
],
|
|
21
|
+
)
|
|
22
|
+
def test_should_return_the_command_string_a_shell_wrapper_runs(
|
|
23
|
+
command: str, inner_command: str
|
|
24
|
+
) -> None:
|
|
25
|
+
all_command_texts = shell_command_wrappers.all_wrapped_command_texts(command)
|
|
26
|
+
assert all_command_texts[0] == command
|
|
27
|
+
assert inner_command in all_command_texts
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
|
|
31
|
+
assert shell_command_wrappers.all_wrapped_command_texts("git push") == ["git push"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize(
|
|
35
|
+
("all_segment_tokens", "expected_program_and_arguments"),
|
|
36
|
+
[
|
|
37
|
+
(["git", "push"], ("git", ["push"])),
|
|
38
|
+
(["sudo", "git", "push"], ("git", ["push"])),
|
|
39
|
+
(["sudo", "-u", "root", "git", "push"], ("git", ["push"])),
|
|
40
|
+
(["env", "VAR=x", "git", "push"], ("git", ["push"])),
|
|
41
|
+
(["xargs", "git", "push"], ("git", ["push"])),
|
|
42
|
+
(["xargs", "-r", "rm"], ("rm", [])),
|
|
43
|
+
(["xargs", "-I", "{}", "rm", "{}"], ("rm", ["{}"])),
|
|
44
|
+
(["xargs", "-n", "1", "git", "push"], ("git", ["push"])),
|
|
45
|
+
(["env", "-u", "HOME", "git", "push"], ("git", ["push"])),
|
|
46
|
+
(["env", "-i", "VAR=x", "git", "push"], ("git", ["push"])),
|
|
47
|
+
(["timeout", "--signal", "KILL", "5", "git", "push"], ("git", ["push"])),
|
|
48
|
+
(["timeout", "--signal=KILL", "5", "git", "push"], ("git", ["push"])),
|
|
49
|
+
(["sudo", "-nu", "ci", "env", "-u", "HOME", "git", "push"], ("git", ["push"])),
|
|
50
|
+
(["time", "sudo", "git", "push"], ("git", ["push"])),
|
|
51
|
+
(["'/usr/bin/git'", "push"], ("git", ["push"])),
|
|
52
|
+
(["VAR=x"], ("", [])),
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
def test_should_return_the_program_past_its_wrappers(
|
|
56
|
+
all_segment_tokens: list[str], expected_program_and_arguments: tuple[str, list[str]]
|
|
57
|
+
) -> None:
|
|
58
|
+
assert (
|
|
59
|
+
shell_command_wrappers.segment_program_and_arguments(all_segment_tokens)
|
|
60
|
+
== expected_program_and_arguments
|
|
61
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Constants for the verify-before-acting PostToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from hooks_constants.tool_names import APPLY_PATCH_TOOL_NAME
|
|
6
|
+
|
|
7
|
+
ALLOW_EXIT_CODE = 0
|
|
8
|
+
TOOL_NAME_KEY = "tool_name"
|
|
9
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
10
|
+
TOOL_USE_ID_KEY = "tool_use_id"
|
|
11
|
+
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
12
|
+
COMMAND_KEY = "command"
|
|
13
|
+
MESSAGE_KEY = "message"
|
|
14
|
+
MESSAGE_ID_KEY = "id"
|
|
15
|
+
CONTENT_KEY = "content"
|
|
16
|
+
BLOCK_TYPE_KEY = "type"
|
|
17
|
+
BLOCK_ID_KEY = "id"
|
|
18
|
+
THINKING_BLOCK_TYPE = "thinking"
|
|
19
|
+
THINKING_TEXT_KEY = "thinking"
|
|
20
|
+
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
21
|
+
TRANSCRIPT_ENCODING = "utf-8"
|
|
22
|
+
TRANSCRIPT_DECODE_ERRORS = "replace"
|
|
23
|
+
THINKING_JOINER = "\n"
|
|
24
|
+
TRANSCRIPT_POLL_INTERVAL_SECONDS = 0.05
|
|
25
|
+
TRANSCRIPT_POLL_LIMIT_SECONDS = 5.0
|
|
26
|
+
|
|
27
|
+
ALL_ALWAYS_MUTATING_TOOL_NAMES = frozenset(
|
|
28
|
+
{"Write", "Edit", "MultiEdit", "NotebookEdit", "Agent", "Task", APPLY_PATCH_TOOL_NAME}
|
|
29
|
+
)
|
|
30
|
+
ALL_SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
|
|
31
|
+
MCP_TOOL_PREFIX = "mcp__"
|
|
32
|
+
MCP_SEGMENT_SEPARATOR = "__"
|
|
33
|
+
MCP_ACTION_WORD_SPLIT_PATTERN = re.compile(r"_+|(?<=[a-z0-9])(?=[A-Z])")
|
|
34
|
+
ALL_MCP_MUTATING_VERBS = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"create",
|
|
37
|
+
"update",
|
|
38
|
+
"delete",
|
|
39
|
+
"send",
|
|
40
|
+
"post",
|
|
41
|
+
"reply",
|
|
42
|
+
"write",
|
|
43
|
+
"merge",
|
|
44
|
+
"set",
|
|
45
|
+
"add",
|
|
46
|
+
"remove",
|
|
47
|
+
"edit",
|
|
48
|
+
"trash",
|
|
49
|
+
"archive",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
ALL_MCP_READ_VERBS = frozenset({"get", "list", "search", "read", "view", "fetch", "find", "query"})
|
|
53
|
+
ALL_MCP_MUTATING_ACTION_NAMES = frozenset({"request_copilot_review"})
|
|
54
|
+
|
|
55
|
+
GIT_PROGRAM_NAME = "git"
|
|
56
|
+
ALL_MUTATING_GIT_SUBCOMMAND_PREFIXES = frozenset(
|
|
57
|
+
{
|
|
58
|
+
("commit",),
|
|
59
|
+
("push",),
|
|
60
|
+
("merge",),
|
|
61
|
+
("rebase",),
|
|
62
|
+
("reset",),
|
|
63
|
+
("checkout", "-b"),
|
|
64
|
+
("switch", "-c"),
|
|
65
|
+
("worktree", "add"),
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
ALL_WRITE_REDIRECTION_OPERATORS = frozenset({">", ">>", "&>", "&>>", ">&", ">|"})
|
|
69
|
+
ALL_NON_FILE_REDIRECTION_TARGETS = frozenset({"/dev/null", "$null", "nul"})
|
|
70
|
+
DESCRIPTOR_DUPLICATION_OPERATOR = ">&"
|
|
71
|
+
DESCRIPTOR_CLOSE_TARGET = "-"
|
|
72
|
+
REDIRECTION_TARGET_QUOTES = "'\""
|
|
73
|
+
|
|
74
|
+
ALL_FILE_WRITING_PROGRAM_NAMES = frozenset(
|
|
75
|
+
{"rm", "rmdir", "unlink", "touch", "cp", "mv", "mkdir", "tee", "ln"}
|
|
76
|
+
)
|
|
77
|
+
SED_PROGRAM_NAME = "sed"
|
|
78
|
+
SED_IN_PLACE_OPTION_PATTERN = re.compile(r"-[a-zA-Z]*i.*|--in-place(?:=.*)?")
|
|
79
|
+
GH_PROGRAM_NAME = "gh"
|
|
80
|
+
GH_SUBCOMMAND_DEPTH = 2
|
|
81
|
+
ALL_MUTATING_GH_SUBCOMMANDS = frozenset(
|
|
82
|
+
{
|
|
83
|
+
("pr", "create"),
|
|
84
|
+
("pr", "edit"),
|
|
85
|
+
("pr", "merge"),
|
|
86
|
+
("pr", "comment"),
|
|
87
|
+
("pr", "ready"),
|
|
88
|
+
("pr", "close"),
|
|
89
|
+
("pr", "review"),
|
|
90
|
+
("issue", "create"),
|
|
91
|
+
("issue", "edit"),
|
|
92
|
+
("issue", "comment"),
|
|
93
|
+
("issue", "close"),
|
|
94
|
+
("run", "rerun"),
|
|
95
|
+
("workflow", "run"),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
GH_API_SUBCOMMAND = "api"
|
|
99
|
+
ALL_GH_API_METHOD_OPTIONS = frozenset({"-X", "--method"})
|
|
100
|
+
GH_API_ATTACHED_METHOD_PATTERN = re.compile(r"(?:-X|--method=)(?P<method>.+)")
|
|
101
|
+
GH_API_FIELD_OPTION_PATTERN = re.compile(r"-[fF].*|--(?:field|raw-field|input)(?:=.*)?")
|
|
102
|
+
ALL_HTTP_WRITE_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
|
|
103
|
+
PULL_REQUEST_SCRIPT_NAME = "pull_request.py"
|
|
104
|
+
ALL_PULL_REQUEST_SCRIPT_WRITE_ACTIONS = frozenset({"create", "edit", "comment", "review"})
|
|
105
|
+
ALL_POWERSHELL_WRITE_CMDLET_NAMES = frozenset(
|
|
106
|
+
{
|
|
107
|
+
"remove-item",
|
|
108
|
+
"set-content",
|
|
109
|
+
"add-content",
|
|
110
|
+
"out-file",
|
|
111
|
+
"copy-item",
|
|
112
|
+
"move-item",
|
|
113
|
+
"new-item",
|
|
114
|
+
"rename-item",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
POWERSHELL_WORD_BRACKETS = "{}();"
|
|
118
|
+
POWERSHELL_SCRIPT_BLOCK_OPENER = "{"
|
|
119
|
+
|
|
120
|
+
ALL_HEDGE_PHRASES = (
|
|
121
|
+
"probably",
|
|
122
|
+
"likely",
|
|
123
|
+
"maybe",
|
|
124
|
+
"perhaps",
|
|
125
|
+
"presumably",
|
|
126
|
+
"might be",
|
|
127
|
+
"may be",
|
|
128
|
+
"seems",
|
|
129
|
+
"appears to",
|
|
130
|
+
"i suspect",
|
|
131
|
+
"i guess",
|
|
132
|
+
"i believe",
|
|
133
|
+
"my guess",
|
|
134
|
+
"my theory",
|
|
135
|
+
"not sure",
|
|
136
|
+
"unsure",
|
|
137
|
+
)
|
|
138
|
+
HEDGE_PATTERN = re.compile(
|
|
139
|
+
r"\b(?:"
|
|
140
|
+
+ "|".join(r"\s+".join(each_phrase.split()) for each_phrase in ALL_HEDGE_PHRASES)
|
|
141
|
+
+ r")\b",
|
|
142
|
+
re.IGNORECASE,
|
|
143
|
+
)
|
|
144
|
+
SENTENCE_SPLIT_PATTERN = re.compile(r"(?<=[.!?])\s+|\n+")
|
|
145
|
+
WHITESPACE_RUN_PATTERN = re.compile(r"\s+")
|
|
146
|
+
WORD_SEPARATOR = " "
|
|
147
|
+
MAXIMUM_QUOTE_LENGTH = 160
|
|
148
|
+
QUOTE_LEAD_LENGTH = MAXIMUM_QUOTE_LENGTH // 2
|
|
149
|
+
TRIM_MARKER = "..."
|
|
150
|
+
|
|
151
|
+
DECISION_KEY = "decision"
|
|
152
|
+
BLOCK_DECISION = "block"
|
|
153
|
+
REASON_KEY = "reason"
|
|
154
|
+
BLOCK_REASON_TEMPLATE = (
|
|
155
|
+
'The reasoning behind this {tool_name} call hedges: "{hedge_sentence}" '
|
|
156
|
+
"Check that claim now with a read-only tool, then continue. "
|
|
157
|
+
"If the check contradicts it, undo this change first."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
DECISION_LOG_RELATIVE_PATH = ".claude/logs/verify-before-acting.jsonl"
|
|
161
|
+
LOG_APPEND_MODE = "a"
|
|
162
|
+
LOG_LINE_END = "\n"
|
|
163
|
+
LOG_TIMESTAMP_KEY = "timestamp"
|
|
164
|
+
LOG_TOOL_NAME_KEY = "tool_name"
|
|
165
|
+
LOG_TOOL_USE_ID_KEY = "tool_use_id"
|
|
166
|
+
LOG_OUTCOME_KEY = "outcome"
|
|
167
|
+
LOG_HEDGE_SENTENCE_KEY = "hedge_sentence"
|
|
168
|
+
OUTCOME_BLOCKED = "blocked"
|
|
169
|
+
OUTCOME_ALLOWED_CLEAN = "allowed_clean"
|
|
170
|
+
OUTCOME_REASONING_UNSEEN = "reasoning_unseen"
|