claude-dev-env 1.59.0 → 1.61.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.
Files changed (81) hide show
  1. package/CLAUDE.md +4 -0
  2. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  3. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  4. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  6. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  7. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  8. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  9. package/docs/CODE_RULES.md +2 -2
  10. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  11. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  12. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  13. package/hooks/blocking/code_rules_enforcer.py +38 -15
  14. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  15. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  16. package/hooks/blocking/config/__init__.py +5 -0
  17. package/hooks/blocking/config/verified_commit_constants.py +118 -0
  18. package/hooks/blocking/destructive_command_blocker.py +483 -61
  19. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  20. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  24. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  25. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  26. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +490 -0
  30. package/hooks/blocking/test_verified_commit_gate.py +495 -0
  31. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
  33. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  34. package/hooks/blocking/verification_verdict_store.py +686 -0
  35. package/hooks/blocking/verified_commit_gate.py +535 -0
  36. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  37. package/hooks/blocking/verifier_verdict_minter.py +221 -0
  38. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  39. package/hooks/hooks.json +43 -1
  40. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  42. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  43. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  44. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  45. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  46. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  47. package/hooks/validation/mypy_validator.py +59 -7
  48. package/hooks/validation/test_mypy_validator.py +94 -0
  49. package/package.json +1 -1
  50. package/rules/file-global-constants.md +7 -1
  51. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  52. package/rules/orphan-css-class.md +23 -0
  53. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  54. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  55. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  56. package/skills/autoconverge/SKILL.md +54 -17
  57. package/skills/autoconverge/reference/closing-report.md +59 -17
  58. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
  60. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
  62. package/skills/autoconverge/workflow/converge.mjs +520 -57
  63. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  66. package/skills/autoconverge/workflow/render_report.py +488 -397
  67. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  68. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  69. package/skills/autoconverge/workflow/test_render_report.py +518 -259
  70. package/skills/pr-converge/reference/per-tick.md +28 -8
  71. package/skills/rebase/SKILL.md +2 -4
  72. package/system-prompts/software-engineer.xml +2 -6
  73. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  74. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  75. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  76. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  77. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  78. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  79. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  80. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  81. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -0,0 +1,667 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse guard: deny shell access to the verification verdict directory.
