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.
@@ -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`` and
22
- ``uvx`` take their own option flags and then the command, so the step-over drops
23
- the flags. ``uv``, ``poetry``, ``pipenv``, ``pdm``, ``hatch``, ``rye``, and
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
- "ALL_FLAG_TAKING_WRAPPER_COMMANDS",
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
- ALL_FLAG_TAKING_WRAPPER_COMMANDS: frozenset[str] = frozenset(
295
- {"sudo", "sudo.exe", "uvx", "uvx.exe"}
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
- ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS,
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 _all_tokens_from_the_first_operand(all_tokens: list[str]) -> list[str]:
157
- """Return the tokens from the first non-option one on, dropping option flags."""
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
- flag_value_token_count = _option_value_token_count(
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 _all_tokens_after_one_wrapper(all_segment_tokens: list[str]) -> list[str] | None:
173
- """Return the tokens a single leading pass-through wrapper runs, else None."""
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
- program_basename = token_basename(unquoted_token(leading_program))
178
- leading_index = all_segment_tokens.index(leading_program)
179
- all_argument_tokens = _all_tokens_from_the_first_operand(
180
- all_segment_tokens[leading_index + 1 :]
181
- )
182
- if program_basename in ALL_FLAG_TAKING_WRAPPER_COMMANDS:
183
- return all_argument_tokens
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(all_argument_tokens[1:])
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 _all_tokens_after_wrappers(all_segment_tokens: list[str]) -> list[str]:
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 = _all_tokens_after_wrappers(all_segment_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 = _all_tokens_after_wrappers(all_segment_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