claude-dev-env 2.3.0 → 2.4.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 +3 -2
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +3 -3
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +13 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -903
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +27 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/team-advisor/SKILL.md +2 -2
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse guard: deny shell and file-tool access to the stamp directory.
|
|
3
|
+
|
|
4
|
+
The push and PR-create gates trust a single invariant: only
|
|
5
|
+
``invoke_code_review.py --record-stamp`` mints stamp files under
|
|
6
|
+
``~/.claude/code-review-stamps/``. settings.json cannot be the only shipped
|
|
7
|
+
protection, so this guard fires on both surfaces:
|
|
8
|
+
|
|
9
|
+
- Bash / PowerShell: deny any command that names the stamp directory (absolute
|
|
10
|
+
path, cd-then-relative write, cd into the stamp directory, a split directory
|
|
11
|
+
change through the Claude home into the stamp directory, stamp-file shape, a
|
|
12
|
+
path-join write, or a path assembled by obfuscation that decodes to a stamp
|
|
13
|
+
segment), or that imports the stamp store module or calls its mint function.
|
|
14
|
+
- Write / Edit / MultiEdit: deny any path under the stamp directory.
|
|
15
|
+
|
|
16
|
+
No legitimate workflow reaches that directory through a shell or the file
|
|
17
|
+
tools: the invoker writes in-process after a clean review, so the sanctioned
|
|
18
|
+
minter command names none of these matchers and passes without an allowlist.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
30
|
+
if _blocking_directory not in sys.path:
|
|
31
|
+
sys.path.insert(0, _blocking_directory)
|
|
32
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
33
|
+
if _hooks_directory not in sys.path:
|
|
34
|
+
sys.path.append(_hooks_directory)
|
|
35
|
+
|
|
36
|
+
from code_review_enforcement_config_bootstrap import (
|
|
37
|
+
register_code_review_enforcement_constants,
|
|
38
|
+
)
|
|
39
|
+
from verified_commit_config_bootstrap import register_verified_commit_constants
|
|
40
|
+
|
|
41
|
+
register_code_review_enforcement_constants()
|
|
42
|
+
register_verified_commit_constants()
|
|
43
|
+
|
|
44
|
+
from code_review_stamp_store import stamp_directory
|
|
45
|
+
from code_review_stamp_write_blocker_parts.obfuscated_stamp_path_reference import (
|
|
46
|
+
references_obfuscated_stamp_path,
|
|
47
|
+
)
|
|
48
|
+
from code_review_stamp_write_blocker_parts.split_directory_change_into_stamp import (
|
|
49
|
+
changes_through_split_directory_into_stamp,
|
|
50
|
+
directory_change_prefix,
|
|
51
|
+
)
|
|
52
|
+
from config.code_review_enforcement_constants import (
|
|
53
|
+
ALL_GATED_SHELL_TOOL_NAMES,
|
|
54
|
+
ALL_STAMP_STORE_FORGE_PATTERNS,
|
|
55
|
+
ALL_WRITE_EDIT_TOOL_NAMES,
|
|
56
|
+
DENY_PERMISSION_DECISION,
|
|
57
|
+
PRE_TOOL_USE_HOOK_EVENT_NAME,
|
|
58
|
+
RELATIVE_STAMP_DIRECTORY_PATTERN,
|
|
59
|
+
STAMP_DIRECTORY_GUARD_MESSAGE,
|
|
60
|
+
STAMP_DIRECTORY_NAME,
|
|
61
|
+
STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN,
|
|
62
|
+
STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN,
|
|
63
|
+
STAMP_WRITE_BLOCKER_HOOK_MODULE_NAME,
|
|
64
|
+
)
|
|
65
|
+
from config.verified_commit_constants import (
|
|
66
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
67
|
+
CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
|
|
68
|
+
COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN,
|
|
69
|
+
DIRECTORY_CHANGE_TARGET_PATTERN,
|
|
70
|
+
NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
|
|
71
|
+
ROOT_KEY_HEX_LENGTH,
|
|
72
|
+
VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN,
|
|
73
|
+
VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
|
|
74
|
+
VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
|
|
75
|
+
VERDICT_PATH_GLUE_PATTERN,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
79
|
+
from hooks_constants.pre_tool_use_stdin import (
|
|
80
|
+
read_hook_input_dictionary_from_stdin,
|
|
81
|
+
)
|
|
82
|
+
except ImportError as import_error:
|
|
83
|
+
raise ImportError(
|
|
84
|
+
"code_review_stamp_directory_write_blocker: cannot import its dependencies; "
|
|
85
|
+
"ensure the blocking directory is importable."
|
|
86
|
+
) from import_error
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stamp_file_relative_reference_pattern() -> str:
|
|
90
|
+
"""Build the ``code-review-stamps/<root-key>.json`` stamp-file shape pattern."""
|
|
91
|
+
root_key_json_suffix = STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN % ROOT_KEY_HEX_LENGTH
|
|
92
|
+
return (
|
|
93
|
+
f"{STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN}"
|
|
94
|
+
f"{re.escape(STAMP_DIRECTORY_NAME)}{root_key_json_suffix}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _references_absolute_stamp_path(command_text: str) -> bool:
|
|
99
|
+
"""Decide whether a command names an absolute stamp-directory path."""
|
|
100
|
+
stamp_directory_pattern = re.compile(
|
|
101
|
+
re.escape(CLAUDE_HOME_DIRECTORY_NAME)
|
|
102
|
+
+ VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN
|
|
103
|
+
+ re.escape(STAMP_DIRECTORY_NAME)
|
|
104
|
+
+ VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
|
|
105
|
+
re.IGNORECASE,
|
|
106
|
+
)
|
|
107
|
+
return stamp_directory_pattern.search(command_text) is not None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _changes_into_claude_home_then_writes_relative(command_text: str) -> bool:
|
|
111
|
+
"""Decide whether a command enters the Claude home then writes the stamp dir."""
|
|
112
|
+
directory_change_into_claude_pattern = re.compile(
|
|
113
|
+
directory_change_prefix()
|
|
114
|
+
+ DIRECTORY_CHANGE_TARGET_PATTERN
|
|
115
|
+
+ re.escape(CLAUDE_HOME_DIRECTORY_NAME)
|
|
116
|
+
+ CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
|
|
117
|
+
re.IGNORECASE,
|
|
118
|
+
)
|
|
119
|
+
change_match = directory_change_into_claude_pattern.search(command_text)
|
|
120
|
+
if change_match is None:
|
|
121
|
+
return False
|
|
122
|
+
relative_stamp_pattern = re.compile(RELATIVE_STAMP_DIRECTORY_PATTERN, re.IGNORECASE)
|
|
123
|
+
return relative_stamp_pattern.search(command_text, change_match.end()) is not None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _changes_into_stamp_directory_then_writes(command_text: str) -> bool:
|
|
127
|
+
"""Decide whether a command enters the stamp directory then runs a command."""
|
|
128
|
+
directory_change_into_stamp_pattern = re.compile(
|
|
129
|
+
directory_change_prefix()
|
|
130
|
+
+ DIRECTORY_CHANGE_TARGET_PATTERN
|
|
131
|
+
+ re.escape(CLAUDE_HOME_DIRECTORY_NAME)
|
|
132
|
+
+ r"[\\/]"
|
|
133
|
+
+ re.escape(STAMP_DIRECTORY_NAME)
|
|
134
|
+
+ VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
|
|
135
|
+
re.IGNORECASE,
|
|
136
|
+
)
|
|
137
|
+
change_match = directory_change_into_stamp_pattern.search(command_text)
|
|
138
|
+
if change_match is None:
|
|
139
|
+
return False
|
|
140
|
+
command_after_change_pattern = re.compile(COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN)
|
|
141
|
+
return (
|
|
142
|
+
command_after_change_pattern.search(command_text, change_match.end())
|
|
143
|
+
is not None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _references_stamp_file_shape(command_text: str) -> bool:
|
|
148
|
+
"""Decide whether a command names a stamp file's ``<root-key>.json`` shape."""
|
|
149
|
+
stamp_file_pattern = re.compile(
|
|
150
|
+
_stamp_file_relative_reference_pattern(), re.IGNORECASE
|
|
151
|
+
)
|
|
152
|
+
return stamp_file_pattern.search(command_text) is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _segments_join_as_stamp_path(command_text: str) -> bool:
|
|
156
|
+
"""Decide whether the two name segments sit adjacent in a path-join shape."""
|
|
157
|
+
home_name = re.escape(CLAUDE_HOME_DIRECTORY_NAME)
|
|
158
|
+
stamp_name = re.escape(STAMP_DIRECTORY_NAME)
|
|
159
|
+
path_join_pattern = re.compile(
|
|
160
|
+
home_name
|
|
161
|
+
+ VERDICT_PATH_GLUE_PATTERN
|
|
162
|
+
+ stamp_name
|
|
163
|
+
+ "|"
|
|
164
|
+
+ stamp_name
|
|
165
|
+
+ VERDICT_PATH_GLUE_PATTERN
|
|
166
|
+
+ home_name,
|
|
167
|
+
re.IGNORECASE,
|
|
168
|
+
)
|
|
169
|
+
return path_join_pattern.search(command_text) is not None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _writes_with_stamp_path_intent(command_text: str) -> bool:
|
|
173
|
+
"""Decide whether a write joins the two name segments into a stamp path."""
|
|
174
|
+
has_non_redirect_write = (
|
|
175
|
+
re.search(NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
|
|
176
|
+
)
|
|
177
|
+
if not has_non_redirect_write:
|
|
178
|
+
return False
|
|
179
|
+
return _segments_join_as_stamp_path(command_text)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _references_store_module_or_mint_call(command_text: str) -> bool:
|
|
183
|
+
"""Decide whether a command imports the stamp store or calls its mint call.
|
|
184
|
+
|
|
185
|
+
::
|
|
186
|
+
|
|
187
|
+
python -c "from code_review_stamp_store import record_clean_stamp" -> True
|
|
188
|
+
python -c "record_clean_stamp(root, live_hash, 'xhigh')" -> True
|
|
189
|
+
python -m pytest test_code_review_stamp_store.py -> False
|
|
190
|
+
|
|
191
|
+
A forge that re-implements the mint in-process names no stamp path, so the
|
|
192
|
+
path matchers miss it. Matching the store import and the mint call closes
|
|
193
|
+
that gap. A pytest run naming the store test file matches none of these.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
command_text: The raw command string from the tool payload.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
True when the command imports ``code_review_stamp_store`` or calls
|
|
200
|
+
``record_clean_stamp``; False otherwise.
|
|
201
|
+
"""
|
|
202
|
+
for each_forge_pattern in ALL_STAMP_STORE_FORGE_PATTERNS:
|
|
203
|
+
if re.search(each_forge_pattern, command_text):
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def references_stamp_directory(command_text: str) -> bool:
|
|
209
|
+
"""Decide whether a command references the code-review stamp directory.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
command_text: The raw command string from the tool payload.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True when the command names the stamp directory through any matcher — a
|
|
216
|
+
store import or mint call, an absolute path, a cd-then-relative write, a
|
|
217
|
+
cd into the directory, a split directory change into the directory, a
|
|
218
|
+
stamp-file shape, a path-join write, or an obfuscation-decoded stamp
|
|
219
|
+
segment; False otherwise.
|
|
220
|
+
"""
|
|
221
|
+
if _references_store_module_or_mint_call(command_text):
|
|
222
|
+
return True
|
|
223
|
+
if _references_absolute_stamp_path(command_text):
|
|
224
|
+
return True
|
|
225
|
+
if _changes_into_claude_home_then_writes_relative(command_text):
|
|
226
|
+
return True
|
|
227
|
+
if _changes_into_stamp_directory_then_writes(command_text):
|
|
228
|
+
return True
|
|
229
|
+
if changes_through_split_directory_into_stamp(command_text):
|
|
230
|
+
return True
|
|
231
|
+
if _references_stamp_file_shape(command_text):
|
|
232
|
+
return True
|
|
233
|
+
if _writes_with_stamp_path_intent(command_text):
|
|
234
|
+
return True
|
|
235
|
+
return references_obfuscated_stamp_path(command_text)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def path_targets_stamp_directory(file_path: str) -> bool:
|
|
239
|
+
"""Decide whether a Write/Edit/MultiEdit path sits under the stamp directory.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
file_path: The path the file tool targets.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
True when the resolved path is under ``~/.claude/code-review-stamps/``.
|
|
246
|
+
"""
|
|
247
|
+
if not file_path:
|
|
248
|
+
return False
|
|
249
|
+
try:
|
|
250
|
+
resolved_target = Path(file_path).expanduser().resolve()
|
|
251
|
+
resolved_stamp_root = stamp_directory().resolve()
|
|
252
|
+
except OSError:
|
|
253
|
+
return False
|
|
254
|
+
if resolved_target == resolved_stamp_root:
|
|
255
|
+
return True
|
|
256
|
+
try:
|
|
257
|
+
resolved_target.relative_to(resolved_stamp_root)
|
|
258
|
+
except ValueError:
|
|
259
|
+
return False
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_deny_payload() -> dict[str, dict[str, str]]:
|
|
264
|
+
"""Build the PreToolUse deny payload for a stamp-directory access."""
|
|
265
|
+
return {
|
|
266
|
+
"hookSpecificOutput": {
|
|
267
|
+
"hookEventName": PRE_TOOL_USE_HOOK_EVENT_NAME,
|
|
268
|
+
"permissionDecision": DENY_PERMISSION_DECISION,
|
|
269
|
+
"permissionDecisionReason": STAMP_DIRECTORY_GUARD_MESSAGE,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _shell_decision(all_tool_input: dict[str, object]) -> dict[str, dict[str, str]] | None:
|
|
275
|
+
"""Return the deny payload when a shell command targets the stamp directory."""
|
|
276
|
+
command_text = all_tool_input.get("command", "")
|
|
277
|
+
if not isinstance(command_text, str) or not command_text:
|
|
278
|
+
return None
|
|
279
|
+
if not references_stamp_directory(command_text):
|
|
280
|
+
return None
|
|
281
|
+
return build_deny_payload()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _file_decision(all_tool_input: dict[str, object]) -> dict[str, dict[str, str]] | None:
|
|
285
|
+
"""Return the deny payload when a file-tool path targets the stamp directory."""
|
|
286
|
+
file_path = all_tool_input.get("file_path", "")
|
|
287
|
+
if not isinstance(file_path, str):
|
|
288
|
+
return None
|
|
289
|
+
if not path_targets_stamp_directory(file_path):
|
|
290
|
+
return None
|
|
291
|
+
return build_deny_payload()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def decision_for_payload(
|
|
295
|
+
all_pretooluse_payload: dict[str, object],
|
|
296
|
+
) -> dict[str, dict[str, str]] | None:
|
|
297
|
+
"""Build the deny decision for stamp-directory shell or file-tool access.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
all_pretooluse_payload: The PreToolUse hook payload.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The deny decision mapping when a gated tool targets the stamp
|
|
304
|
+
directory; None when the tool call may proceed.
|
|
305
|
+
"""
|
|
306
|
+
tool_name = all_pretooluse_payload.get("tool_name", "")
|
|
307
|
+
if not isinstance(tool_name, str):
|
|
308
|
+
return None
|
|
309
|
+
tool_input = all_pretooluse_payload.get("tool_input", {})
|
|
310
|
+
if not isinstance(tool_input, dict):
|
|
311
|
+
return None
|
|
312
|
+
if tool_name in ALL_GATED_SHELL_TOOL_NAMES:
|
|
313
|
+
return _shell_decision(tool_input)
|
|
314
|
+
if tool_name not in ALL_WRITE_EDIT_TOOL_NAMES:
|
|
315
|
+
return None
|
|
316
|
+
return _file_decision(tool_input)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main() -> None:
|
|
320
|
+
"""Read the PreToolUse payload and deny stamp-directory access."""
|
|
321
|
+
pretooluse_payload = read_hook_input_dictionary_from_stdin()
|
|
322
|
+
if pretooluse_payload is None:
|
|
323
|
+
return
|
|
324
|
+
deny_decision = decision_for_payload(pretooluse_payload)
|
|
325
|
+
if deny_decision is None:
|
|
326
|
+
return
|
|
327
|
+
raw_tool_name = pretooluse_payload.get("tool_name", "")
|
|
328
|
+
tool_name_for_log = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
329
|
+
log_hook_block(
|
|
330
|
+
calling_hook_name=STAMP_WRITE_BLOCKER_HOOK_MODULE_NAME,
|
|
331
|
+
hook_event=PRE_TOOL_USE_HOOK_EVENT_NAME,
|
|
332
|
+
block_reason=STAMP_DIRECTORY_GUARD_MESSAGE,
|
|
333
|
+
tool_name=tool_name_for_log,
|
|
334
|
+
)
|
|
335
|
+
sys.stdout.write(json.dumps(deny_decision) + "\n")
|
|
336
|
+
sys.stdout.flush()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
main()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Read and write the per-work-tree code-review stamp files.
|
|
2
|
+
|
|
3
|
+
A stamp records that a clean ``/code-review`` pass ran against an exact branch
|
|
4
|
+
surface at a given effort. Each work tree keeps one JSON file under
|
|
5
|
+
``~/.claude/code-review-stamps/`` mapping an effort token to the surface hash it
|
|
6
|
+
was earned against, so a later push or pull-request gate can ask whether a
|
|
7
|
+
clean review at the needed effort already covers the live surface. This module
|
|
8
|
+
only reads and writes those files; it runs no review.
|
|
9
|
+
|
|
10
|
+
::
|
|
11
|
+
|
|
12
|
+
record_clean_stamp(root, hash_a, "low")
|
|
13
|
+
stamp_covers_surface(root, hash_a, "low") -> True (recorded, matching)
|
|
14
|
+
stamp_covers_surface(root, hash_a, "xhigh") -> False (low is below xhigh)
|
|
15
|
+
stamp_covers_surface(root, hash_b, "low") -> False (surface hash differs)
|
|
16
|
+
|
|
17
|
+
Coverage fails closed: a missing, unreadable, or malformed stamp file, a hash
|
|
18
|
+
that does not match, and an effort below the threshold all read as not covered.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from code_review_enforcement_config_bootstrap import (
|
|
32
|
+
register_code_review_enforcement_constants,
|
|
33
|
+
)
|
|
34
|
+
from verified_commit_config_bootstrap import register_verified_commit_constants
|
|
35
|
+
except ModuleNotFoundError:
|
|
36
|
+
if _blocking_directory not in sys.path:
|
|
37
|
+
sys.path.insert(0, _blocking_directory)
|
|
38
|
+
from code_review_enforcement_config_bootstrap import (
|
|
39
|
+
register_code_review_enforcement_constants,
|
|
40
|
+
)
|
|
41
|
+
from verified_commit_config_bootstrap import register_verified_commit_constants
|
|
42
|
+
|
|
43
|
+
register_code_review_enforcement_constants()
|
|
44
|
+
register_verified_commit_constants()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from config.code_review_enforcement_constants import (
|
|
48
|
+
STAMP_DIRECTORY_NAME,
|
|
49
|
+
STAMP_KEY_EFFORT,
|
|
50
|
+
STAMP_KEY_MANIFEST_SHA256,
|
|
51
|
+
STAMP_KEY_RECORDED_AT_EPOCH,
|
|
52
|
+
effort_meets_threshold,
|
|
53
|
+
)
|
|
54
|
+
from config.verified_commit_constants import (
|
|
55
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
56
|
+
VERDICT_JSON_INDENT,
|
|
57
|
+
)
|
|
58
|
+
from verification_verdict_store import (
|
|
59
|
+
branch_surface_manifest,
|
|
60
|
+
empty_surface_hash,
|
|
61
|
+
manifest_sha256,
|
|
62
|
+
resolve_merge_base,
|
|
63
|
+
resolve_repo_root,
|
|
64
|
+
root_key_for_repo,
|
|
65
|
+
)
|
|
66
|
+
except ModuleNotFoundError:
|
|
67
|
+
if _blocking_directory not in sys.path:
|
|
68
|
+
sys.path.insert(0, _blocking_directory)
|
|
69
|
+
from config.code_review_enforcement_constants import (
|
|
70
|
+
STAMP_DIRECTORY_NAME,
|
|
71
|
+
STAMP_KEY_EFFORT,
|
|
72
|
+
STAMP_KEY_MANIFEST_SHA256,
|
|
73
|
+
STAMP_KEY_RECORDED_AT_EPOCH,
|
|
74
|
+
effort_meets_threshold,
|
|
75
|
+
)
|
|
76
|
+
from config.verified_commit_constants import (
|
|
77
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
78
|
+
VERDICT_JSON_INDENT,
|
|
79
|
+
)
|
|
80
|
+
from verification_verdict_store import (
|
|
81
|
+
branch_surface_manifest,
|
|
82
|
+
empty_surface_hash,
|
|
83
|
+
manifest_sha256,
|
|
84
|
+
resolve_merge_base,
|
|
85
|
+
resolve_repo_root,
|
|
86
|
+
root_key_for_repo,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stamp_directory() -> Path:
|
|
91
|
+
"""Return the shared directory holding every work tree's stamp file.
|
|
92
|
+
|
|
93
|
+
Stamps live outside any repository (under the user's Claude home) so no repo
|
|
94
|
+
accumulates untracked stamp files; every work tree's stamp shares this one
|
|
95
|
+
directory, distinguished by file name.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The stamp directory under the user's Claude home.
|
|
99
|
+
"""
|
|
100
|
+
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / STAMP_DIRECTORY_NAME
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def stamp_path_for_repo(repo_root: str) -> Path:
|
|
104
|
+
"""Derive the stamp file path for a repository work tree.
|
|
105
|
+
|
|
106
|
+
Keyed by the shared ``root_key_for_repo`` derivation, so every work tree
|
|
107
|
+
gets its own stamp file and a subdirectory of a work tree resolves to the
|
|
108
|
+
same key as its root.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
repo_root: The repository top-level directory.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The stamp file path for this work tree.
|
|
115
|
+
"""
|
|
116
|
+
return stamp_directory() / f"{root_key_for_repo(repo_root)}.json"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_stamps_by_effort(stamp_file: Path) -> dict[str, object]:
|
|
120
|
+
"""Read the per-effort stamp records for a work tree, or an empty map.
|
|
121
|
+
|
|
122
|
+
::
|
|
123
|
+
|
|
124
|
+
<root-key>.json holds {"low": {...}, "xhigh": {...}} -> that map
|
|
125
|
+
file missing / unreadable / not a JSON object -> {}
|
|
126
|
+
|
|
127
|
+
A missing, unreadable, or malformed stamp file yields an empty map, so a
|
|
128
|
+
caller reading it sees no coverage and fails closed.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
stamp_file: The per-work-tree stamp file path.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The mapping of effort token to its stored record, or an empty map when
|
|
135
|
+
the file is absent, unreadable, or not a JSON object.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
parsed_stamps = json.loads(stamp_file.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, json.JSONDecodeError):
|
|
140
|
+
return {}
|
|
141
|
+
if not isinstance(parsed_stamps, dict):
|
|
142
|
+
return {}
|
|
143
|
+
return parsed_stamps
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def record_clean_stamp(repo_root: str, bound_manifest_sha256: str, effort: str) -> Path:
|
|
147
|
+
"""Write one effort's clean-review stamp for a work tree.
|
|
148
|
+
|
|
149
|
+
Reads any existing per-effort records, sets this effort's entry to the given
|
|
150
|
+
surface hash and the current epoch, and writes the map back. Recording a
|
|
151
|
+
second effort keeps the first; recording the same effort again overwrites
|
|
152
|
+
its bound hash.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
repo_root: The repository top-level directory.
|
|
156
|
+
bound_manifest_sha256: Hash of the branch surface the clean review covered.
|
|
157
|
+
effort: The effort token the clean review ran at.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The stamp file path the record was written to.
|
|
161
|
+
"""
|
|
162
|
+
stamp_file = stamp_path_for_repo(repo_root)
|
|
163
|
+
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
all_stamps_by_effort = _read_stamps_by_effort(stamp_file)
|
|
165
|
+
all_stamps_by_effort[effort] = {
|
|
166
|
+
STAMP_KEY_EFFORT: effort,
|
|
167
|
+
STAMP_KEY_MANIFEST_SHA256: bound_manifest_sha256,
|
|
168
|
+
STAMP_KEY_RECORDED_AT_EPOCH: int(time.time()),
|
|
169
|
+
}
|
|
170
|
+
stamp_file.write_text(
|
|
171
|
+
json.dumps(all_stamps_by_effort, indent=VERDICT_JSON_INDENT), encoding="utf-8"
|
|
172
|
+
)
|
|
173
|
+
return stamp_file
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def stamp_covers_surface(repo_root: str, live_manifest_sha256: str, required_effort: str) -> bool:
|
|
177
|
+
"""Decide whether a clean stamp covers the live surface at the needed effort.
|
|
178
|
+
|
|
179
|
+
A stored entry covers the surface when its bound hash equals the live hash
|
|
180
|
+
exactly and its effort ranks at or above the required effort. Any missing,
|
|
181
|
+
unreadable, or malformed stamp reads as not covered.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
repo_root: The repository top-level directory.
|
|
185
|
+
live_manifest_sha256: Hash of the live branch surface the gate checks.
|
|
186
|
+
required_effort: The effort the gate demands for the action.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True as soon as one stored entry matches the live hash and meets the
|
|
190
|
+
required effort; False when none match.
|
|
191
|
+
"""
|
|
192
|
+
all_stamps_by_effort = _read_stamps_by_effort(stamp_path_for_repo(repo_root))
|
|
193
|
+
for each_stamp_record in all_stamps_by_effort.values():
|
|
194
|
+
if not isinstance(each_stamp_record, dict):
|
|
195
|
+
continue
|
|
196
|
+
if each_stamp_record.get(STAMP_KEY_MANIFEST_SHA256) != live_manifest_sha256:
|
|
197
|
+
continue
|
|
198
|
+
stored_effort = each_stamp_record.get(STAMP_KEY_EFFORT)
|
|
199
|
+
if not isinstance(stored_effort, str):
|
|
200
|
+
continue
|
|
201
|
+
if effort_meets_threshold(stored_effort, required_effort):
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def live_surface_hash(work_tree_directory: str) -> str | None:
|
|
207
|
+
"""Compute the live branch-surface manifest hash for a work tree.
|
|
208
|
+
|
|
209
|
+
Resolves the work tree's repo root and merge base, then hashes the current
|
|
210
|
+
change surface — every changed path and untracked file bound by its content
|
|
211
|
+
digest — so the hash tracks the exact bytes under review.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
work_tree_directory: A directory inside the work tree to hash.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The surface manifest hash, or None when the directory is outside a work
|
|
218
|
+
tree, the merge base or manifest cannot be resolved, or the change
|
|
219
|
+
surface is empty (HEAD equals the merge base, so nothing is under review).
|
|
220
|
+
"""
|
|
221
|
+
repo_root = resolve_repo_root(work_tree_directory)
|
|
222
|
+
if repo_root is None:
|
|
223
|
+
return None
|
|
224
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
225
|
+
if merge_base_sha is None:
|
|
226
|
+
return None
|
|
227
|
+
surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
|
|
228
|
+
if surface_manifest_text is None:
|
|
229
|
+
return None
|
|
230
|
+
surface_hash = manifest_sha256(surface_manifest_text)
|
|
231
|
+
if surface_hash == empty_surface_hash():
|
|
232
|
+
return None
|
|
233
|
+
return surface_hash
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Concern modules of the code-review stamp write blocker, wired by the entry hook.
|
|
2
|
+
|
|
3
|
+
The entry hook ``code_review_stamp_directory_write_blocker.py`` imports two
|
|
4
|
+
public matchers from this package to keep its own file short: a split
|
|
5
|
+
directory-change matcher and an obfuscated-path-write matcher. Each mirrors the
|
|
6
|
+
sibling verdict-directory guard's logic against the code-review stamp segments.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Put the blocking and hooks directories on sys.path for the parts tests.
|
|
2
|
+
|
|
3
|
+
The concern modules import their shared constants as ``from config...`` against
|
|
4
|
+
the sibling ``blocking/config/`` package, so the tests need the blocking
|
|
5
|
+
directory (which holds that package) and the hooks directory on the path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_blocking_directory = str(Path(__file__).resolve().parents[1])
|
|
12
|
+
_hooks_directory = str(Path(__file__).resolve().parents[2])
|
|
13
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
14
|
+
if each_bootstrap_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, each_bootstrap_directory)
|