claude-dev-env 8.78.3 → 8.80.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/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +3 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Names the programs that run a command held in their arguments rather than as a shell wrapper.
|
|
2
|
+
|
|
3
|
+
``eval`` and PowerShell's ``Invoke-Expression`` run their joined arguments,
|
|
4
|
+
``find -exec`` runs the tokens up to its terminator, and PowerShell's
|
|
5
|
+
``-EncodedCommand`` runs a base64 string of UTF-16LE text. PowerShell accepts
|
|
6
|
+
any prefix of ``-EncodedCommand`` from ``-e`` on, plus the ``-ec`` alias.
|
|
7
|
+
``Start-Process`` runs its file path with its argument list, which may be
|
|
8
|
+
comma-separated, and a parameter may carry its value after a colon, as in
|
|
9
|
+
``-ArgumentList:'pr list'``; the values of its other parameters are not part
|
|
10
|
+
of the command. Unwrapping stops past ``MAXIMUM_UNWRAP_DEPTH`` nested levels, so a
|
|
11
|
+
deeply nested command cannot exhaust the parser.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ALL_STRING_EVALUATING_PROGRAM_NAMES",
|
|
18
|
+
"FIND_PROGRAM_NAME",
|
|
19
|
+
"ALL_FIND_EXEC_ACTIONS",
|
|
20
|
+
"ALL_FIND_EXEC_TERMINATORS",
|
|
21
|
+
"FIND_EXEC_COMMAND_SEPARATOR",
|
|
22
|
+
"ALL_POWERSHELL_PROGRAM_NAMES",
|
|
23
|
+
"ENCODED_COMMAND_OPTION",
|
|
24
|
+
"ENCODED_COMMAND_SHORT_ALIAS",
|
|
25
|
+
"MINIMUM_ENCODED_COMMAND_PREFIX_LENGTH",
|
|
26
|
+
"ENCODED_COMMAND_TEXT_ENCODING",
|
|
27
|
+
"ALL_START_PROCESS_PROGRAM_NAMES",
|
|
28
|
+
"ALL_START_PROCESS_NON_COMMAND_PARAMETERS",
|
|
29
|
+
"START_PROCESS_PARAMETER_PREFIX",
|
|
30
|
+
"START_PROCESS_ARGUMENT_SEPARATOR",
|
|
31
|
+
"START_PROCESS_PARAMETER_VALUE_SEPARATOR",
|
|
32
|
+
"MAXIMUM_UNWRAP_DEPTH",
|
|
33
|
+
"ALL_LINE_BREAK_CHARACTERS",
|
|
34
|
+
"QUOTED_LINE_BREAK_REPLACEMENT",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
ALL_STRING_EVALUATING_PROGRAM_NAMES: frozenset[str] = frozenset(
|
|
38
|
+
{"eval", "invoke-expression", "iex"}
|
|
39
|
+
)
|
|
40
|
+
FIND_PROGRAM_NAME: str = "find"
|
|
41
|
+
ALL_FIND_EXEC_ACTIONS: frozenset[str] = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
|
42
|
+
ALL_FIND_EXEC_TERMINATORS: frozenset[str] = frozenset({";", "\\;", "+"})
|
|
43
|
+
FIND_EXEC_COMMAND_SEPARATOR: str = " ; "
|
|
44
|
+
ALL_POWERSHELL_PROGRAM_NAMES: frozenset[str] = frozenset({"pwsh", "powershell"})
|
|
45
|
+
ENCODED_COMMAND_OPTION: str = "-encodedcommand"
|
|
46
|
+
ENCODED_COMMAND_SHORT_ALIAS: str = "-ec"
|
|
47
|
+
MINIMUM_ENCODED_COMMAND_PREFIX_LENGTH: int = 2
|
|
48
|
+
ENCODED_COMMAND_TEXT_ENCODING: str = "utf-16-le"
|
|
49
|
+
ALL_START_PROCESS_PROGRAM_NAMES: frozenset[str] = frozenset({"start-process", "saps", "start"})
|
|
50
|
+
ALL_START_PROCESS_NON_COMMAND_PARAMETERS: frozenset[str] = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"-workingdirectory",
|
|
53
|
+
"-verb",
|
|
54
|
+
"-windowstyle",
|
|
55
|
+
"-credential",
|
|
56
|
+
"-redirectstandardinput",
|
|
57
|
+
"-redirectstandardoutput",
|
|
58
|
+
"-redirectstandarderror",
|
|
59
|
+
"-environment",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
START_PROCESS_PARAMETER_PREFIX: str = "-"
|
|
63
|
+
START_PROCESS_ARGUMENT_SEPARATOR: str = ","
|
|
64
|
+
START_PROCESS_PARAMETER_VALUE_SEPARATOR: str = ":"
|
|
65
|
+
MAXIMUM_UNWRAP_DEPTH: int = 16
|
|
66
|
+
ALL_LINE_BREAK_CHARACTERS: frozenset[str] = frozenset({"\n", "\r"})
|
|
67
|
+
QUOTED_LINE_BREAK_REPLACEMENT: str = " "
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Characters the command substitution scan reads in a shell command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"COMMAND_SUBSTITUTION_OPENER",
|
|
7
|
+
"BACKTICK",
|
|
8
|
+
"SINGLE_QUOTE",
|
|
9
|
+
"DOUBLE_QUOTE",
|
|
10
|
+
"SHELL_ESCAPE_CHARACTER",
|
|
11
|
+
"OPENING_PARENTHESIS",
|
|
12
|
+
"CLOSING_PARENTHESIS",
|
|
13
|
+
"ALL_PARENTHESIS_DEPTH_CHANGES",
|
|
14
|
+
"ALL_BACKTICK_ESCAPED_CHARACTERS",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
COMMAND_SUBSTITUTION_OPENER: str = "$("
|
|
18
|
+
BACKTICK: str = "`"
|
|
19
|
+
SINGLE_QUOTE: str = "'"
|
|
20
|
+
DOUBLE_QUOTE: str = '"'
|
|
21
|
+
SHELL_ESCAPE_CHARACTER: str = "\\"
|
|
22
|
+
OPENING_PARENTHESIS: str = "("
|
|
23
|
+
CLOSING_PARENTHESIS: str = ")"
|
|
24
|
+
ALL_PARENTHESIS_DEPTH_CHANGES: dict[str, int] = {OPENING_PARENTHESIS: 1, CLOSING_PARENTHESIS: -1}
|
|
25
|
+
ALL_BACKTICK_ESCAPED_CHARACTERS: frozenset[str] = frozenset({"$", BACKTICK, SHELL_ESCAPE_CHARACTER})
|
|
@@ -8,20 +8,57 @@ A wrapper either runs its own operands, as ``sudo``, ``xargs``, ``env``, and
|
|
|
8
8
|
env GIT_DIR=x git push git, ["push"]
|
|
9
9
|
echo origin | xargs git push git, ["push"]
|
|
10
10
|
bash -lc "git commit -F m.txt" "git commit -F m.txt" unwraps in turn
|
|
11
|
+
echo "$(git push)" "git push" unwraps in turn
|
|
11
12
|
|
|
12
13
|
``pytest_invocation`` owns the wrapper and string-exec parsing, and this module
|
|
13
|
-
composes it into the
|
|
14
|
+
composes it into the answers a hook asks about any program.
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
import base64
|
|
20
|
+
import collections
|
|
21
|
+
import itertools
|
|
22
|
+
|
|
23
|
+
from hooks_constants.piped_pytest_blocker_constants import (
|
|
24
|
+
WINDOWS_EXECUTABLE_SUFFIX,
|
|
25
|
+
WRAPPED_COMMAND_TOKEN_JOIN,
|
|
26
|
+
)
|
|
19
27
|
from hooks_constants.pytest_invocation import (
|
|
20
28
|
all_tokens_after_wrappers,
|
|
21
29
|
string_exec_inner_command,
|
|
22
30
|
unquoted_token,
|
|
23
31
|
)
|
|
32
|
+
from hooks_constants.shell_command_evaluation_constants import (
|
|
33
|
+
ALL_FIND_EXEC_ACTIONS,
|
|
34
|
+
ALL_FIND_EXEC_TERMINATORS,
|
|
35
|
+
ALL_POWERSHELL_PROGRAM_NAMES,
|
|
36
|
+
ALL_START_PROCESS_NON_COMMAND_PARAMETERS,
|
|
37
|
+
ALL_START_PROCESS_PROGRAM_NAMES,
|
|
38
|
+
ALL_STRING_EVALUATING_PROGRAM_NAMES,
|
|
39
|
+
ENCODED_COMMAND_OPTION,
|
|
40
|
+
ENCODED_COMMAND_SHORT_ALIAS,
|
|
41
|
+
ENCODED_COMMAND_TEXT_ENCODING,
|
|
42
|
+
FIND_EXEC_COMMAND_SEPARATOR,
|
|
43
|
+
FIND_PROGRAM_NAME,
|
|
44
|
+
ALL_LINE_BREAK_CHARACTERS,
|
|
45
|
+
MAXIMUM_UNWRAP_DEPTH,
|
|
46
|
+
MINIMUM_ENCODED_COMMAND_PREFIX_LENGTH,
|
|
47
|
+
QUOTED_LINE_BREAK_REPLACEMENT,
|
|
48
|
+
START_PROCESS_ARGUMENT_SEPARATOR,
|
|
49
|
+
START_PROCESS_PARAMETER_PREFIX,
|
|
50
|
+
START_PROCESS_PARAMETER_VALUE_SEPARATOR,
|
|
51
|
+
)
|
|
24
52
|
from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
|
|
53
|
+
from hooks_constants.shell_command_substitution_constants import (
|
|
54
|
+
ALL_BACKTICK_ESCAPED_CHARACTERS,
|
|
55
|
+
ALL_PARENTHESIS_DEPTH_CHANGES,
|
|
56
|
+
BACKTICK,
|
|
57
|
+
COMMAND_SUBSTITUTION_OPENER,
|
|
58
|
+
DOUBLE_QUOTE,
|
|
59
|
+
SHELL_ESCAPE_CHARACTER,
|
|
60
|
+
SINGLE_QUOTE,
|
|
61
|
+
)
|
|
25
62
|
from hooks_constants.shell_command_segments import (
|
|
26
63
|
effective_leading_program,
|
|
27
64
|
token_basename,
|
|
@@ -30,6 +67,9 @@ from hooks_constants.shell_command_segments import (
|
|
|
30
67
|
__all__ = [
|
|
31
68
|
"segment_program_and_arguments",
|
|
32
69
|
"all_wrapped_command_texts",
|
|
70
|
+
"all_wrapped_command_segments",
|
|
71
|
+
"all_command_substitution_texts",
|
|
72
|
+
"evaluated_inner_command",
|
|
33
73
|
]
|
|
34
74
|
|
|
35
75
|
|
|
@@ -50,19 +90,294 @@ def segment_program_and_arguments(all_segment_tokens: list[str]) -> tuple[str, l
|
|
|
50
90
|
return program_name, all_unwrapped_tokens[program_index + 1 :]
|
|
51
91
|
|
|
52
92
|
|
|
93
|
+
def _quote_state_after(
|
|
94
|
+
character: str, is_in_single_quotes: bool, is_in_double_quotes: bool
|
|
95
|
+
) -> tuple[bool, bool]:
|
|
96
|
+
"""Return the single and double quote state after one character."""
|
|
97
|
+
if is_in_single_quotes:
|
|
98
|
+
return character != SINGLE_QUOTE, False
|
|
99
|
+
if character == SINGLE_QUOTE and not is_in_double_quotes:
|
|
100
|
+
return True, False
|
|
101
|
+
if character == DOUBLE_QUOTE:
|
|
102
|
+
return False, not is_in_double_quotes
|
|
103
|
+
return False, is_in_double_quotes
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _closing_parenthesis_index(command: str, body_start_index: int) -> int:
|
|
107
|
+
"""Return the index of the unquoted parenthesis that closes a substitution.
|
|
108
|
+
|
|
109
|
+
Returns the command length when no parenthesis closes it.
|
|
110
|
+
"""
|
|
111
|
+
depth = 1
|
|
112
|
+
is_in_single_quotes = False
|
|
113
|
+
is_in_double_quotes = False
|
|
114
|
+
character_index = body_start_index
|
|
115
|
+
while character_index < len(command):
|
|
116
|
+
character = command[character_index]
|
|
117
|
+
if character == SHELL_ESCAPE_CHARACTER and not is_in_single_quotes:
|
|
118
|
+
character_index += 2
|
|
119
|
+
continue
|
|
120
|
+
if not (is_in_single_quotes or is_in_double_quotes):
|
|
121
|
+
depth += ALL_PARENTHESIS_DEPTH_CHANGES.get(character, 0)
|
|
122
|
+
if depth == 0:
|
|
123
|
+
return character_index
|
|
124
|
+
is_in_single_quotes, is_in_double_quotes = _quote_state_after(
|
|
125
|
+
character, is_in_single_quotes, is_in_double_quotes
|
|
126
|
+
)
|
|
127
|
+
character_index += 1
|
|
128
|
+
return len(command)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _backtick_body(command: str, body_start_index: int) -> tuple[str, int]:
|
|
132
|
+
"""Return a backtick substitution's body with its escapes removed, and its closing index."""
|
|
133
|
+
all_body_characters: list[str] = []
|
|
134
|
+
character_index = body_start_index
|
|
135
|
+
while character_index < len(command) and command[character_index] != BACKTICK:
|
|
136
|
+
character = command[character_index]
|
|
137
|
+
next_character = command[character_index + 1 : character_index + 2]
|
|
138
|
+
if (
|
|
139
|
+
character == SHELL_ESCAPE_CHARACTER
|
|
140
|
+
and next_character in ALL_BACKTICK_ESCAPED_CHARACTERS
|
|
141
|
+
):
|
|
142
|
+
all_body_characters.append(next_character)
|
|
143
|
+
character_index += 2
|
|
144
|
+
continue
|
|
145
|
+
all_body_characters.append(character)
|
|
146
|
+
character_index += 1
|
|
147
|
+
return "".join(all_body_characters), character_index
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _substitution_at(command: str, character_index: int) -> tuple[str, int] | None:
|
|
151
|
+
"""Return the body and closing index of a substitution that opens at the index, else None."""
|
|
152
|
+
if command.startswith(COMMAND_SUBSTITUTION_OPENER, character_index):
|
|
153
|
+
body_start_index = character_index + len(COMMAND_SUBSTITUTION_OPENER)
|
|
154
|
+
body_end_index = _closing_parenthesis_index(command, body_start_index)
|
|
155
|
+
return command[body_start_index:body_end_index], body_end_index
|
|
156
|
+
if command[character_index] != BACKTICK:
|
|
157
|
+
return None
|
|
158
|
+
return _backtick_body(command, character_index + 1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def all_command_substitution_texts(command: str) -> list[str]:
|
|
162
|
+
"""Return the command text inside each ``$(...)`` and backtick pair outside single quotes.
|
|
163
|
+
|
|
164
|
+
::
|
|
165
|
+
|
|
166
|
+
echo "$(gh pr list)" ["gh pr list"]
|
|
167
|
+
x=`git push` ["git push"]
|
|
168
|
+
echo '$(gh pr list)' []
|
|
169
|
+
"""
|
|
170
|
+
all_substitution_texts: list[str] = []
|
|
171
|
+
is_in_single_quotes = False
|
|
172
|
+
is_in_double_quotes = False
|
|
173
|
+
character_index = 0
|
|
174
|
+
while character_index < len(command):
|
|
175
|
+
substitution = None if is_in_single_quotes else _substitution_at(command, character_index)
|
|
176
|
+
if substitution is not None:
|
|
177
|
+
all_substitution_texts.append(substitution[0])
|
|
178
|
+
character_index = substitution[1] + 1
|
|
179
|
+
continue
|
|
180
|
+
if not is_in_single_quotes and command[character_index] == SHELL_ESCAPE_CHARACTER:
|
|
181
|
+
character_index += 2
|
|
182
|
+
continue
|
|
183
|
+
is_in_single_quotes, is_in_double_quotes = _quote_state_after(
|
|
184
|
+
command[character_index], is_in_single_quotes, is_in_double_quotes
|
|
185
|
+
)
|
|
186
|
+
character_index += 1
|
|
187
|
+
return all_substitution_texts
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _find_exec_command(all_arguments: list[str]) -> str | None:
|
|
191
|
+
"""Return the commands every ``find -exec`` action runs as one command list, else None."""
|
|
192
|
+
all_action_indexes = [
|
|
193
|
+
each_index
|
|
194
|
+
for each_index, each_argument in enumerate(all_arguments)
|
|
195
|
+
if unquoted_token(each_argument) in ALL_FIND_EXEC_ACTIONS
|
|
196
|
+
]
|
|
197
|
+
if not all_action_indexes:
|
|
198
|
+
return None
|
|
199
|
+
return FIND_EXEC_COMMAND_SEPARATOR.join(
|
|
200
|
+
WRAPPED_COMMAND_TOKEN_JOIN.join(
|
|
201
|
+
itertools.takewhile(
|
|
202
|
+
lambda each_token: unquoted_token(each_token) not in ALL_FIND_EXEC_TERMINATORS,
|
|
203
|
+
all_arguments[each_action_index + 1 :],
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
for each_action_index in all_action_indexes
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_encoded_command_option(argument: str) -> bool:
|
|
211
|
+
lowered_argument = unquoted_token(argument).lower()
|
|
212
|
+
return lowered_argument == ENCODED_COMMAND_SHORT_ALIAS or (
|
|
213
|
+
len(lowered_argument) >= MINIMUM_ENCODED_COMMAND_PREFIX_LENGTH
|
|
214
|
+
and ENCODED_COMMAND_OPTION.startswith(lowered_argument)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _decoded_utf16_base64_text(encoded_text: str) -> str | None:
|
|
219
|
+
"""Return the UTF-16LE text a base64 string holds, else None."""
|
|
220
|
+
try:
|
|
221
|
+
return base64.b64decode("".join(encoded_text.split()), validate=True).decode(
|
|
222
|
+
ENCODED_COMMAND_TEXT_ENCODING
|
|
223
|
+
)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _decoded_powershell_command(all_arguments: list[str]) -> str | None:
|
|
229
|
+
"""Return the text a PowerShell ``-EncodedCommand`` value decodes to, else None."""
|
|
230
|
+
for each_previous_argument, each_argument in itertools.pairwise(all_arguments):
|
|
231
|
+
if _is_encoded_command_option(each_previous_argument):
|
|
232
|
+
return _decoded_utf16_base64_text(unquoted_token(each_argument))
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _start_process_tokens(all_arguments: list[str]) -> list[str]:
|
|
237
|
+
"""Return the ``Start-Process`` arguments with each ``-Name:value`` split in two."""
|
|
238
|
+
all_tokens: list[str] = []
|
|
239
|
+
for each_argument in all_arguments:
|
|
240
|
+
is_bound_parameter = each_argument.startswith(START_PROCESS_PARAMETER_PREFIX)
|
|
241
|
+
if is_bound_parameter and START_PROCESS_PARAMETER_VALUE_SEPARATOR in each_argument:
|
|
242
|
+
all_tokens.extend(each_argument.split(START_PROCESS_PARAMETER_VALUE_SEPARATOR, 1))
|
|
243
|
+
continue
|
|
244
|
+
all_tokens.append(each_argument)
|
|
245
|
+
return all_tokens
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _start_process_command(all_arguments: list[str]) -> str | None:
|
|
249
|
+
"""Return the program and arguments a ``Start-Process`` call runs, else None."""
|
|
250
|
+
all_start_process_tokens = _start_process_tokens(all_arguments)
|
|
251
|
+
all_command_words = [
|
|
252
|
+
unquoted_token(each_argument).replace(
|
|
253
|
+
START_PROCESS_ARGUMENT_SEPARATOR, WRAPPED_COMMAND_TOKEN_JOIN
|
|
254
|
+
)
|
|
255
|
+
for each_previous_argument, each_argument in itertools.pairwise(
|
|
256
|
+
["", *all_start_process_tokens]
|
|
257
|
+
)
|
|
258
|
+
if not each_argument.startswith(START_PROCESS_PARAMETER_PREFIX)
|
|
259
|
+
and each_previous_argument.lower() not in ALL_START_PROCESS_NON_COMMAND_PARAMETERS
|
|
260
|
+
]
|
|
261
|
+
return WRAPPED_COMMAND_TOKEN_JOIN.join(all_command_words) or None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def evaluated_inner_command(all_segment_tokens: list[str]) -> str | None:
|
|
265
|
+
"""Return the command that an evaluating program in the segment runs, else None.
|
|
266
|
+
|
|
267
|
+
::
|
|
268
|
+
|
|
269
|
+
eval "gh pr list" gh pr list
|
|
270
|
+
iex 'gh pr list' gh pr list
|
|
271
|
+
find . -exec gh pr list + gh pr list
|
|
272
|
+
pwsh -EncodedCommand ZwBoACAA... the decoded text
|
|
273
|
+
Start-Process gh -ArgumentList 'pr list' gh pr list
|
|
274
|
+
gh pr list None
|
|
275
|
+
|
|
276
|
+
The pipeline parser splits a ``find`` call at an escaped ``\\;``, so a
|
|
277
|
+
segment that starts with a ``-exec`` action is the rest of that call.
|
|
278
|
+
"""
|
|
279
|
+
if all_segment_tokens and unquoted_token(all_segment_tokens[0]) in ALL_FIND_EXEC_ACTIONS:
|
|
280
|
+
return _find_exec_command(all_segment_tokens)
|
|
281
|
+
program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
282
|
+
normalized_program_name = program_name.lower()
|
|
283
|
+
if normalized_program_name in ALL_STRING_EVALUATING_PROGRAM_NAMES:
|
|
284
|
+
return WRAPPED_COMMAND_TOKEN_JOIN.join(
|
|
285
|
+
unquoted_token(each_argument) for each_argument in all_arguments
|
|
286
|
+
)
|
|
287
|
+
if normalized_program_name == FIND_PROGRAM_NAME:
|
|
288
|
+
return _find_exec_command(all_arguments)
|
|
289
|
+
if normalized_program_name in ALL_POWERSHELL_PROGRAM_NAMES:
|
|
290
|
+
return _decoded_powershell_command(all_arguments)
|
|
291
|
+
if normalized_program_name in ALL_START_PROCESS_PROGRAM_NAMES:
|
|
292
|
+
return _start_process_command(all_arguments)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _quoted_line_breaks_joined(command: str) -> str:
|
|
297
|
+
"""Return a copy of the command that holds a space at each quoted line break.
|
|
298
|
+
|
|
299
|
+
The pipeline parser splits a command at every line break, so a quoted
|
|
300
|
+
value that spans lines, such as an encoded PowerShell command, reads whole
|
|
301
|
+
only in this copy.
|
|
302
|
+
"""
|
|
303
|
+
all_characters: list[str] = []
|
|
304
|
+
is_in_single_quotes = False
|
|
305
|
+
is_in_double_quotes = False
|
|
306
|
+
for each_character in command:
|
|
307
|
+
is_quoted = is_in_single_quotes or is_in_double_quotes
|
|
308
|
+
is_quoted_line_break = is_quoted and each_character in ALL_LINE_BREAK_CHARACTERS
|
|
309
|
+
all_characters.append(
|
|
310
|
+
QUOTED_LINE_BREAK_REPLACEMENT if is_quoted_line_break else each_character
|
|
311
|
+
)
|
|
312
|
+
is_in_single_quotes, is_in_double_quotes = _quote_state_after(
|
|
313
|
+
each_character, is_in_single_quotes, is_in_double_quotes
|
|
314
|
+
)
|
|
315
|
+
return "".join(all_characters)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _inner_commands(command: str, all_command_segments: list[list[str]]) -> list[str]:
|
|
319
|
+
"""Return each command string that a wrapper, evaluating program or substitution runs.
|
|
320
|
+
|
|
321
|
+
A copy of the command with its quoted line breaks joined comes last when it differs.
|
|
322
|
+
"""
|
|
323
|
+
joined_command = _quoted_line_breaks_joined(command)
|
|
324
|
+
all_inner_commands = [
|
|
325
|
+
joined_command if joined_command != command else None,
|
|
326
|
+
*(string_exec_inner_command(each_segment) for each_segment in all_command_segments),
|
|
327
|
+
*(evaluated_inner_command(each_segment) for each_segment in all_command_segments),
|
|
328
|
+
*all_command_substitution_texts(command),
|
|
329
|
+
]
|
|
330
|
+
return [each_command for each_command in all_inner_commands if each_command is not None]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _all_wrapped_command_parses(command: str) -> list[tuple[str, list[list[str]]]]:
|
|
334
|
+
"""Return each command text with its segments, the command first, each text parsed once.
|
|
335
|
+
|
|
336
|
+
The texts after the command are the command string every shell wrapper,
|
|
337
|
+
evaluating program and command substitution inside it runs, unwrapped in
|
|
338
|
+
turn to ``MAXIMUM_UNWRAP_DEPTH`` levels. A text met twice is parsed once.
|
|
339
|
+
"""
|
|
340
|
+
all_parses: list[tuple[str, list[list[str]]]] = []
|
|
341
|
+
all_seen_texts: set[str] = set()
|
|
342
|
+
all_pending_texts = collections.deque([(command, 0)])
|
|
343
|
+
while all_pending_texts:
|
|
344
|
+
each_text, each_depth = all_pending_texts.popleft()
|
|
345
|
+
if each_text in all_seen_texts or each_depth > MAXIMUM_UNWRAP_DEPTH:
|
|
346
|
+
continue
|
|
347
|
+
all_seen_texts.add(each_text)
|
|
348
|
+
all_command_segments = [
|
|
349
|
+
each_segment
|
|
350
|
+
for each_segment, _following_operator in pipeline_segments_for_command(each_text)
|
|
351
|
+
]
|
|
352
|
+
all_parses.append((each_text, all_command_segments))
|
|
353
|
+
all_pending_texts.extend(
|
|
354
|
+
(each_inner_command, each_depth + 1)
|
|
355
|
+
for each_inner_command in _inner_commands(each_text, all_command_segments)
|
|
356
|
+
)
|
|
357
|
+
return all_parses
|
|
358
|
+
|
|
359
|
+
|
|
53
360
|
def all_wrapped_command_texts(command: str) -> list[str]:
|
|
54
|
-
"""Return the command plus
|
|
361
|
+
"""Return the command plus each command string a wrapper or substitution inside it runs.
|
|
55
362
|
|
|
56
363
|
The segments come from the quote-aware pipeline parser, so the quoted
|
|
57
364
|
script in ``powershell -Command "git add -A; git push"`` stays whole until
|
|
58
|
-
its own turn. Nested wrappers
|
|
365
|
+
its own turn. Nested wrappers and ``$(...)`` or backtick substitutions
|
|
366
|
+
unwrap in turn.
|
|
59
367
|
"""
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
368
|
+
return [each_text for each_text, _all_segments in _all_wrapped_command_parses(command)]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def all_wrapped_command_segments(command: str) -> list[list[str]]:
|
|
372
|
+
"""Return every segment of the command and of each command string a wrapper inside it runs.
|
|
373
|
+
|
|
374
|
+
Each command text goes through the pipeline parser once, and both this
|
|
375
|
+
function and ``all_wrapped_command_texts`` read that one parse, so a hook
|
|
376
|
+
that grades segments reads ``bash -c "gh pr list"`` as its outer segment
|
|
377
|
+
plus ``gh pr list``.
|
|
378
|
+
"""
|
|
379
|
+
return [
|
|
380
|
+
each_segment
|
|
381
|
+
for _each_text, all_segments in _all_wrapped_command_parses(command)
|
|
382
|
+
for each_segment in all_segments
|
|
63
383
|
]
|
|
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,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
|
|
|
12
12
|
BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def test_roster_hosts_the_four_gates_and_hands_two_to_the_shell_guards_mod() -> None:
|
|
16
16
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
17
17
|
BashHostedHookEntry(
|
|
18
18
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -28,4 +28,8 @@ def test_roster_hosts_the_three_gates_and_hands_two_to_the_shell_guards_mod() ->
|
|
|
28
28
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
29
29
|
replaced_by_plugin_name="shell-guards",
|
|
30
30
|
),
|
|
31
|
+
BashHostedHookEntry(
|
|
32
|
+
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
33
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
34
|
+
),
|
|
31
35
|
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tests for the shared check that tells a hook it runs in a Claude Code cloud session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hooks_constants.cloud_session import is_cloud_session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize("marker_value", ["true", "TRUE", " true "])
|
|
11
|
+
def test_should_read_the_cloud_marker_case_and_space_blind(
|
|
12
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
13
|
+
) -> None:
|
|
14
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
15
|
+
assert is_cloud_session() is True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize("marker_value", ["false", "", "1"])
|
|
19
|
+
def test_should_not_treat_another_marker_value_as_cloud(
|
|
20
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
21
|
+
) -> None:
|
|
22
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
23
|
+
assert is_cloud_session() is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_should_not_report_cloud_when_the_marker_is_unset() -> None:
|
|
27
|
+
assert is_cloud_session() is False
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for the shared reader of gh command words and standalone options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hooks_constants.gh_command_words import gh_command_words, gh_command_words_and_options
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
("all_arguments", "all_expected_words"),
|
|
12
|
+
[
|
|
13
|
+
(["pr", "list"], ["pr", "list"]),
|
|
14
|
+
(["-R", "o/r", "pr", "list"], ["pr", "list"]),
|
|
15
|
+
(["pr", "--repo", "o/r", "view", "12"], ["pr", "view", "12"]),
|
|
16
|
+
(["api", "-X", "POST", "graphql", "-f", "query=x"], ["api", "graphql"]),
|
|
17
|
+
(["api", "-H", "Accept: application/json", "graphql"], ["api", "graphql"]),
|
|
18
|
+
(["release", "view", "--json", "name"], ["release", "view"]),
|
|
19
|
+
(["api", "repos/o/r", "--preview", "graphql"], ["api", "repos/o/r"]),
|
|
20
|
+
(["pr", "merge", "12", "--body", "-h"], ["pr", "merge", "12"]),
|
|
21
|
+
(["pr", "list", "--repo=o/r"], ["pr", "list"]),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
def test_should_return_the_words_past_options_and_their_values(
|
|
25
|
+
all_arguments: list[str], all_expected_words: list[str]
|
|
26
|
+
) -> None:
|
|
27
|
+
assert gh_command_words(all_arguments) == all_expected_words
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize(
|
|
31
|
+
("all_arguments", "all_expected_options"),
|
|
32
|
+
[
|
|
33
|
+
(["pr", "list", "--web"], ["--web"]),
|
|
34
|
+
(["pr", "list", "--search", "-w"], ["--search"]),
|
|
35
|
+
(["pr", "merge", "12", "--body", "-h"], ["--body"]),
|
|
36
|
+
(["pr", "view", "-w", "-h"], ["-w", "-h"]),
|
|
37
|
+
(["workflow", "run", "ci.yml", "--ref=main"], ["--ref"]),
|
|
38
|
+
(["api", "graphql", "-f", "-X"], ["-f"]),
|
|
39
|
+
(["repo", "create", "n", "-p", "o/t"], ["-p"]),
|
|
40
|
+
],
|
|
41
|
+
)
|
|
42
|
+
def test_should_return_each_option_that_is_not_another_option_value(
|
|
43
|
+
all_arguments: list[str], all_expected_options: list[str]
|
|
44
|
+
) -> None:
|
|
45
|
+
assert gh_command_words_and_options(all_arguments)[1] == all_expected_options
|
|
@@ -39,6 +39,67 @@ def test_log_hook_block_writes_parseable_json_line(tmp_path: Path) -> None:
|
|
|
39
39
|
assert parsed["preview"] == "echo hello"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
_PLACEHOLDER_VALUE = "placeholder"
|
|
43
|
+
_AUTHORIZATION_HEADER_NAME = "Author" + "ization"
|
|
44
|
+
_CLASSIC_TOKEN_PREFIX = "gh" + "p_"
|
|
45
|
+
_FINE_GRAINED_TOKEN_PREFIX = "github" + "_pat_"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
("command", "secret"),
|
|
50
|
+
[
|
|
51
|
+
(
|
|
52
|
+
f"gh api graphql -H '{_AUTHORIZATION_HEADER_NAME}: token {_PLACEHOLDER_VALUE}'",
|
|
53
|
+
_PLACEHOLDER_VALUE,
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
f'wget --header "{_AUTHORIZATION_HEADER_NAME}: Bearer {_PLACEHOLDER_VALUE}"',
|
|
57
|
+
_PLACEHOLDER_VALUE,
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
f"curl -H '{_AUTHORIZATION_HEADER_NAME}: Digest username={_PLACEHOLDER_VALUE}' x",
|
|
61
|
+
_PLACEHOLDER_VALUE,
|
|
62
|
+
),
|
|
63
|
+
(
|
|
64
|
+
f'curl -H "{_AUTHORIZATION_HEADER_NAME}: {_PLACEHOLDER_VALUE}" x',
|
|
65
|
+
_PLACEHOLDER_VALUE,
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
f"python -c \"h = {{'{_AUTHORIZATION_HEADER_NAME}': 'Bearer {_PLACEHOLDER_VALUE}'}}\"",
|
|
69
|
+
_PLACEHOLDER_VALUE,
|
|
70
|
+
),
|
|
71
|
+
(
|
|
72
|
+
f"curl -H '{_AUTHORIZATION_HEADER_NAME}: \"Bearer {_PLACEHOLDER_VALUE}\"' x",
|
|
73
|
+
_PLACEHOLDER_VALUE,
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
f"GH_TOKEN={_CLASSIC_TOKEN_PREFIX}{_PLACEHOLDER_VALUE} gh pr list",
|
|
77
|
+
_CLASSIC_TOKEN_PREFIX + _PLACEHOLDER_VALUE,
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
f"echo {_FINE_GRAINED_TOKEN_PREFIX}{_PLACEHOLDER_VALUE} | gh auth login",
|
|
81
|
+
_FINE_GRAINED_TOKEN_PREFIX + _PLACEHOLDER_VALUE,
|
|
82
|
+
),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
def test_log_hook_block_redacts_secrets_in_the_preview(
|
|
86
|
+
tmp_path: Path, command: str, secret: str
|
|
87
|
+
) -> None:
|
|
88
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
89
|
+
log_hook_block(
|
|
90
|
+
calling_hook_name="test_hook.py",
|
|
91
|
+
hook_event="PreToolUse",
|
|
92
|
+
block_reason="test block reason",
|
|
93
|
+
tool_name="Bash",
|
|
94
|
+
offending_input_preview=command,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
98
|
+
preview = json.loads(log_path.read_text(encoding="utf-8").strip())["preview"]
|
|
99
|
+
assert secret not in preview
|
|
100
|
+
assert "[REDACTED]" in preview
|
|
101
|
+
|
|
102
|
+
|
|
42
103
|
def test_log_hook_block_creates_logs_directory(tmp_path: Path) -> None:
|
|
43
104
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
44
105
|
log_hook_block(
|
|
@@ -89,12 +89,13 @@ def test_should_let_local_project_settings_turn_a_user_plugin_off(
|
|
|
89
89
|
assert is_mod_plugin_enabled("session-prompts") is False
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
@pytest.mark.parametrize("marker_value", ["true", " TRUE "])
|
|
92
93
|
def test_should_keep_the_hook_in_a_cloud_session_with_the_plugin_on(
|
|
93
|
-
settings_homes: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch,
|
|
94
|
+
settings_homes: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, marker_value: str,
|
|
94
95
|
) -> None:
|
|
95
96
|
_, project_directory = settings_homes
|
|
96
97
|
_write_enabled_plugins(
|
|
97
98
|
project_directory / ".claude" / "settings.json", {"session-prompts@market": True}
|
|
98
99
|
)
|
|
99
|
-
monkeypatch.setenv("CLAUDE_CODE_REMOTE",
|
|
100
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
100
101
|
assert is_mod_plugin_enabled("session-prompts") is False
|
|
@@ -12,3 +12,14 @@ def test_pipe_and_wrapper_tokens_preserve_command_boundaries() -> None:
|
|
|
12
12
|
assert ";;" in constants.ALL_SEGMENT_RESET_OPERATOR_TOKENS
|
|
13
13
|
assert "-u" in constants.ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME["sudo"].all_value_taking_options
|
|
14
14
|
assert constants.ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME["timeout"].leading_operand_count == 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_shell_keywords_and_builtins_step_to_the_command_they_run() -> None:
|
|
18
|
+
assert {"if", "then", "do", "!"} <= constants.ALL_SHELL_KEYWORDS_BEFORE_A_COMMAND
|
|
19
|
+
for each_keyword in constants.ALL_SHELL_KEYWORDS_BEFORE_A_COMMAND:
|
|
20
|
+
assert (
|
|
21
|
+
constants.ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME[each_keyword]
|
|
22
|
+
== constants.SHELL_KEYWORD_OPTION_GRAMMAR
|
|
23
|
+
)
|
|
24
|
+
assert "-a" in constants.ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME["exec"].all_value_taking_options
|
|
25
|
+
assert "-p" in constants.ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME["command"].all_flag_options
|