claude-dev-env 2.3.0 → 2.5.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 (290) hide show
  1. package/CLAUDE.md +53 -48
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +3 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +3 -4
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/skill-writer-agent.md +84 -0
  31. package/agents/test_agent_frontmatter.py +67 -18
  32. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
  33. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  34. package/bin/CLAUDE.md +68 -5
  35. package/bin/ever-shipped-skills.mjs +1 -0
  36. package/bin/install-constants.mjs +88 -0
  37. package/bin/install.mjs +1138 -114
  38. package/bin/install.prune.test.mjs +869 -19
  39. package/bin/install.test.mjs +906 -2
  40. package/commands/implement.md +1 -1
  41. package/commands/right-size.md +1 -1
  42. package/docs/CLAUDE.md +2 -0
  43. package/docs/host-pool-health-monitor.md +102 -0
  44. package/docs/references/CLAUDE.md +5 -2
  45. package/docs/references/advisor-tool.md +13 -0
  46. package/docs/references/code-review-enforcement.md +107 -0
  47. package/docs/references/team-advisor-skill.md +14 -0
  48. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  49. package/hooks/blocking/CLAUDE.md +9 -1
  50. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  51. package/hooks/blocking/code_review_gate_deny.py +74 -0
  52. package/hooks/blocking/code_review_pr_create_gate.py +198 -0
  53. package/hooks/blocking/code_review_push_gate.py +145 -0
  54. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
  55. package/hooks/blocking/code_review_stamp_store.py +233 -0
  56. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  57. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  58. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  59. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  60. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  61. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  62. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  63. package/hooks/blocking/config/__init__.py +5 -5
  64. package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
  65. package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
  66. package/hooks/blocking/config/verified_commit_constants.py +160 -155
  67. package/hooks/blocking/conftest.py +2 -0
  68. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  69. package/hooks/blocking/destructive_command_blocker.py +19 -6
  70. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  71. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  72. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  73. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  74. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  75. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  76. package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
  77. package/hooks/blocking/test_code_review_push_gate.py +205 -0
  78. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
  79. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  80. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  81. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  82. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  83. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  84. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  85. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  86. package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
  87. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  88. package/hooks/blocking/test_verification_verdict_store.py +974 -903
  89. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  90. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  91. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  92. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  93. package/hooks/blocking/verification_verdict_store.py +1039 -1014
  94. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  95. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  96. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  97. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  98. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  99. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  100. package/hooks/git-hooks/pre_push.py +89 -2
  101. package/hooks/git-hooks/test_pre_push.py +128 -0
  102. package/hooks/hooks.json +26 -1
  103. package/hooks/hooks_constants/CLAUDE.md +3 -1
  104. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  105. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  106. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  107. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  108. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  109. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  110. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  111. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  112. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  113. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  114. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  115. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  116. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  117. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  118. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  119. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  120. package/hooks/pyproject.toml +1 -0
  121. package/hooks/validators/CLAUDE.md +2 -0
  122. package/hooks/validators/config/__init__.py +0 -0
  123. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  124. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  125. package/hooks/validators/conftest.py +4 -0
  126. package/hooks/validators/mypy_integration.py +63 -50
  127. package/hooks/validators/pyproject_config_discovery.py +101 -0
  128. package/hooks/validators/ruff_integration.py +257 -25
  129. package/hooks/validators/run_all_validators.py +223 -19
  130. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  131. package/hooks/validators/test_mypy_integration.py +32 -0
  132. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +160 -2
  135. package/hooks/validators/test_run_all_validators.py +140 -68
  136. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  137. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  138. package/package.json +10 -2
  139. package/rules/CLAUDE.md +1 -0
  140. package/rules/docstring-prose-matches-implementation.md +45 -67
  141. package/rules/durable-post-artifacts.md +7 -0
  142. package/rules/state-what-is.md +25 -0
  143. package/rules/verified-commit-gate-skip.md +1 -1
  144. package/scripts/CLAUDE.md +1 -0
  145. package/scripts/Capture-PoolHealth.ps1 +410 -0
  146. package/scripts/_code_review_test_support.py +404 -0
  147. package/scripts/claude_chain_runner.py +141 -1
  148. package/scripts/codec_forwarding_test_support.py +83 -0
  149. package/scripts/conftest.py +23 -0
  150. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
  151. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
  152. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  153. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  154. package/scripts/invoke_code_review.py +550 -38
  155. package/scripts/resolve_worker_spawn.py +626 -619
  156. package/scripts/spawn_grok_batch.py +672 -672
  157. package/scripts/test_claude_chain_runner.py +131 -0
  158. package/scripts/test_invoke_code_review_chain.py +70 -0
  159. package/scripts/test_invoke_code_review_cli.py +192 -0
  160. package/scripts/test_invoke_code_review_codec.py +77 -0
  161. package/scripts/test_invoke_code_review_contract.py +256 -0
  162. package/scripts/test_invoke_code_review_git.py +123 -0
  163. package/scripts/test_invoke_code_review_mode.py +99 -0
  164. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  165. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  166. package/skills/CLAUDE.md +2 -0
  167. package/skills/auditing-claude-config/SKILL.md +114 -114
  168. package/skills/autoconverge/SKILL.md +427 -421
  169. package/skills/autoconverge/reference/convergence.md +26 -4
  170. package/skills/autoconverge/reference/multi-pr.md +6 -1
  171. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  172. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  173. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  174. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  175. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
  176. package/skills/autoconverge/workflow/converge.mjs +191 -8
  177. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  178. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  179. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  180. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  181. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  182. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  183. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  184. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  185. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  186. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  187. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  188. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  189. package/skills/closeout/SKILL.md +33 -50
  190. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  191. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  192. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  193. package/skills/condensing-instructions/SKILL.md +81 -0
  194. package/skills/copilot-review/SKILL.md +119 -119
  195. package/skills/e-code-review/SKILL.md +52 -0
  196. package/skills/e-code-review/reference/fix.md +54 -0
  197. package/skills/e-code-review/reference/loop.md +43 -0
  198. package/skills/e-code-review/reference/low.md +57 -0
  199. package/skills/e-code-review/reference/medium.md +153 -0
  200. package/skills/e-code-review/reference/xhigh.md +182 -0
  201. package/skills/e-simplify/SKILL.md +97 -0
  202. package/skills/fresh-branch/CLAUDE.md +2 -0
  203. package/skills/fresh-branch/SKILL.md +2 -0
  204. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  205. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  206. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  207. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  208. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  209. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  210. package/skills/issue-tracker/SKILL.md +92 -0
  211. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  212. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  213. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  214. package/skills/orchestrator/SKILL.md +162 -21
  215. package/skills/orchestrator/scripts/status_gate.py +625 -0
  216. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  217. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  218. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  219. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  220. package/skills/orchestrator-refresh/SKILL.md +110 -35
  221. package/skills/plan-to-pr/SKILL.md +155 -0
  222. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  223. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  224. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  225. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  226. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  227. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  228. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  229. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  230. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  231. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  232. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  233. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  234. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  235. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  236. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  237. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  238. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  239. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  240. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  241. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  242. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  243. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  244. package/skills/pr-converge/SKILL.md +472 -469
  245. package/skills/pr-converge/reference/examples.md +3 -3
  246. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  247. package/skills/pr-converge/reference/ground-rules.md +7 -4
  248. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  249. package/skills/pr-converge/reference/per-tick.md +5 -5
  250. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  251. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  252. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  253. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  254. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  255. package/skills/pr-fix-protocol/SKILL.md +1 -1
  256. package/skills/privacy-hygiene/SKILL.md +68 -68
  257. package/skills/prototype/SKILL.md +86 -0
  258. package/skills/prototype/reference/honest-limitations.md +23 -0
  259. package/skills/prototype/reference/promotion-tasks.md +23 -0
  260. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  261. package/skills/prototype/scripts/conftest.py +15 -0
  262. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  263. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  264. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  265. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  266. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  267. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  268. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  269. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  270. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  271. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  272. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  273. package/skills/prototype/workflows/promotion.md +27 -0
  274. package/skills/prototype/workflows/sandbox.md +35 -0
  275. package/skills/release-notes-html/SKILL.md +164 -0
  276. package/skills/skill-builder/CLAUDE.md +3 -3
  277. package/skills/skill-builder/SKILL.md +5 -5
  278. package/skills/skill-builder/references/CLAUDE.md +1 -1
  279. package/skills/skill-builder/references/delegation-map.md +3 -3
  280. package/skills/skill-builder/references/description-field.md +1 -1
  281. package/skills/skill-builder/references/skill-modularity.md +2 -3
  282. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  283. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  284. package/skills/skill-builder/workflows/new-skill.md +2 -2
  285. package/skills/task-build/CLAUDE.md +8 -7
  286. package/skills/task-build/SKILL.md +16 -8
  287. package/skills/task-build/reference/tool-routing.md +19 -0
  288. package/skills/team-advisor/SKILL.md +2 -2
  289. package/scripts/test_invoke_code_review.py +0 -672
  290. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -0,0 +1,198 @@
