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,323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook that blocks a mutating call made on an unchecked claim.
|
|
3
|
+
|
|
4
|
+
The hook reads the thinking text of the assistant message that made the call,
|
|
5
|
+
found in the session transcript by the call's ``tool_use_id``. When that
|
|
6
|
+
reasoning hedges, the hook blocks and quotes the hedge sentence::
|
|
7
|
+
|
|
8
|
+
flag: The config probably lives in settings.json. -> Write blocks
|
|
9
|
+
ok: The config lives in settings.json, as read. -> Write passes
|
|
10
|
+
ok: The config probably lives in settings.json. -> gh pr view passes
|
|
11
|
+
|
|
12
|
+
The tool has already run, so the block reason tells the model to check the
|
|
13
|
+
claim now with a read-only tool and to undo the change when the check
|
|
14
|
+
contradicts it.
|
|
15
|
+
|
|
16
|
+
A call counts as mutating when its tool is in ALL_ALWAYS_MUTATING_TOOL_NAMES
|
|
17
|
+
(Write, Edit, MultiEdit, NotebookEdit, apply_patch, Agent, Task), when a
|
|
18
|
+
segment of its shell command runs a writing program, a git or gh write, an
|
|
19
|
+
in-place sed, or a PowerShell write cmdlet, or redirects output into a file,
|
|
20
|
+
or when an MCP tool's last name segment is in ALL_MCP_MUTATING_ACTION_NAMES or
|
|
21
|
+
holds a write verb as one of its words and no read verb. Shell segments come
|
|
22
|
+
from the quote-aware parser after every wrapper is stepped over, so
|
|
23
|
+
``rg 'gh pr merge' docs`` reads and ``sudo git push`` writes. Every other call
|
|
24
|
+
is a check and passes with no log line.
|
|
25
|
+
|
|
26
|
+
Each mutating call writes one JSON line to DECISION_LOG_RELATIVE_PATH under
|
|
27
|
+
the home directory: blocked, allowed_clean, or reasoning_unseen when the
|
|
28
|
+
transcript record is missing or its thinking text is empty. A missing or
|
|
29
|
+
unreadable input always allows, so the hook never fails closed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import datetime
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
41
|
+
if hooks_root_directory not in sys.path:
|
|
42
|
+
sys.path.insert(0, hooks_root_directory)
|
|
43
|
+
|
|
44
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
45
|
+
HOOK_EVENT_NAME_KEY,
|
|
46
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
47
|
+
POST_TOOL_USE_HOOK_EVENT_NAME,
|
|
48
|
+
)
|
|
49
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
50
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
51
|
+
from hooks_constants.shell_command_mutation import is_mutating_shell_command
|
|
52
|
+
from hooks_constants.verify_before_acting_constants import (
|
|
53
|
+
ALL_ALWAYS_MUTATING_TOOL_NAMES,
|
|
54
|
+
ALL_MCP_MUTATING_ACTION_NAMES,
|
|
55
|
+
ALL_MCP_MUTATING_VERBS,
|
|
56
|
+
ALL_MCP_READ_VERBS,
|
|
57
|
+
ALL_SHELL_TOOL_NAMES,
|
|
58
|
+
ALLOW_EXIT_CODE,
|
|
59
|
+
BLOCK_DECISION,
|
|
60
|
+
BLOCK_ID_KEY,
|
|
61
|
+
BLOCK_REASON_TEMPLATE,
|
|
62
|
+
BLOCK_TYPE_KEY,
|
|
63
|
+
COMMAND_KEY,
|
|
64
|
+
CONTENT_KEY,
|
|
65
|
+
DECISION_KEY,
|
|
66
|
+
DECISION_LOG_RELATIVE_PATH,
|
|
67
|
+
LOG_APPEND_MODE,
|
|
68
|
+
LOG_LINE_END,
|
|
69
|
+
HEDGE_PATTERN,
|
|
70
|
+
LOG_HEDGE_SENTENCE_KEY,
|
|
71
|
+
LOG_OUTCOME_KEY,
|
|
72
|
+
LOG_TIMESTAMP_KEY,
|
|
73
|
+
LOG_TOOL_NAME_KEY,
|
|
74
|
+
LOG_TOOL_USE_ID_KEY,
|
|
75
|
+
MAXIMUM_QUOTE_LENGTH,
|
|
76
|
+
MCP_ACTION_WORD_SPLIT_PATTERN,
|
|
77
|
+
MCP_SEGMENT_SEPARATOR,
|
|
78
|
+
MCP_TOOL_PREFIX,
|
|
79
|
+
MESSAGE_ID_KEY,
|
|
80
|
+
MESSAGE_KEY,
|
|
81
|
+
OUTCOME_ALLOWED_CLEAN,
|
|
82
|
+
OUTCOME_BLOCKED,
|
|
83
|
+
OUTCOME_REASONING_UNSEEN,
|
|
84
|
+
QUOTE_LEAD_LENGTH,
|
|
85
|
+
REASON_KEY,
|
|
86
|
+
SENTENCE_SPLIT_PATTERN,
|
|
87
|
+
THINKING_BLOCK_TYPE,
|
|
88
|
+
THINKING_JOINER,
|
|
89
|
+
THINKING_TEXT_KEY,
|
|
90
|
+
TOOL_INPUT_KEY,
|
|
91
|
+
TOOL_NAME_KEY,
|
|
92
|
+
TOOL_USE_BLOCK_TYPE,
|
|
93
|
+
TOOL_USE_ID_KEY,
|
|
94
|
+
TRANSCRIPT_DECODE_ERRORS,
|
|
95
|
+
TRANSCRIPT_ENCODING,
|
|
96
|
+
TRANSCRIPT_PATH_KEY,
|
|
97
|
+
TRANSCRIPT_POLL_INTERVAL_SECONDS,
|
|
98
|
+
TRANSCRIPT_POLL_LIMIT_SECONDS,
|
|
99
|
+
TRIM_MARKER,
|
|
100
|
+
WHITESPACE_RUN_PATTERN,
|
|
101
|
+
WORD_SEPARATOR,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def is_mutating_call(tool_name: str, tool_input: object) -> bool:
|
|
105
|
+
"""Return True when the call writes, sends, or spawns rather than reads."""
|
|
106
|
+
if tool_name in ALL_ALWAYS_MUTATING_TOOL_NAMES:
|
|
107
|
+
return True
|
|
108
|
+
if tool_name in ALL_SHELL_TOOL_NAMES:
|
|
109
|
+
command = tool_input.get(COMMAND_KEY) if isinstance(tool_input, dict) else None
|
|
110
|
+
return isinstance(command, str) and is_mutating_shell_command(command)
|
|
111
|
+
if tool_name.startswith(MCP_TOOL_PREFIX):
|
|
112
|
+
last_segment = tool_name.rsplit(MCP_SEGMENT_SEPARATOR, maxsplit=1)[-1]
|
|
113
|
+
if last_segment in ALL_MCP_MUTATING_ACTION_NAMES:
|
|
114
|
+
return True
|
|
115
|
+
all_action_words = {
|
|
116
|
+
each_word.lower()
|
|
117
|
+
for each_word in MCP_ACTION_WORD_SPLIT_PATTERN.split(last_segment)
|
|
118
|
+
if each_word
|
|
119
|
+
}
|
|
120
|
+
return bool(all_action_words & ALL_MCP_MUTATING_VERBS) and not (
|
|
121
|
+
all_action_words & ALL_MCP_READ_VERBS
|
|
122
|
+
)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parsed_record(transcript_line: str) -> dict[str, object] | None:
|
|
127
|
+
try:
|
|
128
|
+
parsed_line = json.loads(transcript_line)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
return None
|
|
131
|
+
return parsed_line if isinstance(parsed_line, dict) else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _message_of(all_record_fields: dict[str, object] | None) -> dict[str, object]:
|
|
135
|
+
message = all_record_fields.get(MESSAGE_KEY) if all_record_fields is not None else None
|
|
136
|
+
return message if isinstance(message, dict) else {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _content_blocks(all_record_fields: dict[str, object] | None) -> list[dict[str, object]]:
|
|
140
|
+
all_content = _message_of(all_record_fields).get(CONTENT_KEY)
|
|
141
|
+
if not isinstance(all_content, list):
|
|
142
|
+
return []
|
|
143
|
+
return [each_block for each_block in all_content if isinstance(each_block, dict)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _thinking_texts(all_blocks: list[dict[str, object]]) -> list[str]:
|
|
147
|
+
all_thinking_values = [
|
|
148
|
+
each_block.get(THINKING_TEXT_KEY)
|
|
149
|
+
for each_block in all_blocks
|
|
150
|
+
if each_block.get(BLOCK_TYPE_KEY) == THINKING_BLOCK_TYPE
|
|
151
|
+
]
|
|
152
|
+
return [each_value for each_value in all_thinking_values if isinstance(each_value, str)]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tool_use_position(all_blocks: list[dict[str, object]], tool_use_id: str) -> int | None:
|
|
156
|
+
for each_position, each_block in enumerate(all_blocks):
|
|
157
|
+
if (
|
|
158
|
+
each_block.get(BLOCK_TYPE_KEY) == TOOL_USE_BLOCK_TYPE
|
|
159
|
+
and each_block.get(BLOCK_ID_KEY) == tool_use_id
|
|
160
|
+
):
|
|
161
|
+
return each_position
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _earlier_thinking(all_earlier_lines: list[str], message_id: str) -> list[str]:
|
|
166
|
+
all_texts: list[str] = []
|
|
167
|
+
for each_line in all_earlier_lines:
|
|
168
|
+
if message_id not in each_line:
|
|
169
|
+
continue
|
|
170
|
+
record = _parsed_record(each_line)
|
|
171
|
+
if _message_of(record).get(MESSAGE_ID_KEY) == message_id:
|
|
172
|
+
all_texts.extend(_thinking_texts(_content_blocks(record)))
|
|
173
|
+
return all_texts
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def acting_reasoning(all_transcript_lines: list[str], tool_use_id: str) -> str | None:
|
|
177
|
+
"""Return the thinking text of the message that made the call, or None when unfound.
|
|
178
|
+
|
|
179
|
+
Scans from the end for the record whose ``tool_use`` block carries the id,
|
|
180
|
+
then joins the thinking blocks of every earlier record sharing its
|
|
181
|
+
``message.id`` with the thinking that precedes the call in its own record.
|
|
182
|
+
Only lines holding the id substring are parsed, so a long transcript stays
|
|
183
|
+
inside the hook timeout.
|
|
184
|
+
"""
|
|
185
|
+
for each_index in range(len(all_transcript_lines) - 1, -1, -1):
|
|
186
|
+
transcript_line = all_transcript_lines[each_index]
|
|
187
|
+
if tool_use_id not in transcript_line:
|
|
188
|
+
continue
|
|
189
|
+
record = _parsed_record(transcript_line)
|
|
190
|
+
all_blocks = _content_blocks(record)
|
|
191
|
+
tool_use_position = _tool_use_position(all_blocks, tool_use_id)
|
|
192
|
+
if tool_use_position is None:
|
|
193
|
+
continue
|
|
194
|
+
message_id = _message_of(record).get(MESSAGE_ID_KEY)
|
|
195
|
+
all_texts = (
|
|
196
|
+
_earlier_thinking(all_transcript_lines[:each_index], message_id)
|
|
197
|
+
if isinstance(message_id, str) and message_id
|
|
198
|
+
else []
|
|
199
|
+
)
|
|
200
|
+
all_texts.extend(_thinking_texts(all_blocks[:tool_use_position]))
|
|
201
|
+
return THINKING_JOINER.join(all_texts)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _transcript_lines(transcript_path_value: object) -> list[str] | None:
|
|
206
|
+
if not isinstance(transcript_path_value, str) or not transcript_path_value:
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
transcript_text = Path(transcript_path_value).read_text(
|
|
210
|
+
encoding=TRANSCRIPT_ENCODING, errors=TRANSCRIPT_DECODE_ERRORS
|
|
211
|
+
)
|
|
212
|
+
except (OSError, ValueError):
|
|
213
|
+
return None
|
|
214
|
+
return transcript_text.splitlines()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _quoted_excerpt(sentence: str, hedge_start: int) -> str:
|
|
218
|
+
if len(sentence) <= MAXIMUM_QUOTE_LENGTH:
|
|
219
|
+
return sentence
|
|
220
|
+
window_start = max(
|
|
221
|
+
0, min(hedge_start - QUOTE_LEAD_LENGTH, len(sentence) - MAXIMUM_QUOTE_LENGTH)
|
|
222
|
+
)
|
|
223
|
+
window_end = window_start + MAXIMUM_QUOTE_LENGTH
|
|
224
|
+
leading_marker = TRIM_MARKER if window_start > 0 else ""
|
|
225
|
+
trailing_marker = TRIM_MARKER if window_end < len(sentence) else ""
|
|
226
|
+
return leading_marker + sentence[window_start:window_end].strip() + trailing_marker
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def first_hedge_sentence(reasoning: str) -> str | None:
|
|
230
|
+
"""Return the first hedge sentence, trimmed around its hedge phrase, or None."""
|
|
231
|
+
for each_sentence in SENTENCE_SPLIT_PATTERN.split(reasoning):
|
|
232
|
+
normalized_sentence = WHITESPACE_RUN_PATTERN.sub(WORD_SEPARATOR, each_sentence).strip()
|
|
233
|
+
hedge_match = HEDGE_PATTERN.search(normalized_sentence)
|
|
234
|
+
if hedge_match is not None:
|
|
235
|
+
return _quoted_excerpt(normalized_sentence, hedge_match.start())
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _log_decision(
|
|
240
|
+
tool_name: str, tool_use_id: object, outcome: str, hedge_sentence: str | None
|
|
241
|
+
) -> None:
|
|
242
|
+
try:
|
|
243
|
+
log_path = Path.home() / DECISION_LOG_RELATIVE_PATH
|
|
244
|
+
except RuntimeError:
|
|
245
|
+
return
|
|
246
|
+
log_record: dict[str, object] = {
|
|
247
|
+
LOG_TIMESTAMP_KEY: datetime.datetime.now().isoformat(),
|
|
248
|
+
LOG_TOOL_NAME_KEY: tool_name,
|
|
249
|
+
LOG_TOOL_USE_ID_KEY: tool_use_id if isinstance(tool_use_id, str) else None,
|
|
250
|
+
LOG_OUTCOME_KEY: outcome,
|
|
251
|
+
}
|
|
252
|
+
if hedge_sentence is not None:
|
|
253
|
+
log_record[LOG_HEDGE_SENTENCE_KEY] = hedge_sentence
|
|
254
|
+
try:
|
|
255
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
with log_path.open(LOG_APPEND_MODE, encoding=TRANSCRIPT_ENCODING) as log_file:
|
|
257
|
+
log_file.write(json.dumps(log_record) + LOG_LINE_END)
|
|
258
|
+
except OSError:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _call_reasoning(all_hook_fields: dict[str, object]) -> str | None:
|
|
263
|
+
"""Return the call's reasoning, polling to a deadline while its record is unwritten.
|
|
264
|
+
|
|
265
|
+
PostToolUse can start before the harness appends the tool-use record, so a
|
|
266
|
+
missing record is read again until TRANSCRIPT_POLL_LIMIT_SECONDS passes.
|
|
267
|
+
"""
|
|
268
|
+
tool_use_id = all_hook_fields.get(TOOL_USE_ID_KEY)
|
|
269
|
+
if not isinstance(tool_use_id, str) or not tool_use_id:
|
|
270
|
+
return None
|
|
271
|
+
deadline = time.monotonic() + TRANSCRIPT_POLL_LIMIT_SECONDS
|
|
272
|
+
while True:
|
|
273
|
+
all_transcript_lines = _transcript_lines(all_hook_fields.get(TRANSCRIPT_PATH_KEY))
|
|
274
|
+
if all_transcript_lines is None:
|
|
275
|
+
return None
|
|
276
|
+
reasoning = acting_reasoning(all_transcript_lines, tool_use_id)
|
|
277
|
+
if reasoning is not None or time.monotonic() >= deadline:
|
|
278
|
+
return reasoning
|
|
279
|
+
time.sleep(TRANSCRIPT_POLL_INTERVAL_SECONDS)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _emit_block(tool_name: str, tool_use_id: object, hedge_sentence: str) -> None:
|
|
283
|
+
block_reason = BLOCK_REASON_TEMPLATE.format(tool_name=tool_name, hedge_sentence=hedge_sentence)
|
|
284
|
+
_log_decision(tool_name, tool_use_id, OUTCOME_BLOCKED, hedge_sentence)
|
|
285
|
+
log_hook_block(
|
|
286
|
+
Path(__file__).name,
|
|
287
|
+
POST_TOOL_USE_HOOK_EVENT_NAME,
|
|
288
|
+
block_reason,
|
|
289
|
+
tool_name=tool_name,
|
|
290
|
+
offending_input_preview=hedge_sentence,
|
|
291
|
+
)
|
|
292
|
+
block_payload = {
|
|
293
|
+
DECISION_KEY: BLOCK_DECISION,
|
|
294
|
+
REASON_KEY: block_reason,
|
|
295
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {HOOK_EVENT_NAME_KEY: POST_TOOL_USE_HOOK_EVENT_NAME},
|
|
296
|
+
}
|
|
297
|
+
sys.stdout.write(json.dumps(block_payload))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main() -> int:
|
|
301
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
302
|
+
if hook_input is None:
|
|
303
|
+
return ALLOW_EXIT_CODE
|
|
304
|
+
tool_name = hook_input.get(TOOL_NAME_KEY)
|
|
305
|
+
if not isinstance(tool_name, str) or not is_mutating_call(
|
|
306
|
+
tool_name, hook_input.get(TOOL_INPUT_KEY)
|
|
307
|
+
):
|
|
308
|
+
return ALLOW_EXIT_CODE
|
|
309
|
+
tool_use_id = hook_input.get(TOOL_USE_ID_KEY)
|
|
310
|
+
reasoning = _call_reasoning(hook_input)
|
|
311
|
+
if reasoning is None or not reasoning.strip():
|
|
312
|
+
_log_decision(tool_name, tool_use_id, OUTCOME_REASONING_UNSEEN, None)
|
|
313
|
+
return ALLOW_EXIT_CODE
|
|
314
|
+
hedge_sentence = first_hedge_sentence(reasoning)
|
|
315
|
+
if hedge_sentence is None:
|
|
316
|
+
_log_decision(tool_name, tool_use_id, OUTCOME_ALLOWED_CLEAN, None)
|
|
317
|
+
return ALLOW_EXIT_CODE
|
|
318
|
+
_emit_block(tool_name, tool_use_id, hedge_sentence)
|
|
319
|
+
return ALLOW_EXIT_CODE
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
sys.exit(main())
|
package/hooks/hooks.json
CHANGED
|
@@ -241,6 +241,16 @@
|
|
|
241
241
|
"timeout": 60
|
|
242
242
|
}
|
|
243
243
|
]
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"matcher": "Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*",
|
|
247
|
+
"hooks": [
|
|
248
|
+
{
|
|
249
|
+
"type": "command",
|
|
250
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verify_before_acting.py",
|
|
251
|
+
"timeout": 10
|
|
252
|
+
}
|
|
253
|
+
]
|
|
244
254
|
}
|
|
245
255
|
],
|
|
246
256
|
"InstructionsLoaded": [
|
|
@@ -18,9 +18,12 @@ parsing lives in ``shell_command_pipeline``. Pytest invocation classification
|
|
|
18
18
|
that consumes these program, wrapper, and option constants lives in
|
|
19
19
|
``pytest_invocation``.
|
|
20
20
|
|
|
21
|
-
Two wrapper shapes reach the program behind them differently. ``sudo
|
|
22
|
-
``uvx`` take their own
|
|
23
|
-
|
|
21
|
+
Two wrapper shapes reach the program behind them differently. ``sudo``,
|
|
22
|
+
``uvx``, ``xargs``, ``env``, and ``timeout`` take their own options and then the
|
|
23
|
+
command, and ``ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME`` names each one's
|
|
24
|
+
value-taking options, its flag-only options, and the operands it reads before
|
|
25
|
+
the command, so ``xargs -I {} rm {}`` and ``timeout --signal KILL 5 git push``
|
|
26
|
+
both reach the program. ``uv``, ``poetry``, ``pipenv``, ``pdm``, ``hatch``, ``rye``, and
|
|
24
27
|
``coverage`` take the literal ``run`` subcommand first, so the step-over reads
|
|
25
28
|
that word and passes only when it is there — ``uv sync`` and ``coverage report``
|
|
26
29
|
keep the wrapper as its own program. ``uv`` spells the same pass-through as
|
|
@@ -110,6 +113,7 @@ the blocker's own lexer.
|
|
|
110
113
|
from __future__ import annotations
|
|
111
114
|
|
|
112
115
|
import re
|
|
116
|
+
from typing import NamedTuple
|
|
113
117
|
|
|
114
118
|
from hooks_constants.shell_command_segments import ALL_SHELL_CONTROL_OPERATOR_TOKENS
|
|
115
119
|
from hooks_constants.unscoped_search_blocker_constants import (
|
|
@@ -142,7 +146,10 @@ __all__ = [
|
|
|
142
146
|
"ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS",
|
|
143
147
|
"ALL_VALUE_TAKING_SHELL_OPTION_FLAGS",
|
|
144
148
|
"ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS",
|
|
145
|
-
"
|
|
149
|
+
"WrapperOptionGrammar",
|
|
150
|
+
"WINDOWS_EXECUTABLE_SUFFIX",
|
|
151
|
+
"RUN_SUBCOMMAND_OPTION_GRAMMAR",
|
|
152
|
+
"ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME",
|
|
146
153
|
"ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS",
|
|
147
154
|
"RUN_SUBCOMMAND_NAME",
|
|
148
155
|
"TOOL_SUBCOMMAND_NAME",
|
|
@@ -291,9 +298,74 @@ ALL_VALUE_TAKING_SHELL_OPTION_FLAGS: frozenset[str] = frozenset(
|
|
|
291
298
|
}
|
|
292
299
|
)
|
|
293
300
|
|
|
294
|
-
|
|
295
|
-
|
|
301
|
+
class WrapperOptionGrammar(NamedTuple):
|
|
302
|
+
"""The options one wrapper reads before the command it runs."""
|
|
303
|
+
|
|
304
|
+
all_value_taking_options: frozenset[str]
|
|
305
|
+
all_flag_options: frozenset[str]
|
|
306
|
+
leading_operand_count: int
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
WINDOWS_EXECUTABLE_SUFFIX = ".exe"
|
|
310
|
+
RUN_SUBCOMMAND_OPTION_GRAMMAR = WrapperOptionGrammar(
|
|
311
|
+
all_value_taking_options=ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS,
|
|
312
|
+
all_flag_options=frozenset(),
|
|
313
|
+
leading_operand_count=0,
|
|
296
314
|
)
|
|
315
|
+
ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME: dict[str, WrapperOptionGrammar] = {
|
|
316
|
+
"sudo": WrapperOptionGrammar(
|
|
317
|
+
all_value_taking_options=frozenset(
|
|
318
|
+
{
|
|
319
|
+
"-u",
|
|
320
|
+
"-g",
|
|
321
|
+
"-C",
|
|
322
|
+
"-h",
|
|
323
|
+
"-p",
|
|
324
|
+
"-D",
|
|
325
|
+
"-R",
|
|
326
|
+
"-T",
|
|
327
|
+
"-U",
|
|
328
|
+
"-r",
|
|
329
|
+
"-t",
|
|
330
|
+
"--user",
|
|
331
|
+
"--group",
|
|
332
|
+
"--close-from",
|
|
333
|
+
"--host",
|
|
334
|
+
"--prompt",
|
|
335
|
+
"--chdir",
|
|
336
|
+
"--chroot",
|
|
337
|
+
"--command-timeout",
|
|
338
|
+
"--other-user",
|
|
339
|
+
"--role",
|
|
340
|
+
"--type",
|
|
341
|
+
}
|
|
342
|
+
),
|
|
343
|
+
all_flag_options=frozenset({"-E", "-H", "-i", "-k", "-n", "-S", "-s", "-b", "-P"}),
|
|
344
|
+
leading_operand_count=0,
|
|
345
|
+
),
|
|
346
|
+
"uvx": RUN_SUBCOMMAND_OPTION_GRAMMAR,
|
|
347
|
+
"xargs": WrapperOptionGrammar(
|
|
348
|
+
all_value_taking_options=frozenset(
|
|
349
|
+
{"-I", "-n", "-L", "-P", "-d", "-a", "-s", "-E", "--max-args", "--max-procs"}
|
|
350
|
+
),
|
|
351
|
+
all_flag_options=frozenset(
|
|
352
|
+
{"-r", "-0", "-t", "-p", "-x", "-i", "-e", "-l", "--null", "--no-run-if-empty"}
|
|
353
|
+
),
|
|
354
|
+
leading_operand_count=0,
|
|
355
|
+
),
|
|
356
|
+
"env": WrapperOptionGrammar(
|
|
357
|
+
all_value_taking_options=frozenset(
|
|
358
|
+
{"-u", "-C", "-S", "--unset", "--chdir", "--split-string"}
|
|
359
|
+
),
|
|
360
|
+
all_flag_options=frozenset({"-i", "-0", "-v", "--ignore-environment", "--null"}),
|
|
361
|
+
leading_operand_count=0,
|
|
362
|
+
),
|
|
363
|
+
"timeout": WrapperOptionGrammar(
|
|
364
|
+
all_value_taking_options=frozenset({"-s", "-k", "--signal", "--kill-after"}),
|
|
365
|
+
all_flag_options=frozenset({"-v", "--verbose", "--preserve-status", "--foreground"}),
|
|
366
|
+
leading_operand_count=1,
|
|
367
|
+
),
|
|
368
|
+
}
|
|
297
369
|
ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
298
370
|
{
|
|
299
371
|
"uv",
|
|
@@ -13,9 +13,6 @@ __all__ = [
|
|
|
13
13
|
"GH_PROGRAM_NAME",
|
|
14
14
|
"GH_PR_SUBCOMMAND",
|
|
15
15
|
"GH_PR_CREATE_ACTION",
|
|
16
|
-
"ALL_GIT_OPTIONS_WITH_VALUE",
|
|
17
|
-
"ALL_POWERSHELL_PROGRAM_NAMES",
|
|
18
|
-
"ALL_POWERSHELL_COMMAND_FLAGS",
|
|
19
16
|
"ALL_GH_PR_VIEW_ARGUMENTS",
|
|
20
17
|
"GH_PR_VIEW_TIMEOUT_SECONDS",
|
|
21
18
|
"NO_PULL_REQUEST_MARKER",
|
|
@@ -43,11 +40,6 @@ GIT_PUSH_SUBCOMMAND: str = "push"
|
|
|
43
40
|
GH_PROGRAM_NAME: str = "gh"
|
|
44
41
|
GH_PR_SUBCOMMAND: str = "pr"
|
|
45
42
|
GH_PR_CREATE_ACTION: str = "create"
|
|
46
|
-
ALL_GIT_OPTIONS_WITH_VALUE: frozenset[str] = frozenset({"-C", "-c"})
|
|
47
|
-
ALL_POWERSHELL_PROGRAM_NAMES: frozenset[str] = frozenset(
|
|
48
|
-
{"pwsh", "pwsh.exe", "powershell", "powershell.exe"}
|
|
49
|
-
)
|
|
50
|
-
ALL_POWERSHELL_COMMAND_FLAGS: frozenset[str] = frozenset({"-command", "-c"})
|
|
51
43
|
|
|
52
44
|
ALL_GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = (
|
|
53
45
|
"gh",
|
|
@@ -21,7 +21,6 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
from hooks_constants.piped_pytest_blocker_constants import (
|
|
23
23
|
ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS,
|
|
24
|
-
ALL_FLAG_TAKING_WRAPPER_COMMANDS,
|
|
25
24
|
ALL_PYTEST_PROGRAM_BASENAMES,
|
|
26
25
|
ALL_QUOTE_CHARACTERS,
|
|
27
26
|
ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS,
|
|
@@ -30,28 +29,33 @@ from hooks_constants.piped_pytest_blocker_constants import (
|
|
|
30
29
|
ALL_STRING_EXECUTING_SHELL_BASENAMES,
|
|
31
30
|
ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS,
|
|
32
31
|
ALL_VALUE_TAKING_SHELL_OPTION_FLAGS,
|
|
33
|
-
|
|
32
|
+
ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME,
|
|
34
33
|
COMMAND_OPTION_TOKEN_PATTERN,
|
|
35
34
|
END_OF_OPTIONS_TOKEN,
|
|
36
35
|
MODULE_RUN_FLAG,
|
|
37
36
|
PYTEST_MODULE_NAME,
|
|
38
37
|
PYTHON_INTERPRETER_BASENAME_PATTERN,
|
|
39
38
|
RUN_SUBCOMMAND_NAME,
|
|
39
|
+
RUN_SUBCOMMAND_OPTION_GRAMMAR,
|
|
40
40
|
SHORT_OPTION_CLUSTER_PATTERN,
|
|
41
41
|
SHORT_OPTION_PREFIX,
|
|
42
42
|
TOOL_SUBCOMMAND_NAME,
|
|
43
|
+
WINDOWS_EXECUTABLE_SUFFIX,
|
|
43
44
|
WRAPPED_COMMAND_TOKEN_JOIN,
|
|
45
|
+
WrapperOptionGrammar,
|
|
44
46
|
)
|
|
45
47
|
from hooks_constants.shell_command_pipeline import (
|
|
46
48
|
all_operator_aware_tokenizations,
|
|
47
49
|
segments_with_following_operator,
|
|
48
50
|
)
|
|
49
51
|
from hooks_constants.shell_command_segments import (
|
|
52
|
+
LEADING_ASSIGNMENT_PATTERN,
|
|
50
53
|
effective_leading_program,
|
|
51
54
|
token_basename,
|
|
52
55
|
)
|
|
53
56
|
|
|
54
57
|
__all__ = [
|
|
58
|
+
"all_tokens_after_wrappers",
|
|
55
59
|
"segment_reports_a_pytest_exit_code",
|
|
56
60
|
"segment_runs_pytest",
|
|
57
61
|
"string_exec_inner_command",
|
|
@@ -153,8 +157,17 @@ def _runs_pytest_as_a_module(all_interpreter_argument_tokens: list[str]) -> bool
|
|
|
153
157
|
return _some_module_run_flag_names_pytest(all_module_tokens)
|
|
154
158
|
|
|
155
159
|
|
|
156
|
-
def
|
|
157
|
-
"""Return
|
|
160
|
+
def _option_token_count(stripped_token: str, grammar: WrapperOptionGrammar) -> int:
|
|
161
|
+
"""Return how many tokens one wrapper option spans, counting its separate value."""
|
|
162
|
+
if stripped_token in grammar.all_flag_options:
|
|
163
|
+
return 1
|
|
164
|
+
return 1 + _option_value_token_count(stripped_token, grammar.all_value_taking_options)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _all_tokens_from_the_first_operand(
|
|
168
|
+
all_tokens: list[str], grammar: WrapperOptionGrammar
|
|
169
|
+
) -> list[str]:
|
|
170
|
+
"""Return the tokens from the first non-option one on, dropping the wrapper's options."""
|
|
158
171
|
all_remaining_tokens = all_tokens
|
|
159
172
|
while all_remaining_tokens:
|
|
160
173
|
stripped_token = unquoted_token(all_remaining_tokens[0])
|
|
@@ -162,35 +175,60 @@ def _all_tokens_from_the_first_operand(all_tokens: list[str]) -> list[str]:
|
|
|
162
175
|
return all_remaining_tokens[1:]
|
|
163
176
|
if COMMAND_OPTION_TOKEN_PATTERN.match(stripped_token) is None:
|
|
164
177
|
return all_remaining_tokens
|
|
165
|
-
|
|
166
|
-
stripped_token, ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS
|
|
167
|
-
)
|
|
168
|
-
all_remaining_tokens = all_remaining_tokens[1 + flag_value_token_count :]
|
|
178
|
+
all_remaining_tokens = all_remaining_tokens[_option_token_count(stripped_token, grammar) :]
|
|
169
179
|
return []
|
|
170
180
|
|
|
171
181
|
|
|
172
|
-
def
|
|
173
|
-
|
|
182
|
+
def _wrapper_basename(token: str) -> str:
|
|
183
|
+
return token_basename(unquoted_token(token)).removesuffix(WINDOWS_EXECUTABLE_SUFFIX)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _wrapper_program_index(all_segment_tokens: list[str]) -> int | None:
|
|
187
|
+
"""Return the index of the program a wrapper step reads.
|
|
188
|
+
|
|
189
|
+
A wrapper with its own option grammar is read where it stands, so the
|
|
190
|
+
``-u HOME`` behind ``env`` stays an option. Any other leading program is
|
|
191
|
+
found past assignments and plain launchers such as ``time`` and ``nohup``.
|
|
192
|
+
"""
|
|
193
|
+
for each_index, each_token in enumerate(all_segment_tokens):
|
|
194
|
+
if LEADING_ASSIGNMENT_PATTERN.match(each_token) is not None:
|
|
195
|
+
continue
|
|
196
|
+
if _wrapper_basename(each_token) in ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME:
|
|
197
|
+
return each_index
|
|
198
|
+
break
|
|
174
199
|
leading_program = effective_leading_program(all_segment_tokens)
|
|
175
200
|
if leading_program is None:
|
|
176
201
|
return None
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
202
|
+
return all_segment_tokens.index(leading_program)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _all_tokens_after_one_wrapper(all_segment_tokens: list[str]) -> list[str] | None:
|
|
206
|
+
"""Return the tokens a single leading pass-through wrapper runs, else None."""
|
|
207
|
+
leading_index = _wrapper_program_index(all_segment_tokens)
|
|
208
|
+
if leading_index is None:
|
|
209
|
+
return None
|
|
210
|
+
program_basename = _wrapper_basename(all_segment_tokens[leading_index])
|
|
211
|
+
grammar = ALL_WRAPPER_OPTION_GRAMMARS_BY_NAME.get(program_basename)
|
|
212
|
+
if grammar is not None:
|
|
213
|
+
all_operand_tokens = _all_tokens_from_the_first_operand(
|
|
214
|
+
all_segment_tokens[leading_index + 1 :], grammar
|
|
215
|
+
)
|
|
216
|
+
return all_operand_tokens[grammar.leading_operand_count :]
|
|
184
217
|
if program_basename not in ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS:
|
|
185
218
|
return None
|
|
219
|
+
all_argument_tokens = _all_tokens_from_the_first_operand(
|
|
220
|
+
all_segment_tokens[leading_index + 1 :], RUN_SUBCOMMAND_OPTION_GRAMMAR
|
|
221
|
+
)
|
|
186
222
|
if all_argument_tokens and unquoted_token(all_argument_tokens[0]) == TOOL_SUBCOMMAND_NAME:
|
|
187
|
-
all_argument_tokens = _all_tokens_from_the_first_operand(
|
|
223
|
+
all_argument_tokens = _all_tokens_from_the_first_operand(
|
|
224
|
+
all_argument_tokens[1:], RUN_SUBCOMMAND_OPTION_GRAMMAR
|
|
225
|
+
)
|
|
188
226
|
if not all_argument_tokens or unquoted_token(all_argument_tokens[0]) != RUN_SUBCOMMAND_NAME:
|
|
189
227
|
return None
|
|
190
|
-
return _all_tokens_from_the_first_operand(all_argument_tokens[1:])
|
|
228
|
+
return _all_tokens_from_the_first_operand(all_argument_tokens[1:], RUN_SUBCOMMAND_OPTION_GRAMMAR)
|
|
191
229
|
|
|
192
230
|
|
|
193
|
-
def
|
|
231
|
+
def all_tokens_after_wrappers(all_segment_tokens: list[str]) -> list[str]:
|
|
194
232
|
"""Return the segment tokens with every leading pass-through wrapper stepped over."""
|
|
195
233
|
all_remaining_tokens = all_segment_tokens
|
|
196
234
|
while True:
|
|
@@ -225,7 +263,7 @@ def segment_runs_pytest(all_segment_tokens: list[str]) -> bool:
|
|
|
225
263
|
True when the segment's program is pytest or a Python interpreter
|
|
226
264
|
running the pytest module, including through pass-through wrappers.
|
|
227
265
|
"""
|
|
228
|
-
all_unwrapped_tokens =
|
|
266
|
+
all_unwrapped_tokens = all_tokens_after_wrappers(all_segment_tokens)
|
|
229
267
|
leading_program = effective_leading_program(all_unwrapped_tokens)
|
|
230
268
|
if leading_program is None:
|
|
231
269
|
return False
|
|
@@ -297,7 +335,7 @@ def string_exec_inner_command(all_segment_tokens: list[str]) -> str | None:
|
|
|
297
335
|
The inner command string the shell executes, or None when the segment
|
|
298
336
|
is not a string-executing shell wrapper with a command string.
|
|
299
337
|
"""
|
|
300
|
-
all_unwrapped_tokens =
|
|
338
|
+
all_unwrapped_tokens = all_tokens_after_wrappers(all_segment_tokens)
|
|
301
339
|
leading_program = effective_leading_program(all_unwrapped_tokens)
|
|
302
340
|
if leading_program is None:
|
|
303
341
|
return None
|