claude-dev-env 2.2.1 → 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.
Files changed (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -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)