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.
- package/CLAUDE.md +4 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -15
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +118 -0
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +490 -0
- package/hooks/blocking/test_verified_commit_gate.py +495 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +686 -0
- package/hooks/blocking/verified_commit_gate.py +535 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +221 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/orphan-css-class.md +23 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
- package/skills/autoconverge/workflow/converge.mjs +520 -57
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +518 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- 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()
|