3
+
4
+ The verified-commit gate trusts a single invariant — only
5
+ ``verifier_verdict_minter.py`` (a SubagentStop hook running in-process)
6
+ writes verdict files. settings.json denies the Write/Edit/MultiEdit file
7
+ tools against ``~/.claude/verification/**``, but a Bash or PowerShell
8
+ command reaches the same directory through ``python -c``, a redirect, or an
9
+ ``Out-File``/``Set-Content`` cmdlet. A forged verdict only needs the file to
10
+ exist with ``all_pass`` true and the live manifest hash — both values the
11
+ session can derive — so an unguarded shell write defeats the whole gate.
12
+
13
+ This guard fires on Bash and PowerShell tool calls and denies any command
14
+ whose text references the verdict directory — whether by an absolute
15
+ ``.claude/verification/`` path, by changing into the Claude home directory
16
+ and then writing a relative ``verification/`` path, by naming a verdict
17
+ file's ``verification/<root-key>.json`` shape directly, or by pairing a
18
+ path-obfuscation primitive (``chr(``, ``bytes.fromhex(``, base64 decode,
19
+ ``codecs.decode(``, a ``bytes([...])``/``bytearray([...])`` int-list
20
+ constructor) with a file write so a path assembled from codes cannot
21
+ slip past the literal matchers. No legitimate workflow reaches that
22
+ directory through a shell: the minter runs in-process and the gate reads
23
+ verdicts in-process, so blocking every shell reference is the fail-closed
24
+ stance that keeps the verdict store unforgeable.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import binascii
31
+ import json
32
+ import re
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ blocking_directory = str(Path(__file__).resolve().parent)
37
+ if blocking_directory not in sys.path:
38
+ sys.path.insert(0, blocking_directory)
39
+
40
+ from config.verified_commit_constants import (
41
+ ALL_GATED_TOOL_NAMES,
42
+ ALL_VERDICT_PATH_SEGMENT_BODIES,
43
+ ALL_VERDICT_PATH_SEGMENT_NAMES,
44
+ BASE64_TOKEN_PATTERN,
45
+ CHARACTER_CODE_SEQUENCE_PATTERN,
46
+ CHR_CALL_CHAIN_PATTERN,
47
+ CHR_CALL_CODE_PATTERN,
48
+ CLAUDE_HOME_DIRECTORY_NAME,
49
+ CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
50
+ COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN,
51
+ DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN,
52
+ DIRECTORY_CHANGE_OPTION_TERMINATOR,
53
+ DIRECTORY_CHANGE_PATH_OPTIONS,
54
+ DIRECTORY_CHANGE_PATTERN_PREFIX,
55
+ DIRECTORY_CHANGE_PATTERN_SUFFIX,
56
+ DIRECTORY_CHANGE_TARGET_PATTERN,
57
+ DIRECTORY_CHANGE_VERBS,
58
+ FILE_WRITE_PRIMITIVE_PATTERN,
59
+ HEX_DIGITS_PER_BYTE,
60
+ HEX_TOKEN_PATTERN,
61
+ NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
62
+ PATH_OBFUSCATION_PRIMITIVE_PATTERN,
63
+ RELATIVE_VERDICT_DIRECTORY_PATTERN,
64
+ VERDICT_DIRECTORY_GUARD_MESSAGE,
65
+ VERDICT_DIRECTORY_NAME,
66
+ VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN,
67
+ VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
68
+ VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
69
+ VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
70
+ VERDICT_FILE_RELATIVE_REFERENCE_PATTERN,
71
+ VERDICT_PATH_GLUE_PATTERN,
72
+ VERDICT_PATH_JOINED_VARIABLE_PATTERN,
73
+ VERDICT_PATH_VARIABLE_ASSIGNMENT_PATTERN,
74
+ WRITE_CALL_REGION_PATTERN,
75
+ )
76
+
77
+
78
+ def _directory_change_verbs_pattern() -> str:
79
+ """Build the alternation of directory-change verbs for a change matcher.
80
+
81
+ Returns:
82
+ The regex alternation of escaped directory-change verbs in sorted
83
+ order, ready to sit between the change prefix and suffix patterns.
84
+ """
85
+ return "|".join(re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS))
86
+
87
+
88
+ def _directory_change_option_prefix_pattern() -> str:
89
+ """Build the optional path-option prefix that may precede a change target.
90
+
91
+ A PowerShell directory change names its destination after a path flag
92
+ (``Set-Location -Path ~/.claude/verification``,
93
+ ``Set-Location -LiteralPath ...``), and a POSIX ``cd`` may guard the
94
+ destination with the ``--`` end-of-options terminator
95
+ (``cd -- ~/.claude/verification``). Consuming any run of those tokens
96
+ before the target keeps the flag from being read as the destination, so
97
+ the change matchers see the real path the same way the sibling gate's
98
+ ``_directory_change_target`` token-walk does.
99
+
100
+ Returns:
101
+ The regex matching zero or more leading path-option or terminator
102
+ tokens, ready to sit between the change suffix and target patterns.
103
+ """
104
+ option_alternation = "|".join(
105
+ re.escape(each_option)
106
+ for each_option in sorted(DIRECTORY_CHANGE_PATH_OPTIONS | {DIRECTORY_CHANGE_OPTION_TERMINATOR})
107
+ )
108
+ return DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN % option_alternation
109
+
110
+
111
+ def _references_absolute_verdict_path(command_text: str) -> bool:
112
+ """Decide whether a command names an absolute ``.claude/verification/`` path.
113
+
114
+ Matches the Claude-home/verdict-directory name pair joined by any run of
115
+ path separators, quotes, commas, or whitespace, anchored by a trailing
116
+ path separator. That separator class catches every home prefix form
117
+ (``~``, ``$HOME``, an expanded absolute path), either path separator, and
118
+ the ``joinpath('.claude', 'verification', ...)`` / ``os.path.join``
119
+ spellings; the trailing path boundary keeps sibling directories
120
+ (``verification-docs``) and bare prose mentions of the path from matching.
121
+
122
+ Args:
123
+ command_text: The raw command string from the tool payload.
124
+
125
+ Returns:
126
+ True when the command names an absolute verdict-directory path.
127
+ """
128
+ verdict_directory_pattern = re.compile(
129
+ re.escape(CLAUDE_HOME_DIRECTORY_NAME)
130
+ + VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN
131
+ + re.escape(VERDICT_DIRECTORY_NAME)
132
+ + VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
133
+ re.IGNORECASE,
134
+ )
135
+ return verdict_directory_pattern.search(command_text) is not None
136
+
137
+
138
+ def _changes_into_claude_home_then_writes_relative(command_text: str) -> bool:
139
+ """Decide whether a command enters the Claude home then writes ``verification/``.
140
+
141
+ A ``cd ~/.claude`` (or ``pushd``/``Set-Location``/``sl`` into any path
142
+ ending in ``.claude``) followed by a relative ``verification/`` write
143
+ lands in the verdict directory without ever naming the two segments
144
+ adjacently, so the absolute-path matcher alone misses it.
145
+
146
+ Args:
147
+ command_text: The raw command string from the tool payload.
148
+
149
+ Returns:
150
+ True when a directory change into ``.claude`` precedes a relative
151
+ verdict-directory reference.
152
+ """
153
+ directory_change_into_claude_pattern = re.compile(
154
+ DIRECTORY_CHANGE_PATTERN_PREFIX
155
+ + _directory_change_verbs_pattern()
156
+ + DIRECTORY_CHANGE_PATTERN_SUFFIX
157
+ + _directory_change_option_prefix_pattern()
158
+ + DIRECTORY_CHANGE_TARGET_PATTERN
159
+ + re.escape(CLAUDE_HOME_DIRECTORY_NAME)
160
+ + CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
161
+ re.IGNORECASE,
162
+ )
163
+ change_match = directory_change_into_claude_pattern.search(command_text)
164
+ if change_match is None:
165
+ return False
166
+ relative_verdict_pattern = re.compile(RELATIVE_VERDICT_DIRECTORY_PATTERN, re.IGNORECASE)
167
+ return relative_verdict_pattern.search(command_text, change_match.end()) is not None
168
+
169
+
170
+ def _changes_into_verdict_directory_then_writes(command_text: str) -> bool:
171
+ """Decide whether a command enters the verdict directory then runs a command.
172
+
173
+ A ``cd ~/.claude/verification`` (or ``pushd``/``Set-Location``/``sl`` into
174
+ any path ending in ``.claude/verification``, with or without a trailing
175
+ separator) followed by any further command lands a forged verdict in the
176
+ verdict directory without naming the ``verification/`` prefix on the file,
177
+ so the other matchers miss it. The trust contract denies every shell
178
+ command that reaches the verdict directory, so any command after the
179
+ change — a redirect, ``cp``, ``mv``, ``tee``, ``install``, or a
180
+ ``python -c`` write — is a forge vector. Matching any command separator
181
+ followed by content keeps the guard write-agnostic rather than chasing an
182
+ open-ended verb list.
183
+
184
+ Args:
185
+ command_text: The raw command string from the tool payload.
186
+
187
+ Returns:
188
+ True when a directory change into the verdict directory precedes any
189
+ further command.
190
+ """
191
+ directory_change_into_verdict_pattern = re.compile(
192
+ DIRECTORY_CHANGE_PATTERN_PREFIX
193
+ + _directory_change_verbs_pattern()
194
+ + DIRECTORY_CHANGE_PATTERN_SUFFIX
195
+ + _directory_change_option_prefix_pattern()
196
+ + DIRECTORY_CHANGE_TARGET_PATTERN
197
+ + re.escape(CLAUDE_HOME_DIRECTORY_NAME)
198
+ + r"[\\/]"
199
+ + re.escape(VERDICT_DIRECTORY_NAME)
200
+ + VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
201
+ re.IGNORECASE,
202
+ )
203
+ change_match = directory_change_into_verdict_pattern.search(command_text)
204
+ if change_match is None:
205
+ return False
206
+ command_after_change_pattern = re.compile(COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN)
207
+ return command_after_change_pattern.search(command_text, change_match.end()) is not None
208
+
209
+
210
+ def _references_verdict_file_shape(command_text: str) -> bool:
211
+ """Decide whether a command names a verdict file's ``verification/<key>.json`` shape.
212
+
213
+ A verdict file name is ``verification/<root-key-hex>.json``, where the
214
+ root key is the leading hex digits of a repo-path digest. Naming that
215
+ shape relative to any directory is a forge vector on its own, so it is
216
+ flagged regardless of a preceding directory change.
217
+
218
+ Args:
219
+ command_text: The raw command string from the tool payload.
220
+
221
+ Returns:
222
+ True when the command names the verdict-file shape.
223
+ """
224
+ verdict_file_pattern = re.compile(VERDICT_FILE_RELATIVE_REFERENCE_PATTERN, re.IGNORECASE)
225
+ return verdict_file_pattern.search(command_text) is not None
226
+
227
+
228
+ def _decoded_texts_from_command(command_text: str) -> list[str]:
229
+ """Decode every hex, base64, character-code, and chr-chain token a command embeds.
230
+
231
+ A path-obfuscation forge hides ``.claude`` or ``verification`` inside hex
232
+ digits, a base64 token, a comma-separated character-code sequence, or a
233
+ run of ``+``-joined ``chr(<int>)`` calls. Decoding each token back to text
234
+ lets the segment matcher see the hidden name. Tokens that fail to decode or
235
+ carry an out-of-range code are skipped.
236
+
237
+ Args:
238
+ command_text: The raw command string from the tool payload.
239
+
240
+ Returns:
241
+ The lowercase decoded text for every token that decodes cleanly.
242
+ """
243
+ all_decoded_texts: list[str] = []
244
+ for each_hex_token in re.findall(HEX_TOKEN_PATTERN, command_text, re.IGNORECASE):
245
+ decoded_hex_text = _decode_hex_token(each_hex_token)
246
+ if decoded_hex_text:
247
+ all_decoded_texts.append(decoded_hex_text.lower())
248
+ for each_base64_token in re.findall(BASE64_TOKEN_PATTERN, command_text):
249
+ decoded_base64_text = _decode_base64_token(each_base64_token)
250
+ if decoded_base64_text:
251
+ all_decoded_texts.append(decoded_base64_text.lower())
252
+ for each_code_sequence in re.findall(CHARACTER_CODE_SEQUENCE_PATTERN, command_text):
253
+ decoded_code_text = _decode_character_code_sequence(each_code_sequence)
254
+ if decoded_code_text:
255
+ all_decoded_texts.append(decoded_code_text.lower())
256
+ for each_chr_chain in re.findall(CHR_CALL_CHAIN_PATTERN, command_text, re.IGNORECASE):
257
+ decoded_chain_text = _decode_chr_call_chain(each_chr_chain)
258
+ if decoded_chain_text:
259
+ all_decoded_texts.append(decoded_chain_text.lower())
260
+ return all_decoded_texts
261
+
262
+
263
+ def _decode_hex_token(hex_token: str) -> str:
264
+ """Decode a hex-digit token to text, returning empty text on failure.
265
+
266
+ Args:
267
+ hex_token: A run of hex digits taken from the command.
268
+
269
+ Returns:
270
+ The decoded ASCII text, or empty text when the token is odd-length,
271
+ not valid hex, or not decodable as ASCII.
272
+ """
273
+ if len(hex_token) % HEX_DIGITS_PER_BYTE == 1:
274
+ return ""
275
+ try:
276
+ return bytes.fromhex(hex_token).decode("ascii")
277
+ except (ValueError, binascii.Error):
278
+ return ""
279
+
280
+
281
+ def _decode_base64_token(base64_token: str) -> str:
282
+ """Decode a base64 token to text, returning empty text on failure.
283
+
284
+ Args:
285
+ base64_token: A base64-shaped token taken from the command.
286
+
287
+ Returns:
288
+ The decoded ASCII text, or empty text when the token is not valid
289
+ base64 or not decodable as ASCII.
290
+ """
291
+ try:
292
+ return base64.b64decode(base64_token, validate=True).decode("ascii")
293
+ except (ValueError, binascii.Error):
294
+ return ""
295
+
296
+
297
+ def _decode_character_code_sequence(code_sequence: str) -> str:
298
+ """Decode a comma-separated character-code sequence to text.
299
+
300
+ The ``CHARACTER_CODE_SEQUENCE_PATTERN`` source caps each code at three
301
+ decimal digits, so every code is a valid ``chr`` argument and decoding
302
+ always succeeds.
303
+
304
+ Args:
305
+ code_sequence: A comma-separated run of decimal character codes, each
306
+ within the three-digit range the source pattern admits.
307
+
308
+ Returns:
309
+ The decoded text.
310
+ """
311
+ decoded_characters: list[str] = []
312
+ for each_code in code_sequence.split(","):
313
+ code_point = int(each_code.strip())
314
+ decoded_characters.append(chr(code_point))
315
+ return "".join(decoded_characters)
316
+
317
+
318
+ def _decode_chr_call_chain(chr_chain: str) -> str:
319
+ """Decode a run of ``+``-joined ``chr(<int>)`` calls to text.
320
+
321
+ A forge assembles ``/.claude/verification/`` from a run of
322
+ ``chr(<code>)+chr(<code>)+...`` calls so the path carries no comma-separated
323
+ code list and no literal segment name. The ``CHR_CALL_CHAIN_PATTERN``
324
+ source caps each code at three decimal digits, so every captured int is a
325
+ valid ``chr`` argument and decoding always succeeds.
326
+
327
+ Args:
328
+ chr_chain: A run of two or more ``+``-joined ``chr(<int>)`` calls
329
+ taken from the command.
330
+
331
+ Returns:
332
+ The decoded text.
333
+ """
334
+ decoded_characters: list[str] = []
335
+ for each_code in re.findall(CHR_CALL_CODE_PATTERN, chr_chain, re.IGNORECASE):
336
+ decoded_characters.append(chr(int(each_code)))
337
+ return "".join(decoded_characters)
338
+
339
+
340
+ def _decoded_token_references_verdict_segment(command_text: str) -> bool:
341
+ """Decide whether a decodable token in a command hides a verdict-path segment.
342
+
343
+ A forge hides ``.claude`` or ``verification`` inside a hex, base64,
344
+ character-code, or ``+``-joined ``chr(<int>)`` chain token
345
+ (``bytes.fromhex('2e636c61756465')`` and a ``chr(<code>)+chr(<code>)+...``
346
+ run both decode to a ``.claude`` segment). Such a token carries no
347
+ incidental meaning — text that decodes to a verdict-path segment body
348
+ (``claude``) is forge evidence wherever it sits in the command, so this
349
+ check is not scoped to the write call.
350
+
351
+ Args:
352
+ command_text: The raw command string from the tool payload.
353
+
354
+ Returns:
355
+ True when a decodable token decodes to a verdict-path segment body.
356
+ """
357
+ all_decoded_texts = _decoded_texts_from_command(command_text)
358
+ for each_decoded_text in all_decoded_texts:
359
+ for each_segment_body in ALL_VERDICT_PATH_SEGMENT_BODIES:
360
+ if each_segment_body in each_decoded_text:
361
+ return True
362
+ return False
363
+
364
+
365
+ def _write_call_region_names_verdict_segment(command_text: str) -> bool:
366
+ """Decide whether a write call's argument region names a verdict-path segment.
367
+
368
+ The region runs from a non-redirect write primitive (``open(``,
369
+ ``write_text``, ``Out-File``, ``Set-Content``, ``Add-Content``, ``tee``)
370
+ to the next statement separator (``;``, ``|``, ``&``, newline) or the end
371
+ of the command. A plain-text ``.claude`` or ``verification`` inside that
372
+ region is part of the path being written, so it is a forge signal; the
373
+ same word in a later statement (``open(chr(...)+chr(...));
374
+ print('verification done')``) sits past the separator and does not count.
375
+
376
+ Args:
377
+ command_text: The raw command string from the tool payload.
378
+
379
+ Returns:
380
+ True when a verdict-path segment name appears inside a write call's
381
+ argument region.
382
+ """
383
+ write_call_region_pattern = re.compile(WRITE_CALL_REGION_PATTERN, re.IGNORECASE)
384
+ for each_region_match in write_call_region_pattern.finditer(command_text):
385
+ lowercased_region = each_region_match.group(0).lower()
386
+ for each_segment_name in ALL_VERDICT_PATH_SEGMENT_NAMES:
387
+ if each_segment_name in lowercased_region:
388
+ return True
389
+ return False
390
+
391
+
392
+ def _uses_obfuscated_path_write(command_text: str) -> bool:
393
+ """Decide whether a command builds a verdict path with obfuscation and writes.
394
+
395
+ A character-construction primitive (``chr(``, ``bytes.fromhex(``, base64
396
+ decode, ``codecs.decode(``, a ``bytes([...])``/``bytearray([...])``
397
+ int-list constructor) assembles a path from codes that carry no
398
+ literal ``verification`` or ``.claude`` substring, so the text-pattern
399
+ matchers miss it. The pairing fires only when three conditions hold
400
+ together: an obfuscation primitive, a non-redirect write call (``open(``,
401
+ ``write_text``, ``Out-File``, ``Set-Content``, ``Add-Content``, ``tee``),
402
+ and a verdict-path segment reachable by that write — either a decodable
403
+ token that decodes to a segment body anywhere in the command, or a
404
+ plain-text segment name inside the write call's argument region.
405
+
406
+ Scoping the plain-text segment to the write region leaves an unrelated
407
+ decode-and-print one-liner whose write targets another path
408
+ (``open(chr(...)+chr(...)); print('verification done')``) alone, because
409
+ the incidental ``verification`` sits in a later statement rather than the
410
+ write call. A decode-to-file one-liner with no segment
411
+ (``open('decoded.bin','wb').write(b64decode(x))``) is also left alone,
412
+ while a forge whose path decodes to the verdict directory is still caught.
413
+ A bare ``>`` redirect to the verdict path is caught by the literal-path
414
+ matchers, so a command whose only write is a redirect is left alone here.
415
+
416
+ Args:
417
+ command_text: The raw command string from the tool payload.
418
+
419
+ Returns:
420
+ True when the command pairs a path-obfuscation primitive with a
421
+ non-redirect file write and a verdict-path segment reachable by that
422
+ write.
423
+ """
424
+ has_obfuscation_primitive = re.search(PATH_OBFUSCATION_PRIMITIVE_PATTERN, command_text) is not None
425
+ has_non_redirect_write = (
426
+ re.search(NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
427
+ )
428
+ if not (has_obfuscation_primitive and has_non_redirect_write):
429
+ return False
430
+ if _decoded_token_references_verdict_segment(command_text):
431
+ return True
432
+ return _write_call_region_names_verdict_segment(command_text)
433
+
434
+
435
+ def _segments_join_as_verdict_path(command_text: str) -> bool:
436
+ """Decide whether the two name segments sit adjacent in a path-join shape.
437
+
438
+ The ``.claude`` home name and the ``verification`` directory name connect
439
+ through only path-join glue — quotes, a ``+`` concatenation operator,
440
+ whitespace, and a path separator — in either order, so a string
441
+ concatenation (``'/.claude'+'/verification'``) reads as one verdict path
442
+ while a prose mention (``.claude docs about the verification flow``) does
443
+ not, because the words between the segments are not path-join glue.
444
+
445
+ Args:
446
+ command_text: The raw command string from the tool payload.
447
+
448
+ Returns:
449
+ True when the two segments connect through a path-join shape.
450
+ """
451
+ home_name = re.escape(CLAUDE_HOME_DIRECTORY_NAME)
452
+ verdict_name = re.escape(VERDICT_DIRECTORY_NAME)
453
+ path_join_pattern = re.compile(
454
+ home_name + VERDICT_PATH_GLUE_PATTERN + verdict_name
455
+ + "|"
456
+ + verdict_name + VERDICT_PATH_GLUE_PATTERN + home_name,
457
+ re.IGNORECASE,
458
+ )
459
+ return path_join_pattern.search(command_text) is not None
460
+
461
+
462
+ def _segments_join_through_shell_variable(command_text: str) -> bool:
463
+ """Decide whether a shell variable path-joins the two name segments.
464
+
465
+ One segment binds to a shell variable (``p=~/.claude``,
466
+ ``VDIR=verification``) whose ``$p``/``$VDIR`` reference is then
467
+ path-joined toward the verdict directory (``$p/verification``,
468
+ ``$VDIR/a.json``); the other segment appears elsewhere in the command. A
469
+ redirect to ``/tmp/notes.txt`` carries no path-joined variable reference,
470
+ so a benign mention of both words passes.
471
+
472
+ Args:
473
+ command_text: The raw command string from the tool payload.
474
+
475
+ Returns:
476
+ True when a path-joined variable reference binds one segment while the
477
+ other segment appears in the command.
478
+ """
479
+ lowercased_command = command_text.lower()
480
+ home_name = CLAUDE_HOME_DIRECTORY_NAME.lower()
481
+ verdict_name = VERDICT_DIRECTORY_NAME.lower()
482
+ joined_variable_pattern = re.compile(VERDICT_PATH_JOINED_VARIABLE_PATTERN)
483
+ for each_prefix_name, each_suffix_name in joined_variable_pattern.findall(command_text):
484
+ variable_name = each_prefix_name or each_suffix_name
485
+ assignment_pattern = re.compile(
486
+ VERDICT_PATH_VARIABLE_ASSIGNMENT_PATTERN % re.escape(variable_name)
487
+ )
488
+ assignment_match = assignment_pattern.search(command_text)
489
+ if assignment_match is None:
490
+ continue
491
+ assigned_value = assignment_match.group(1).lower()
492
+ binds_home = home_name in assigned_value
493
+ binds_verdict = verdict_name in assigned_value
494
+ if binds_home and verdict_name in lowercased_command:
495
+ return True
496
+ if binds_verdict and home_name in lowercased_command:
497
+ return True
498
+ return False
499
+
500
+
501
+ def _changes_through_split_directory_change_into_verdict(command_text: str) -> bool:
502
+ """Decide whether a split directory change reaches the verdict directory then runs a command.
503
+
504
+ A change into the Claude home (``cd ~/.claude``) followed by a change into
505
+ a relative ``verification`` directory (``cd verification``) lands in the
506
+ verdict directory without naming the two segments adjacently. The trust
507
+ contract denies every shell command that reaches the verdict directory, so
508
+ any command after the second change — a redirect, ``cp``, ``mv``, ``tee``,
509
+ ``install``, or a ``python -c`` write — is a forge vector, mirroring the
510
+ single-step ``cd ~/.claude/verification`` case. An unrelated second change
511
+ (``cd hooks``) lands elsewhere and passes.
512
+
513
+ Args:
514
+ command_text: The raw command string from the tool payload.
515
+
516
+ Returns:
517
+ True when a change into the Claude home precedes a change into a
518
+ relative verdict directory that is itself followed by any further
519
+ command.
520
+ """
521
+ change_into_claude_pattern = re.compile(
522
+ DIRECTORY_CHANGE_PATTERN_PREFIX
523
+ + _directory_change_verbs_pattern()
524
+ + DIRECTORY_CHANGE_PATTERN_SUFFIX
525
+ + _directory_change_option_prefix_pattern()
526
+ + DIRECTORY_CHANGE_TARGET_PATTERN
527
+ + re.escape(CLAUDE_HOME_DIRECTORY_NAME)
528
+ + CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
529
+ re.IGNORECASE,
530
+ )
531
+ change_into_claude_match = change_into_claude_pattern.search(command_text)
532
+ if change_into_claude_match is None:
533
+ return False
534
+ change_into_verdict_pattern = re.compile(
535
+ DIRECTORY_CHANGE_PATTERN_PREFIX
536
+ + _directory_change_verbs_pattern()
537
+ + DIRECTORY_CHANGE_PATTERN_SUFFIX
538
+ + _directory_change_option_prefix_pattern()
539
+ + VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
540
+ re.IGNORECASE,
541
+ )
542
+ change_into_verdict_match = change_into_verdict_pattern.search(
543
+ command_text, change_into_claude_match.end()
544
+ )
545
+ if change_into_verdict_match is None:
546
+ return False
547
+ command_after_change_pattern = re.compile(COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN)
548
+ return (
549
+ command_after_change_pattern.search(command_text, change_into_verdict_match.end())
550
+ is not None
551
+ )
552
+
553
+
554
+ def _writes_with_verdict_path_intent(command_text: str) -> bool:
555
+ """Decide whether a write builds a verdict path from joined segments.
556
+
557
+ A path split across string concatenation (``'/.claude'+'/verification'``)
558
+ or a shell variable (``$p/verification``, ``$VDIR/a.json``) lands in the
559
+ verdict directory while breaking the adjacency the literal-path matchers
560
+ require, yet the two name segments still connect through a path-join shape.
561
+ The literal-concatenation shape is paired with a non-redirect write
562
+ (``open(``, ``write_text``, ``Out-File``, ``Set-Content``, ``Add-Content``,
563
+ ``tee``): a bare ``>`` redirect to an adjacent ``.claude/verification``
564
+ literal is already caught by the absolute-path matcher, so requiring a
565
+ non-redirect write here leaves a commit message that merely quotes the
566
+ path before a redirect (``git commit -m "fix .claude/verification gate"
567
+ > /tmp/commit.log``) alone. The shell-variable shape is paired with any
568
+ write primitive, including a redirect, because ``echo x > $p/verification``
569
+ is itself the forge. A command whose two name segments only co-occur as
570
+ free prose inside a quoted message
571
+ (``echo "updated .claude docs about the verification flow"
572
+ > /tmp/notes.txt``) carries no path-join shape and passes, as does a plain
573
+ ``git commit -m "fix .claude/verification gate"`` with no write primitive.
574
+
575
+ Args:
576
+ command_text: The raw command string from the tool payload.
577
+
578
+ Returns:
579
+ True when a non-redirect write co-occurs with a literal concatenation
580
+ of the two verdict-path name segments, or when any write co-occurs
581
+ with a shell variable that path-joins the two segments.
582
+ """
583
+ has_non_redirect_write = (
584
+ re.search(NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
585
+ )
586
+ if has_non_redirect_write and _segments_join_as_verdict_path(command_text):
587
+ return True
588
+ has_write_primitive = re.search(FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
589
+ if not has_write_primitive:
590
+ return False
591
+ return _segments_join_through_shell_variable(command_text)
592
+
593
+
594
+ def references_verdict_directory(command_text: str) -> bool:
595
+ """Decide whether a command references the verification verdict directory.
596
+
597
+ Args:
598
+ command_text: The raw command string from the tool payload.
599
+
600
+ Returns:
601
+ True when the command names an absolute verdict path, changes into
602
+ the Claude home before a relative verdict write, changes directly
603
+ into the verdict directory before any command, steps through a split
604
+ directory change into the verdict directory before any command, names
605
+ a verdict file's ``verification/<root-key>.json`` shape, pairs a
606
+ path-obfuscation primitive with a non-redirect file write, or pairs a
607
+ file write with a path-join shape connecting both verdict-path name
608
+ segments.
609
+ """
610
+ if _references_absolute_verdict_path(command_text):
611
+ return True
612
+ if _changes_into_claude_home_then_writes_relative(command_text):
613
+ return True
614
+ if _changes_into_verdict_directory_then_writes(command_text):
615
+ return True
616
+ if _changes_through_split_directory_change_into_verdict(command_text):
617
+ return True
618
+ if _references_verdict_file_shape(command_text):
619
+ return True
620
+ if _writes_with_verdict_path_intent(command_text):
621
+ return True
622
+ return _uses_obfuscated_path_write(command_text)
623
+
624
+
625
+ def decision_for_payload(pretooluse_payload: dict) -> dict | None:
626
+ """Build the deny decision for a verdict-directory shell access.
627
+
628
+ Args:
629
+ pretooluse_payload: The PreToolUse hook payload.
630
+
631
+ Returns:
632
+ The deny decision mapping when a gated shell command references the
633
+ verdict directory; None when the command may proceed.
634
+ """
635
+ if pretooluse_payload.get("tool_name", "") not in ALL_GATED_TOOL_NAMES:
636
+ return None
637
+ command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
638
+ if not command_text:
639
+ return None
640
+ if not references_verdict_directory(command_text):
641
+ return None
642
+ return {
643
+ "hookSpecificOutput": {
644
+ "hookEventName": "PreToolUse",
645
+ "permissionDecision": "deny",
646
+ "permissionDecisionReason": VERDICT_DIRECTORY_GUARD_MESSAGE,
647
+ }
648
+ }
649
+
650
+
651
+ def main() -> None:
652
+ """Read the PreToolUse payload and deny verdict-directory shell access."""
653
+ try:
654
+ pretooluse_payload = json.load(sys.stdin)
655
+ except json.JSONDecodeError:
656
+ return
657
+ if not isinstance(pretooluse_payload, dict):
658
+ return
659
+ deny_decision = decision_for_payload(pretooluse_payload)
660
+ if deny_decision is None:
661
+ return
662
+ print(json.dumps(deny_decision))
663
+ sys.stdout.flush()
664
+
665
+
666
+ if __name__ == "__main__":
667
+ main()