1
+ """PreToolUse gate: PR create lands only behind a clean xhigh code-review stamp.
2
+
3
+ Fires when ``CODE_REVIEW_ENFORCEMENT_ENABLED`` is on (default off). Runs on
4
+ Bash/PowerShell ``gh pr create`` and on the MCP ``create_pull_request``
5
+ tool. Resolves the repository from the session
6
+ working directory (or the payload cwd), computes the live change-surface
7
+ hash, and allows the create only when a clean stamp at effort ``xhigh`` or
8
+ higher covers that exact hash under ``~/.claude/code-review-stamps/``.
9
+
10
+ Empty surfaces and repos with no resolvable upstream base are out of scope
11
+ and allowed. Push lands behind ``code_review_push_gate`` and commit behind
12
+ ``verified_commit_gate``; this gate covers pull-request creation only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ try:
23
+ _blocking_directory = str(Path(__file__).resolve().parent)
24
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
25
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
26
+ if each_bootstrap_directory not in sys.path:
27
+ sys.path.insert(0, each_bootstrap_directory)
28
+
29
+ from code_review_enforcement_config_bootstrap import (
30
+ register_code_review_enforcement_constants,
31
+ )
32
+ from verified_commit_config_bootstrap import register_verified_commit_constants
33
+
34
+ register_code_review_enforcement_constants()
35
+ register_verified_commit_constants()
36
+
37
+ from code_review_gate_deny import (
38
+ build_code_review_deny_payload,
39
+ log_and_emit_code_review_deny,
40
+ )
41
+ from code_review_stamp_store import live_surface_hash, stamp_covers_surface
42
+ from config.code_review_enforcement_constants import (
43
+ ALL_GATED_SHELL_TOOL_NAMES,
44
+ CODE_REVIEW_ENFORCEMENT_ENABLED,
45
+ GH_PR_CREATE_INVOCATION_PATTERN,
46
+ HASH_PREVIEW_LENGTH,
47
+ MCP_CREATE_PULL_REQUEST_TOOL_NAME,
48
+ PR_CREATE_GATE_CORRECTIVE_MESSAGE,
49
+ PR_CREATE_GATE_HOOK_MODULE_NAME,
50
+ PR_CREATE_REQUIRED_EFFORT,
51
+ )
52
+ from verification_verdict_store import resolve_repo_root
53
+ except ImportError as import_error:
54
+ raise ImportError(
55
+ "the code_review_pr_create_gate dependencies did not import; "
56
+ "ensure the hooks directory is importable."
57
+ ) from import_error
58
+
59
+
60
+ def _strip_quoted_regions(command_text: str) -> str:
61
+ """Blank out single- and double-quoted spans so prose is not gated."""
62
+ quote_pattern = re.compile(r"\"[^\"]*\"|'[^']*'")
63
+ return quote_pattern.sub(lambda each_match: " " * len(each_match.group(0)), command_text)
64
+
65
+
66
+ def command_invokes_gh_pr_create(command_text: str) -> bool:
67
+ """Decide whether a shell command invokes ``gh pr create``.
68
+
69
+ ::
70
+
71
+ gh pr create --title T --body-file b.md -> True
72
+ echo "gh pr create" -> False (quoted prose)
73
+ gh pr edit 1 --title T -> False
74
+
75
+ Args:
76
+ command_text: The raw command string from the tool payload.
77
+
78
+ Returns:
79
+ True when the command contains a real ``gh pr create`` invocation.
80
+ """
81
+ quote_stripped_command = _strip_quoted_regions(command_text)
82
+ invocation_pattern = re.compile(GH_PR_CREATE_INVOCATION_PATTERN, re.IGNORECASE)
83
+ return invocation_pattern.search(quote_stripped_command) is not None
84
+
85
+
86
+ def is_mcp_create_pull_request_tool(tool_name: str) -> bool:
87
+ """Decide whether a tool name is the MCP create-pull-request tool.
88
+
89
+ Args:
90
+ tool_name: The PreToolUse ``tool_name`` field.
91
+
92
+ Returns:
93
+ True when the name is the configured MCP create-PR tool.
94
+ """
95
+ return tool_name == MCP_CREATE_PULL_REQUEST_TOOL_NAME
96
+
97
+
98
+ def deny_reason_for_directory(target_directory: str) -> str | None:
99
+ """Decide whether a PR create from a directory must be blocked.
100
+
101
+ ::
102
+
103
+ enforcement off / no repo / empty surface / covering xhigh stamp -> None
104
+ production surface without xhigh-or-higher stamp -> corrective (deny)
105
+
106
+ Args:
107
+ target_directory: The session or repo directory the create targets.
108
+
109
+ Returns:
110
+ The deny reason when the surface needs a clean xhigh stamp and none
111
+ covers it; None when the create may proceed.
112
+ """
113
+ if not CODE_REVIEW_ENFORCEMENT_ENABLED:
114
+ return None
115
+ repo_root = resolve_repo_root(target_directory)
116
+ if repo_root is None:
117
+ return None
118
+ surface_hash = live_surface_hash(repo_root)
119
+ if surface_hash is None:
120
+ return None
121
+ if stamp_covers_surface(repo_root, surface_hash, PR_CREATE_REQUIRED_EFFORT):
122
+ return None
123
+ hash_preview = surface_hash[:HASH_PREVIEW_LENGTH]
124
+ return (
125
+ f"{PR_CREATE_GATE_CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
126
+ )
127
+
128
+
129
+ def _deny_for_directory_or_none(
130
+ session_directory: str,
131
+ ) -> dict[str, dict[str, str]] | None:
132
+ """Return the deny payload for a directory that lacks a covering stamp."""
133
+ deny_reason = deny_reason_for_directory(session_directory)
134
+ if deny_reason is None:
135
+ return None
136
+ return build_code_review_deny_payload(deny_reason)
137
+
138
+
139
+ def _shell_command_targets_pr_create(all_pretooluse_payload: dict[str, object]) -> bool:
140
+ """Decide whether a shell payload carries a real ``gh pr create``."""
141
+ tool_input = all_pretooluse_payload.get("tool_input", {})
142
+ if not isinstance(tool_input, dict):
143
+ return False
144
+ command_text = tool_input.get("command", "")
145
+ if not isinstance(command_text, str) or not command_text:
146
+ return False
147
+ return command_invokes_gh_pr_create(command_text)
148
+
149
+
150
+ def decision_for_payload(
151
+ all_pretooluse_payload: dict[str, object],
152
+ ) -> dict[str, dict[str, str]] | None:
153
+ """Build the deny decision for a gated PR-create attempt.
154
+
155
+ Args:
156
+ all_pretooluse_payload: The PreToolUse hook payload.
157
+
158
+ Returns:
159
+ The deny decision mapping when the create lacks a covering xhigh
160
+ stamp; None when the create may proceed.
161
+ """
162
+ tool_name = all_pretooluse_payload.get("tool_name", "")
163
+ if not isinstance(tool_name, str):
164
+ return None
165
+ session_directory = all_pretooluse_payload.get("cwd", ".")
166
+ if not isinstance(session_directory, str):
167
+ session_directory = "."
168
+ if is_mcp_create_pull_request_tool(tool_name):
169
+ return _deny_for_directory_or_none(session_directory)
170
+ if tool_name not in ALL_GATED_SHELL_TOOL_NAMES:
171
+ return None
172
+ if not _shell_command_targets_pr_create(all_pretooluse_payload):
173
+ return None
174
+ return _deny_for_directory_or_none(session_directory)
175
+
176
+
177
+ def main() -> None:
178
+ """Read the PreToolUse payload and decide whether to allow the PR create."""
179
+ try:
180
+ pretooluse_payload = json.load(sys.stdin)
181
+ except json.JSONDecodeError:
182
+ return
183
+ deny_decision = decision_for_payload(pretooluse_payload)
184
+ if deny_decision is None:
185
+ return
186
+ tool_name = pretooluse_payload.get("tool_name", "")
187
+ deny_reason = deny_decision["hookSpecificOutput"]["permissionDecisionReason"]
188
+ if not isinstance(deny_reason, str):
189
+ return
190
+ log_and_emit_code_review_deny(
191
+ deny_reason,
192
+ tool_name if isinstance(tool_name, str) else "",
193
+ PR_CREATE_GATE_HOOK_MODULE_NAME,
194
+ )
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()
@@ -0,0 +1,145 @@
1
+ """PreToolUse gate: git push lands only behind a clean low code-review stamp.
2
+
3
+ Fires on Bash/PowerShell tool calls when ``CODE_REVIEW_ENFORCEMENT_ENABLED``
4
+ is on (default off). When the command carries a ``git push``, the gate
5
+ resolves each repository the push targets, computes the live
6
+ change-surface hash against the merge base, and allows the push only when a
7
+ clean stamp at effort ``low`` or higher covers that exact hash under
8
+ ``~/.claude/code-review-stamps/``.
9
+
10
+ Allowed without a stamp:
11
+
12
+ - a command carrying the bypass marker (``# code-review-skip``) as a trailing
13
+ shell comment,
14
+ - a repository with no resolvable upstream base,
15
+ - a mechanically exempt surface (docs and image files, pytest test files, and
16
+ Python files whose docstring-stripped AST is unchanged), and
17
+ - a surface a clean ``low``-or-higher stamp already covers.
18
+
19
+ Commit lands behind ``verified_commit_gate``; only push is gated here.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ try:
29
+ _blocking_directory = str(Path(__file__).resolve().parent)
30
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
31
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
32
+ if each_bootstrap_directory not in sys.path:
33
+ sys.path.insert(0, each_bootstrap_directory)
34
+
35
+ from code_review_enforcement_config_bootstrap import (
36
+ register_code_review_enforcement_constants,
37
+ )
38
+ from verified_commit_config_bootstrap import register_verified_commit_constants
39
+
40
+ register_code_review_enforcement_constants()
41
+ register_verified_commit_constants()
42
+
43
+ from code_review_gate_deny import log_and_emit_code_review_deny
44
+ from code_review_stamp_store import live_surface_hash, stamp_covers_surface
45
+ from config.code_review_enforcement_constants import (
46
+ ALL_GATED_SHELL_TOOL_NAMES,
47
+ CODE_REVIEW_BYPASS_MARKER,
48
+ CODE_REVIEW_ENFORCEMENT_ENABLED,
49
+ GATED_PUSH_SUBCOMMANDS,
50
+ HASH_PREVIEW_LENGTH,
51
+ PUSH_GATE_CORRECTIVE_MESSAGE,
52
+ PUSH_GATE_HOOK_MODULE_NAME,
53
+ PUSH_REQUIRED_EFFORT,
54
+ )
55
+ from verification_verdict_store import (
56
+ is_verification_exempt_diff,
57
+ resolve_merge_base,
58
+ resolve_repo_root,
59
+ )
60
+ from verified_commit_gate_parts.command_tokenization import (
61
+ command_carries_trailing_marker,
62
+ )
63
+ from verified_commit_gate_parts.gated_invocations import gated_repo_directories
64
+ except ImportError as import_error:
65
+ raise ImportError(
66
+ "the code_review_push_gate dependencies did not import; "
67
+ "ensure the hooks directory is importable."
68
+ ) from import_error
69
+
70
+
71
+ def deny_reason_for_directory(target_directory: str) -> str | None:
72
+ """Decide whether a push from a directory must be blocked.
73
+
74
+ Allowed when enforcement is off, the directory is no repo, has no upstream
75
+ base, is a mechanically exempt surface, or a clean low-or-higher stamp
76
+ covers it.
77
+
78
+ Args:
79
+ target_directory: The directory the push targets.
80
+
81
+ Returns:
82
+ The deny reason when the surface lacks a covering low stamp, else None.
83
+ """
84
+ if not CODE_REVIEW_ENFORCEMENT_ENABLED:
85
+ return None
86
+ repo_root = resolve_repo_root(target_directory)
87
+ if repo_root is None:
88
+ return None
89
+ merge_base_sha = resolve_merge_base(repo_root)
90
+ if merge_base_sha is None:
91
+ return None
92
+ if is_verification_exempt_diff(repo_root, merge_base_sha):
93
+ return None
94
+ surface_hash = live_surface_hash(repo_root)
95
+ if surface_hash is None:
96
+ return None
97
+ if stamp_covers_surface(repo_root, surface_hash, PUSH_REQUIRED_EFFORT):
98
+ return None
99
+ hash_preview = surface_hash[:HASH_PREVIEW_LENGTH]
100
+ return f"{PUSH_GATE_CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
101
+
102
+
103
+ def _emit_first_denied_directory(command_text: str, session_directory: str, tool_name: str) -> None:
104
+ """Deny the push against the first target directory that lacks a stamp."""
105
+ all_target_directories = gated_repo_directories(
106
+ command_text, session_directory, all_gated_subcommands=GATED_PUSH_SUBCOMMANDS
107
+ )
108
+ for each_target_directory in all_target_directories:
109
+ deny_reason = deny_reason_for_directory(each_target_directory)
110
+ if deny_reason is None:
111
+ continue
112
+ log_and_emit_code_review_deny(deny_reason, tool_name, PUSH_GATE_HOOK_MODULE_NAME)
113
+ return
114
+
115
+
116
+ def main() -> None:
117
+ """Read the PreToolUse payload and decide whether to allow the push.
118
+
119
+ Allows the command without a stamp when it carries the bypass marker
120
+ (``CODE_REVIEW_BYPASS_MARKER``) as a genuine trailing shell comment;
121
+ otherwise denies a push whose surface lacks a covering low stamp.
122
+ """
123
+ try:
124
+ pretooluse_payload = json.load(sys.stdin)
125
+ except json.JSONDecodeError:
126
+ return
127
+ tool_name = pretooluse_payload.get("tool_name", "")
128
+ if tool_name not in ALL_GATED_SHELL_TOOL_NAMES:
129
+ return
130
+ tool_input = pretooluse_payload.get("tool_input", {})
131
+ if not isinstance(tool_input, dict):
132
+ return
133
+ command_text = tool_input.get("command", "")
134
+ if not isinstance(command_text, str) or not command_text:
135
+ return
136
+ if command_carries_trailing_marker(command_text, CODE_REVIEW_BYPASS_MARKER):
137
+ return
138
+ session_directory = pretooluse_payload.get("cwd", ".")
139
+ if not isinstance(session_directory, str):
140
+ session_directory = "."
141
+ _emit_first_denied_directory(command_text, session_directory, tool_name)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse guard: deny shell and file-tool access to the stamp directory.
3
+
4
+ Runs only when ``CODE_REVIEW_ENFORCEMENT_ENABLED`` is on (default off); with
5
+ the flag off the guard allows every stamp-directory access.
6
+
7
+ The push and PR-create gates trust a single invariant: only
8
+ ``invoke_code_review.py --record-stamp`` mints stamp files under
9
+ ``~/.claude/code-review-stamps/``. settings.json cannot be the only shipped
10
+ protection, so this guard fires on both surfaces:
11
+
12
+ - Bash / PowerShell: deny any command that names the stamp directory (absolute
13
+ path, cd-then-relative write, cd into the stamp directory, a split directory
14
+ change through the Claude home into the stamp directory, stamp-file shape, a
15
+ path-join write, or a path assembled by obfuscation that decodes to a stamp
16
+ segment), or that imports the stamp store module or calls its mint function.
17
+ - Write / Edit / MultiEdit: deny any path under the stamp directory.
18
+
19
+ No legitimate workflow reaches that directory through a shell or the file
20
+ tools: the invoker writes in-process after a clean review, so the sanctioned
21
+ minter command names none of these matchers and passes without an allowlist.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ try:
32
+ _blocking_directory = str(Path(__file__).resolve().parent)
33
+ if _blocking_directory not in sys.path:
34
+ sys.path.insert(0, _blocking_directory)
35
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
36
+ if _hooks_directory not in sys.path:
37
+ sys.path.append(_hooks_directory)
38
+
39
+ from code_review_enforcement_config_bootstrap import (
40
+ register_code_review_enforcement_constants,
41
+ )
42
+ from verified_commit_config_bootstrap import register_verified_commit_constants
43
+
44
+ register_code_review_enforcement_constants()
45
+ register_verified_commit_constants()
46
+
47
+ from code_review_stamp_store import stamp_directory
48
+ from code_review_stamp_write_blocker_parts.obfuscated_stamp_path_reference import (
49
+ references_obfuscated_stamp_path,
50
+ )
51
+ from code_review_stamp_write_blocker_parts.split_directory_change_into_stamp import (
52
+ changes_through_split_directory_into_stamp,
53
+ directory_change_prefix,
54
+ )
55
+ from config.code_review_enforcement_constants import (
56
+ ALL_GATED_SHELL_TOOL_NAMES,
57
+ ALL_STAMP_STORE_FORGE_PATTERNS,
58
+ ALL_WRITE_EDIT_TOOL_NAMES,
59
+ CODE_REVIEW_ENFORCEMENT_ENABLED,
60
+ DENY_PERMISSION_DECISION,
61
+ PRE_TOOL_USE_HOOK_EVENT_NAME,
62
+ RELATIVE_STAMP_DIRECTORY_PATTERN,
63
+ STAMP_DIRECTORY_GUARD_MESSAGE,
64
+ STAMP_DIRECTORY_NAME,
65
+ STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN,
66
+ STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN,
67
+ STAMP_WRITE_BLOCKER_HOOK_MODULE_NAME,
68
+ )
69
+ from config.verified_commit_constants import (
70
+ CLAUDE_HOME_DIRECTORY_NAME,
71
+ CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
72
+ COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN,
73
+ DIRECTORY_CHANGE_TARGET_PATTERN,
74
+ NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
75
+ ROOT_KEY_HEX_LENGTH,
76
+ VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN,
77
+ VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
78
+ VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
79
+ VERDICT_PATH_GLUE_PATTERN,
80
+ )
81
+
82
+ from hooks_constants.hook_block_logger import log_hook_block
83
+ from hooks_constants.pre_tool_use_stdin import (
84
+ read_hook_input_dictionary_from_stdin,
85
+ )
86
+ except ImportError as import_error:
87
+ raise ImportError(
88
+ "code_review_stamp_directory_write_blocker: cannot import its dependencies; "
89
+ "ensure the blocking directory is importable."
90
+ ) from import_error
91
+
92
+
93
+ def _stamp_file_relative_reference_pattern() -> str:
94
+ """Build the ``code-review-stamps/<root-key>.json`` stamp-file shape pattern."""
95
+ root_key_json_suffix = STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN % ROOT_KEY_HEX_LENGTH
96
+ return (
97
+ f"{STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN}"
98
+ f"{re.escape(STAMP_DIRECTORY_NAME)}{root_key_json_suffix}"
99
+ )
100
+
101
+
102
+ def _references_absolute_stamp_path(command_text: str) -> bool:
103
+ """Decide whether a command names an absolute stamp-directory path."""
104
+ stamp_directory_pattern = re.compile(
105
+ re.escape(CLAUDE_HOME_DIRECTORY_NAME)
106
+ + VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN
107
+ + re.escape(STAMP_DIRECTORY_NAME)
108
+ + VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
109
+ re.IGNORECASE,
110
+ )
111
+ return stamp_directory_pattern.search(command_text) is not None
112
+
113
+
114
+ def _changes_into_claude_home_then_writes_relative(command_text: str) -> bool:
115
+ """Decide whether a command enters the Claude home then writes the stamp dir."""
116
+ directory_change_into_claude_pattern = re.compile(
117
+ directory_change_prefix()
118
+ + DIRECTORY_CHANGE_TARGET_PATTERN
119
+ + re.escape(CLAUDE_HOME_DIRECTORY_NAME)
120
+ + CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
121
+ re.IGNORECASE,
122
+ )
123
+ change_match = directory_change_into_claude_pattern.search(command_text)
124
+ if change_match is None:
125
+ return False
126
+ relative_stamp_pattern = re.compile(RELATIVE_STAMP_DIRECTORY_PATTERN, re.IGNORECASE)
127
+ return relative_stamp_pattern.search(command_text, change_match.end()) is not None
128
+
129
+
130
+ def _changes_into_stamp_directory_then_writes(command_text: str) -> bool:
131
+ """Decide whether a command enters the stamp directory then runs a command."""
132
+ directory_change_into_stamp_pattern = re.compile(
133
+ directory_change_prefix()
134
+ + DIRECTORY_CHANGE_TARGET_PATTERN
135
+ + re.escape(CLAUDE_HOME_DIRECTORY_NAME)
136
+ + r"[\\/]"
137
+ + re.escape(STAMP_DIRECTORY_NAME)
138
+ + VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
139
+ re.IGNORECASE,
140
+ )
141
+ change_match = directory_change_into_stamp_pattern.search(command_text)
142
+ if change_match is None:
143
+ return False
144
+ command_after_change_pattern = re.compile(COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN)
145
+ return (
146
+ command_after_change_pattern.search(command_text, change_match.end())
147
+ is not None
148
+ )
149
+
150
+
151
+ def _references_stamp_file_shape(command_text: str) -> bool:
152
+ """Decide whether a command names a stamp file's ``<root-key>.json`` shape."""
153
+ stamp_file_pattern = re.compile(
154
+ _stamp_file_relative_reference_pattern(), re.IGNORECASE
155
+ )
156
+ return stamp_file_pattern.search(command_text) is not None
157
+
158
+
159
+ def _segments_join_as_stamp_path(command_text: str) -> bool:
160
+ """Decide whether the two name segments sit adjacent in a path-join shape."""
161
+ home_name = re.escape(CLAUDE_HOME_DIRECTORY_NAME)
162
+ stamp_name = re.escape(STAMP_DIRECTORY_NAME)
163
+ path_join_pattern = re.compile(
164
+ home_name
165
+ + VERDICT_PATH_GLUE_PATTERN
166
+ + stamp_name
167
+ + "|"
168
+ + stamp_name
169
+ + VERDICT_PATH_GLUE_PATTERN
170
+ + home_name,
171
+ re.IGNORECASE,
172
+ )
173
+ return path_join_pattern.search(command_text) is not None
174
+
175
+
176
+ def _writes_with_stamp_path_intent(command_text: str) -> bool:
177
+ """Decide whether a write joins the two name segments into a stamp path."""
178
+ has_non_redirect_write = (
179
+ re.search(NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
180
+ )
181
+ if not has_non_redirect_write:
182
+ return False
183
+ return _segments_join_as_stamp_path(command_text)
184
+
185
+
186
+ def _references_store_module_or_mint_call(command_text: str) -> bool:
187
+ """Decide whether a command imports the stamp store or calls its mint call.
188
+
189
+ ::
190
+
191
+ python -c "from code_review_stamp_store import record_clean_stamp" -> True
192
+ python -c "record_clean_stamp(root, live_hash, 'xhigh')" -> True
193
+ python -m pytest test_code_review_stamp_store.py -> False
194
+
195
+ A forge that re-implements the mint in-process names no stamp path, so the
196
+ path matchers miss it. Matching the store import and the mint call closes
197
+ that gap. A pytest run naming the store test file matches none of these.
198
+
199
+ Args:
200
+ command_text: The raw command string from the tool payload.
201
+
202
+ Returns:
203
+ True when the command imports ``code_review_stamp_store`` or calls
204
+ ``record_clean_stamp``; False otherwise.
205
+ """
206
+ for each_forge_pattern in ALL_STAMP_STORE_FORGE_PATTERNS:
207
+ if re.search(each_forge_pattern, command_text):
208
+ return True
209
+ return False
210
+
211
+
212
+ def references_stamp_directory(command_text: str) -> bool:
213
+ """Decide whether a command references the code-review stamp directory.
214
+
215
+ Args:
216
+ command_text: The raw command string from the tool payload.
217
+
218
+ Returns:
219
+ True when the command names the stamp directory through any matcher — a
220
+ store import or mint call, an absolute path, a cd-then-relative write, a
221
+ cd into the directory, a split directory change into the directory, a
222
+ stamp-file shape, a path-join write, or an obfuscation-decoded stamp
223
+ segment; False otherwise.
224
+ """
225
+ if _references_store_module_or_mint_call(command_text):
226
+ return True
227
+ if _references_absolute_stamp_path(command_text):
228
+ return True
229
+ if _changes_into_claude_home_then_writes_relative(command_text):
230
+ return True
231
+ if _changes_into_stamp_directory_then_writes(command_text):
232
+ return True
233
+ if changes_through_split_directory_into_stamp(command_text):
234
+ return True
235
+ if _references_stamp_file_shape(command_text):
236
+ return True
237
+ if _writes_with_stamp_path_intent(command_text):
238
+ return True
239
+ return references_obfuscated_stamp_path(command_text)
240
+
241
+
242
+ def path_targets_stamp_directory(file_path: str) -> bool:
243
+ """Decide whether a Write/Edit/MultiEdit path sits under the stamp directory.
244
+
245
+ Args:
246
+ file_path: The path the file tool targets.
247
+
248
+ Returns:
249
+ True when the resolved path is under ``~/.claude/code-review-stamps/``.
250
+ """
251
+ if not file_path:
252
+ return False
253
+ try:
254
+ resolved_target = Path(file_path).expanduser().resolve()
255
+ resolved_stamp_root = stamp_directory().resolve()
256
+ except OSError:
257
+ return False
258
+ if resolved_target == resolved_stamp_root:
259
+ return True
260
+ try:
261
+ resolved_target.relative_to(resolved_stamp_root)
262
+ except ValueError:
263
+ return False
264
+ return True
265
+
266
+
267
+ def build_deny_payload() -> dict[str, dict[str, str]]:
268
+ """Build the PreToolUse deny payload for a stamp-directory access."""
269
+ return {
270
+ "hookSpecificOutput": {
271
+ "hookEventName": PRE_TOOL_USE_HOOK_EVENT_NAME,
272
+ "permissionDecision": DENY_PERMISSION_DECISION,
273
+ "permissionDecisionReason": STAMP_DIRECTORY_GUARD_MESSAGE,
274
+ }
275
+ }
276
+
277
+
278
+ def _shell_decision(all_tool_input: dict[str, object]) -> dict[str, dict[str, str]] | None:
279
+ """Return the deny payload when a shell command targets the stamp directory."""
280
+ command_text = all_tool_input.get("command", "")
281
+ if not isinstance(command_text, str) or not command_text:
282
+ return None
283
+ if not references_stamp_directory(command_text):
284
+ return None
285
+ return build_deny_payload()
286
+
287
+
288
+ def _file_decision(all_tool_input: dict[str, object]) -> dict[str, dict[str, str]] | None:
289
+ """Return the deny payload when a file-tool path targets the stamp directory."""
290
+ file_path = all_tool_input.get("file_path", "")
291
+ if not isinstance(file_path, str):
292
+ return None
293
+ if not path_targets_stamp_directory(file_path):
294
+ return None
295
+ return build_deny_payload()
296
+
297
+
298
+ def decision_for_payload(
299
+ all_pretooluse_payload: dict[str, object],
300
+ ) -> dict[str, dict[str, str]] | None:
301
+ """Build the deny decision for stamp-directory shell or file-tool access.
302
+
303
+ Returns None immediately when `CODE_REVIEW_ENFORCEMENT_ENABLED` is off.
304
+
305
+ Args:
306
+ all_pretooluse_payload: The PreToolUse hook payload.
307
+
308
+ Returns:
309
+ The deny decision mapping when a gated tool targets the stamp
310
+ directory; None when the tool call may proceed.
311
+ """
312
+ if not CODE_REVIEW_ENFORCEMENT_ENABLED:
313
+ return None
314
+ tool_name = all_pretooluse_payload.get("tool_name", "")
315
+ if not isinstance(tool_name, str):
316
+ return None
317
+ tool_input = all_pretooluse_payload.get("tool_input", {})
318
+ if not isinstance(tool_input, dict):
319
+ return None
320
+ if tool_name in ALL_GATED_SHELL_TOOL_NAMES:
321
+ return _shell_decision(tool_input)
322
+ if tool_name not in ALL_WRITE_EDIT_TOOL_NAMES:
323
+ return None
324
+ return _file_decision(tool_input)
325
+
326
+
327
+ def main() -> None:
328
+ """Read the PreToolUse payload and deny stamp-directory access."""
329
+ pretooluse_payload = read_hook_input_dictionary_from_stdin()
330
+ if pretooluse_payload is None:
331
+ return
332
+ deny_decision = decision_for_payload(pretooluse_payload)
333
+ if deny_decision is None:
334
+ return
335
+ raw_tool_name = pretooluse_payload.get("tool_name", "")
336
+ tool_name_for_log = raw_tool_name if isinstance(raw_tool_name, str) else ""
337
+ log_hook_block(
338
+ calling_hook_name=STAMP_WRITE_BLOCKER_HOOK_MODULE_NAME,
339
+ hook_event=PRE_TOOL_USE_HOOK_EVENT_NAME,
340
+ block_reason=STAMP_DIRECTORY_GUARD_MESSAGE,
341
+ tool_name=tool_name_for_log,
342
+ )
343
+ sys.stdout.write(json.dumps(deny_decision) + "\n")
344
+ sys.stdout.flush()
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()