claude-dev-env 1.94.0 → 2.0.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 (280) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +41 -33
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/CLAUDE.md +10 -1
  42. package/bin/ever-shipped-skills.mjs +73 -0
  43. package/bin/expand_home_directory_tokens.mjs +1 -1
  44. package/bin/install.mjs +140 -7
  45. package/bin/install.prune.test.mjs +457 -0
  46. package/docs/CODE_RULES.md +1 -1
  47. package/hooks/advisory/refactor_guard.py +3 -4
  48. package/hooks/blocking/CLAUDE.md +7 -1
  49. package/hooks/blocking/block_main_commit.py +2 -2
  50. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  61. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  62. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  63. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  64. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  65. package/hooks/blocking/code_rules_enforcer.py +4 -0
  66. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  67. package/hooks/blocking/code_rules_optional_params.py +2 -2
  68. package/hooks/blocking/code_rules_shared.py +83 -1
  69. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  70. package/hooks/blocking/code_rules_test_layout.py +9 -3
  71. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  72. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  73. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  74. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  75. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  76. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  77. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  78. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  79. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  80. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  85. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  86. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  87. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  88. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  89. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  90. package/hooks/blocking/pii_payload_scan.py +138 -42
  91. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  92. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  93. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  94. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  95. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  96. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  97. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  98. package/hooks/blocking/plain_language_blocker.py +138 -4
  99. package/hooks/blocking/pr_description_command_parser.py +8 -4
  100. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  101. package/hooks/blocking/sensitive_file_protector.py +114 -48
  102. package/hooks/blocking/tdd_enforcer.py +97 -601
  103. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  104. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  105. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  106. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  107. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  108. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  109. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  110. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  111. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  112. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  113. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  114. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  115. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  116. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  117. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  118. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  119. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  120. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  121. package/hooks/blocking/test_code_rules_shared.py +181 -0
  122. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  123. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  124. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  125. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  126. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  127. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  128. package/hooks/blocking/tests/conftest.py +10 -0
  129. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  130. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  131. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  132. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  133. package/hooks/git-hooks/post_commit.py +3 -4
  134. package/hooks/hooks_constants/CLAUDE.md +4 -2
  135. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  136. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  137. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  138. package/hooks/hooks_constants/local_identity.py +59 -8
  139. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  140. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  141. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  142. package/hooks/hooks_constants/test_local_identity.py +105 -3
  143. package/hooks/pyproject.toml +52 -4
  144. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  145. package/hooks/validation/mypy_validator.py +2 -2
  146. package/hooks/validators/CLAUDE.md +1 -1
  147. package/hooks/validators/README.md +2 -0
  148. package/hooks/validators/health_check.py +1 -0
  149. package/hooks/validators/mypy_integration.py +2 -0
  150. package/hooks/validators/python_style_checks.py +114 -136
  151. package/hooks/validators/python_style_helpers.py +95 -0
  152. package/hooks/validators/ruff_integration.py +3 -0
  153. package/hooks/validators/test_python_style_checks.py +0 -164
  154. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  155. package/hooks/validators/test_python_style_fixes.py +251 -0
  156. package/hooks/validators/test_python_style_helpers.py +125 -0
  157. package/hooks/workflow/auto_formatter.py +5 -4
  158. package/package.json +1 -1
  159. package/rules/CLAUDE.md +1 -0
  160. package/rules/anti-corollary-tests.md +69 -0
  161. package/rules/bdd.md +1 -3
  162. package/rules/code-reviews.md +1 -1
  163. package/rules/gh-paginate.md +1 -1
  164. package/rules/plain-language.md +2 -0
  165. package/scripts/CLAUDE.md +4 -0
  166. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  167. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  168. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  169. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  170. package/scripts/grok_headless_runner.py +294 -0
  171. package/scripts/grok_worker_preflight.py +410 -0
  172. package/scripts/invoke_code_review.py +463 -0
  173. package/scripts/resolve_worker_spawn.py +619 -0
  174. package/scripts/spawn_grok_batch.py +672 -0
  175. package/scripts/test_grok_headless_runner.py +626 -0
  176. package/scripts/test_grok_worker_preflight.py +1054 -0
  177. package/scripts/test_invoke_code_review.py +672 -0
  178. package/scripts/test_resolve_worker_spawn.py +1014 -0
  179. package/scripts/test_spawn_grok_batch.py +1017 -0
  180. package/skills/CLAUDE.md +6 -3
  181. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  182. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  183. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  184. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  185. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  186. package/skills/autoconverge/CLAUDE.md +3 -3
  187. package/skills/autoconverge/SKILL.md +9 -3
  188. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  189. package/skills/autoconverge/reference/convergence.md +33 -11
  190. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  191. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  192. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  193. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  194. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  195. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  196. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  197. package/skills/autoconverge/workflow/converge.mjs +202 -18
  198. package/skills/bugteam/CLAUDE.md +2 -2
  199. package/skills/bugteam/CONSTRAINTS.md +3 -2
  200. package/skills/bugteam/PROMPTS.md +7 -6
  201. package/skills/bugteam/SKILL.md +18 -13
  202. package/skills/bugteam/reference/README.md +2 -3
  203. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  204. package/skills/bugteam/reference/design-rationale.md +1 -1
  205. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  206. package/skills/bugteam/reference/team-setup.md +8 -2
  207. package/skills/closeout/SKILL.md +153 -0
  208. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  209. package/skills/closeout/reference/issue-body-templates.md +108 -0
  210. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  211. package/skills/codex-review/CLAUDE.md +46 -0
  212. package/skills/codex-review/SKILL.md +181 -0
  213. package/skills/codex-review/reference/CLAUDE.md +15 -0
  214. package/skills/codex-review/reference/cli-contract.md +253 -0
  215. package/skills/codex-review/reference/loop-integration.md +118 -0
  216. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  217. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  218. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  219. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  220. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  221. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  222. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  223. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  224. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  225. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  226. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  227. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  228. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  229. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  230. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  231. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  232. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  233. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  234. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  235. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  236. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  237. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  238. package/skills/codex-review/test_skill_scaffold.py +192 -0
  239. package/skills/grok-spawn/CLAUDE.md +28 -0
  240. package/skills/grok-spawn/SKILL.md +226 -0
  241. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  242. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  243. package/skills/grokify/SKILL.md +9 -1
  244. package/skills/grokify/capability-claims.test.mjs +28 -0
  245. package/skills/grokify/evals/README.md +72 -0
  246. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  247. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  248. package/skills/orchestrator/SKILL.md +32 -22
  249. package/skills/orchestrator-refresh/SKILL.md +12 -8
  250. package/skills/pr-converge/CLAUDE.md +1 -1
  251. package/skills/pr-converge/SKILL.md +34 -13
  252. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  253. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  254. package/skills/pr-converge/reference/ground-rules.md +1 -1
  255. package/skills/pr-converge/reference/per-tick.md +130 -42
  256. package/skills/pr-converge/reference/state-schema.md +10 -0
  257. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  258. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  259. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  260. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  261. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  262. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  263. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  264. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  265. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  266. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  267. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  268. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  269. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  270. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  271. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  272. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  273. package/skills/pr-fix-protocol/SKILL.md +65 -0
  274. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  275. package/skills/reviewer-gates/SKILL.md +7 -5
  276. package/skills/skill-builder/references/skill-modularity.md +1 -1
  277. package/skills/team-advisor/SKILL.md +16 -12
  278. package/system-prompts/software-engineer.xml +7 -6
  279. package/hooks/validators/test_verify_paths.py +0 -32
  280. package/hooks/validators/verify_paths.py +0 -57
@@ -16,7 +16,9 @@ module so the hooks.json wiring and every importer resolve unchanged.
16
16
 
17
17
  A repository named in ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories``
18
18
  list in ``~/.claude/local-identity.json`` skips the staged-commit scan; a
19
- repository without a readable origin remote is never exempt.
19
+ repository without a readable origin remote is never exempt. Exact values listed
20
+ under ``pii_allowlisted_values`` for a repository's origin slug may pass the
21
+ Write/Edit and staged-commit scans in that repository only.
20
22
  """
21
23
 
22
24
  from __future__ import annotations
@@ -25,67 +27,83 @@ import json
25
27
  import subprocess
26
28
  import sys
27
29
  from pathlib import Path
28
- from urllib.parse import urlparse
29
-
30
- _blocking_directory = str(Path(__file__).resolve().parent)
31
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
32
- if _blocking_directory not in sys.path:
33
- sys.path.insert(0, _blocking_directory)
34
- if _hooks_directory not in sys.path:
35
- sys.path.insert(0, _hooks_directory)
36
-
37
- from block_main_commit import resolve_directory # noqa: E402
38
- from pii_commit_command import ( # noqa: E402
39
- extract_git_commit_working_directory,
40
- is_git_commit_shell_command,
41
- )
42
- from pii_payload_scan import ( # noqa: E402
43
- build_deny_reason,
44
- evaluate_post_body_texts,
45
- evaluate_write_edit_payload,
46
- )
47
- from pii_scanner import is_path_exempt_from_pii_scan, scan_text_for_pii # noqa: E402
48
- from precommit_code_rules_gate import resolve_repository_root # noqa: E402
49
- from volatile_path_in_post_blocker import ( # noqa: E402
50
- extract_gh_post_body_texts_for_privacy_gate,
51
- extract_mcp_body_texts,
52
- )
53
-
54
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
55
- from hooks_constants.local_identity import pii_exempt_repository_slugs # noqa: E402
56
- from hooks_constants.pii_prevention_constants import ( # noqa: E402
57
- ALL_GIT_ORIGIN_URL_COMMAND,
58
- ALL_NETWORK_GIT_URL_SCHEMES,
59
- ALL_SHELL_TOOL_NAMES,
60
- ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
61
- ALL_STAGED_FILES_COMMAND,
62
- ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
63
- BODY_FILE_ENCODING,
64
- GIT_COMMAND_TIMEOUT_SECONDS,
65
- GIT_URL_SUFFIX,
66
- GITHUB_COM_HOST,
67
- HOOK_SCRIPT_BASENAME,
68
- MAXIMUM_STAGED_FILE_BYTES,
69
- MCP_GITHUB_TOOL_PREFIX,
70
- MINIMUM_OWNER_REPO_SEGMENT_COUNT,
71
- NULL_BYTE_MARKER,
72
- POSIX_PATH_SEPARATOR,
73
- REPOSITORY_ROOT_UNRESOLVED_REASON,
74
- SCP_STYLE_PATH_SEPARATOR,
75
- STAGED_BLOB_PREFIX,
76
- STAGED_BLOB_REASON_DECODE_FAILED,
77
- STAGED_BLOB_REASON_GIT_SHOW_FAILED,
78
- STAGED_BLOB_REASON_NULL_BYTES,
79
- STAGED_BLOB_REASON_OVERSIZED,
80
- STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE,
81
- STAGED_LIST_FAILURE_REASON,
82
- URL_SCHEME_SEPARATOR,
83
- USERINFO_HOST_SEPARATOR,
84
- WINDOWS_PATH_SEPARATOR,
85
- )
86
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
87
- read_hook_input_dictionary_from_stdin,
88
- )
30
+
31
+ try:
32
+ _blocking_directory = str(Path(__file__).resolve().parent)
33
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
34
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
35
+ if each_bootstrap_directory not in sys.path:
36
+ sys.path.insert(0, each_bootstrap_directory)
37
+ from pii_commit_command import (
38
+ extract_git_commit_working_directory,
39
+ is_git_commit_shell_command,
40
+ )
41
+ from pii_payload_scan import (
42
+ build_deny_reason,
43
+ evaluate_post_body_texts,
44
+ evaluate_write_edit_payload,
45
+ )
46
+ from pii_prevention_blocker_parts.repository_exemption import (
47
+ _is_repository_exempt_from_pii_scan,
48
+ _owner_repo_slug_from_origin_url,
49
+ repository_allowlisted_values,
50
+ )
51
+ from pii_prevention_blocker_parts.repository_resolution import (
52
+ compose_command_working_directory,
53
+ expand_user_directory,
54
+ refusal_reason_for_unresolved_repository,
55
+ )
56
+ from pii_scanner import is_path_exempt_from_pii_scan, scan_text_for_pii
57
+ from precommit_code_rules_gate import resolve_repository_root
58
+ from volatile_path_in_post_blocker import (
59
+ extract_gh_post_body_texts_for_privacy_gate,
60
+ extract_mcp_body_texts,
61
+ )
62
+
63
+ from hooks_constants.hook_block_logger import log_hook_block
64
+ from hooks_constants.pii_prevention_constants import (
65
+ ALL_SHELL_TOOL_NAMES,
66
+ ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
67
+ ALL_STAGED_FILES_COMMAND,
68
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
69
+ BODY_FILE_ENCODING,
70
+ GIT_COMMAND_TIMEOUT_SECONDS,
71
+ HOOK_SCRIPT_BASENAME,
72
+ MAXIMUM_STAGED_FILE_BYTES,
73
+ MCP_GITHUB_TOOL_PREFIX,
74
+ NULL_BYTE_MARKER,
75
+ STAGED_BLOB_PREFIX,
76
+ STAGED_BLOB_REASON_DECODE_FAILED,
77
+ STAGED_BLOB_REASON_GIT_SHOW_FAILED,
78
+ STAGED_BLOB_REASON_NULL_BYTES,
79
+ STAGED_BLOB_REASON_OVERSIZED,
80
+ STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE,
81
+ STAGED_LIST_FAILURE_REASON,
82
+ )
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
+ "pii_prevention_blocker: cannot import its sibling modules; "
89
+ "ensure the blocking and hooks directories are importable."
90
+ ) from import_error
91
+
92
+
93
+ __all__ = [
94
+ "evaluate",
95
+ "evaluate_bash_command",
96
+ "evaluate_post_body_texts",
97
+ "evaluate_staged_commit",
98
+ "evaluate_write_edit_payload",
99
+ "extract_git_commit_working_directory",
100
+ "is_git_commit_shell_command",
101
+ "list_staged_file_paths",
102
+ "read_staged_file_text",
103
+ "resolve_repository_root",
104
+ "_owner_repo_slug_from_origin_url",
105
+ "main",
106
+ ]
89
107
 
90
108
 
91
109
  def list_staged_file_paths(
@@ -103,7 +121,7 @@ def list_staged_file_paths(
103
121
  try:
104
122
  completed_process = subprocess.run(
105
123
  list(ALL_STAGED_FILES_COMMAND),
106
- capture_output=True,
124
+ check=False, capture_output=True,
107
125
  text=True,
108
126
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
109
127
  cwd=str(repository_root),
@@ -120,64 +138,85 @@ def list_staged_file_paths(
120
138
  return all_paths, None
121
139
 
122
140
 
141
+ def _git_show_staged_blob(
142
+ repository_root: Path, staged_blob_reference: str
143
+ ) -> subprocess.CompletedProcess[bytes] | None:
144
+ """Return the ``git show`` result for a staged blob, or None on failure."""
145
+ try:
146
+ completed_process = subprocess.run(
147
+ list(ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX) + [staged_blob_reference],
148
+ check=False, capture_output=True,
149
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
150
+ cwd=str(repository_root),
151
+ )
152
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
153
+ return None
154
+ if completed_process.returncode != 0:
155
+ return None
156
+ return completed_process
157
+
158
+
159
+ def _unscannable_result(relative_path: str, reason: str) -> tuple[None, str]:
160
+ """Build the ``(None, deny_reason)`` pair for an unscannable staged blob."""
161
+ return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
162
+ relative_path=relative_path, reason=reason
163
+ )
164
+
165
+
123
166
  def read_staged_file_text(
124
167
  repository_root: Path, relative_path: str
125
168
  ) -> tuple[str | None, str | None]:
126
- """Return staged blob text, or report why the blob is unscannable.
169
+ """Return staged blob text, or ``(None, deny_reason)`` when unscannable.
127
170
 
128
171
  Args:
129
172
  repository_root: Repository root for the git show working directory.
130
173
  relative_path: Repository-relative path of the staged file.
131
174
 
132
175
  Returns:
133
- ``(text, None)`` when the blob is scannable UTF-8 text, or
134
- ``(None, deny_reason)`` when the blob cannot be scanned (fail-closed).
176
+ ``(text, None)`` for scannable UTF-8 text; ``(None, reason)`` otherwise.
135
177
  """
136
178
  staged_blob_reference = STAGED_BLOB_PREFIX + relative_path
137
- try:
138
- completed_process = subprocess.run(
139
- list(ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX) + [staged_blob_reference],
140
- capture_output=True,
141
- timeout=GIT_COMMAND_TIMEOUT_SECONDS,
142
- cwd=str(repository_root),
143
- )
144
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
145
- return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
146
- relative_path=relative_path,
147
- reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
148
- )
149
- if completed_process.returncode != 0:
150
- return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
151
- relative_path=relative_path,
152
- reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
153
- )
179
+ completed_process = _git_show_staged_blob(repository_root, staged_blob_reference)
180
+ if completed_process is None:
181
+ return _unscannable_result(relative_path, STAGED_BLOB_REASON_GIT_SHOW_FAILED)
154
182
  raw_bytes = completed_process.stdout
155
183
  if len(raw_bytes) > MAXIMUM_STAGED_FILE_BYTES:
156
- return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
157
- relative_path=relative_path,
158
- reason=STAGED_BLOB_REASON_OVERSIZED,
159
- )
184
+ return _unscannable_result(relative_path, STAGED_BLOB_REASON_OVERSIZED)
160
185
  if NULL_BYTE_MARKER in raw_bytes:
161
- return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
162
- relative_path=relative_path,
163
- reason=STAGED_BLOB_REASON_NULL_BYTES,
164
- )
186
+ return _unscannable_result(relative_path, STAGED_BLOB_REASON_NULL_BYTES)
165
187
  try:
166
188
  return raw_bytes.decode(BODY_FILE_ENCODING), None
167
189
  except UnicodeDecodeError:
168
- return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
169
- relative_path=relative_path,
170
- reason=STAGED_BLOB_REASON_DECODE_FAILED,
171
- )
190
+ return _unscannable_result(relative_path, STAGED_BLOB_REASON_DECODE_FAILED)
172
191
 
173
192
 
174
- def evaluate_staged_commit(
175
- repository_root: Path,
193
+ def _scan_staged_path(
194
+ repository_root: Path, relative_path: str, all_allowlisted_values: frozenset[str]
176
195
  ) -> str | None:
196
+ """Return a deny reason for one staged path, or None when it is clean."""
197
+ staged_text, unscannable_reason = read_staged_file_text(
198
+ repository_root, relative_path
199
+ )
200
+ if unscannable_reason is not None:
201
+ return unscannable_reason
202
+ if staged_text is None:
203
+ return _unscannable_result(relative_path, STAGED_BLOB_REASON_GIT_SHOW_FAILED)[1]
204
+ all_findings = [
205
+ each_finding
206
+ for each_finding in scan_text_for_pii(staged_text)
207
+ if each_finding.matched_text not in all_allowlisted_values
208
+ ]
209
+ if not all_findings:
210
+ return None
211
+ return build_deny_reason(all_findings, f"staged commit ({relative_path})")
212
+
213
+
214
+ def evaluate_staged_commit(repository_root: Path) -> str | None:
177
215
  """Return a deny reason when staged content carries PII or is unscannable.
178
216
 
179
217
  Fail-closed: git list/show failures and unscannable blobs deny the commit
180
- rather than treating unread content as clean.
218
+ rather than treating unread content as clean. A value in the repository's
219
+ PII allowlist is dropped from the findings, so its commits may carry it.
181
220
 
182
221
  Args:
183
222
  repository_root: Repository whose index is about to be committed.
@@ -188,185 +227,45 @@ def evaluate_staged_commit(
188
227
  all_relative_paths, list_failure_reason = list_staged_file_paths(repository_root)
189
228
  if list_failure_reason is not None or all_relative_paths is None:
190
229
  return list_failure_reason or STAGED_LIST_FAILURE_REASON
230
+ all_allowlisted_values = repository_allowlisted_values(repository_root)
191
231
  for each_relative_path in all_relative_paths:
192
232
  if is_path_exempt_from_pii_scan(each_relative_path):
193
233
  continue
194
- staged_text, unscannable_reason = read_staged_file_text(
195
- repository_root, each_relative_path
234
+ deny_reason = _scan_staged_path(
235
+ repository_root, each_relative_path, all_allowlisted_values
196
236
  )
197
- if unscannable_reason is not None:
198
- return unscannable_reason
199
- if staged_text is None:
200
- return STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
201
- relative_path=each_relative_path,
202
- reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
203
- )
204
- all_findings = scan_text_for_pii(staged_text)
205
- if all_findings:
206
- gate_surface = f"staged commit ({each_relative_path})"
207
- return build_deny_reason(all_findings, gate_surface)
237
+ if deny_reason is not None:
238
+ return deny_reason
208
239
  return None
209
240
 
210
241
 
211
- def _strip_trailing_path_separators(origin_url: str) -> str:
212
- stripped_url = origin_url
213
- while stripped_url and stripped_url[-1] in (
214
- POSIX_PATH_SEPARATOR,
215
- WINDOWS_PATH_SEPARATOR,
216
- ):
217
- stripped_url = stripped_url[:-1]
218
- return stripped_url
219
-
220
-
221
- def _strip_trailing_git_suffix(origin_url: str) -> str:
222
- if origin_url.lower().endswith(GIT_URL_SUFFIX):
223
- return origin_url[: -len(GIT_URL_SUFFIX)]
224
- return origin_url
225
-
226
-
227
- def _normalized_origin_url(origin_url: str) -> str:
228
- return _strip_trailing_git_suffix(_strip_trailing_path_separators(origin_url))
229
-
230
-
231
- def _host_and_path_from_scheme_url(origin_url: str) -> tuple[str, str] | None:
232
- parsed_url = urlparse(origin_url)
233
- if parsed_url.scheme not in ALL_NETWORK_GIT_URL_SCHEMES:
234
- return None
235
- maybe_host = parsed_url.hostname
236
- if maybe_host is None:
237
- return None
238
- try:
239
- parsed_url.port
240
- except ValueError:
241
- return None
242
- repository_path = parsed_url.path.replace(
243
- WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR
244
- ).lstrip(POSIX_PATH_SEPARATOR)
245
- return maybe_host.lower(), repository_path
246
-
247
-
248
- def _host_and_path_from_scp_url(origin_url: str) -> tuple[str, str] | None:
249
- if USERINFO_HOST_SEPARATOR not in origin_url:
250
- return None
251
- userinfo_and_host, separator, repository_path = origin_url.partition(
252
- SCP_STYLE_PATH_SEPARATOR
253
- )
254
- if not separator or USERINFO_HOST_SEPARATOR not in userinfo_and_host:
255
- return None
256
- if not repository_path:
257
- return None
258
- host = userinfo_and_host.rsplit(USERINFO_HOST_SEPARATOR, maxsplit=1)[-1]
259
- if not host:
260
- return None
261
- normalized_path = repository_path.replace(
262
- WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR
263
- )
264
- return host.lower(), normalized_path
265
-
266
-
267
- def _host_and_repository_path_from_origin_url(
268
- origin_url: str,
269
- ) -> tuple[str, str] | None:
270
- normalized_url = _normalized_origin_url(origin_url)
271
- if URL_SCHEME_SEPARATOR in normalized_url:
272
- return _host_and_path_from_scheme_url(normalized_url)
273
- return _host_and_path_from_scp_url(normalized_url)
274
-
275
-
276
- def _owner_repo_slug_from_path(repository_path: str) -> str | None:
277
- all_segments = [
278
- each_segment
279
- for each_segment in repository_path.split(POSIX_PATH_SEPARATOR)
280
- if each_segment
281
- ]
282
- if len(all_segments) < MINIMUM_OWNER_REPO_SEGMENT_COUNT:
283
- return None
284
- owner_name = all_segments[0]
285
- repository_name = all_segments[1]
286
- return POSIX_PATH_SEPARATOR.join((owner_name, repository_name)).lower()
287
-
288
-
289
- def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
290
- """Return the lowercased owner/repo slug from a github.com origin URL.
291
-
292
- ::
293
-
294
- https://github.com/Owner/Repo.git -> owner/repo
295
- https://github.com/Owner/Repo -> owner/repo
296
- https://github.com:443/Owner/Repo.git -> owner/repo
297
- ssh://git@github.com:22/Owner/Repo.git -> owner/repo
298
- git@github.com:Owner/Repo.git -> owner/repo
299
- ssh://git@github.com/Owner/Repo.git -> owner/repo
300
- https://github.com/Owner/Repo.git/ -> owner/repo
301
- https://evil.test/Owner/Repo.git -> None
302
- https://github.com:443.evil.test/Owner/Repo.git -> None
303
- ssh://git@github.com:22.evil.test/Owner/Repo.git -> None
304
- C:/repos/Owner/Repo -> None
305
-
306
- Accepts only the exact host ``github.com`` (case-insensitive). Spoof hosts,
307
- unparseable port authorities, and path-shaped origins return None so the
308
- repository is never exempt.
309
-
310
- Args:
311
- origin_url: The ``remote.origin.url`` value, https or ssh form.
312
-
313
- Returns:
314
- The ``owner/repo`` slug in lowercase, or None when the host is not
315
- exactly ``github.com``, the port authority is unparseable, or the URL
316
- carries no owner/repo tail.
317
- """
318
- maybe_host_and_path = _host_and_repository_path_from_origin_url(origin_url)
319
- if maybe_host_and_path is None:
320
- return None
321
- host, repository_path = maybe_host_and_path
322
- if host != GITHUB_COM_HOST:
323
- return None
324
- return _owner_repo_slug_from_path(repository_path)
325
-
242
+ def _evaluate_commit_staged_content(
243
+ bash_command: str, working_directory: str | None
244
+ ) -> str | None:
245
+ """Scan the staged content of the repository the commit command targets.
326
246
 
327
- def _repository_origin_slug(repository_root: Path) -> str | None:
328
- """Return the lowercased owner/repo slug of the origin remote, or None.
247
+ Resolves the repository from the command's ``-C``/``cd`` path, falling back
248
+ to the session working directory only when the command names none, so a
249
+ removed cwd never blocks a commit that targets a valid repo.
329
250
 
330
251
  Args:
331
- repository_root: Repository whose origin remote identifies it.
252
+ bash_command: The Bash or PowerShell tool command string.
253
+ working_directory: Session working directory fallback.
332
254
 
333
255
  Returns:
334
- The ``owner/repo`` slug in lowercase, or None when the repository has no
335
- readable origin remote or the URL carries no owner/repo tail.
256
+ Deny reason text, or None when the staged content is clean or exempt.
336
257
  """
337
- try:
338
- completed_process = subprocess.run(
339
- list(ALL_GIT_ORIGIN_URL_COMMAND),
340
- capture_output=True,
341
- text=True,
342
- timeout=GIT_COMMAND_TIMEOUT_SECONDS,
343
- cwd=str(repository_root),
344
- )
345
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
346
- return None
347
- if completed_process.returncode != 0:
258
+ command_directory = compose_command_working_directory(bash_command)
259
+ attempted_directory = (
260
+ command_directory if command_directory is not None else working_directory
261
+ )
262
+ expanded_directory = expand_user_directory(attempted_directory)
263
+ repository_root = resolve_repository_root(expanded_directory)
264
+ if repository_root is None:
265
+ return refusal_reason_for_unresolved_repository(attempted_directory)
266
+ if _is_repository_exempt_from_pii_scan(repository_root):
348
267
  return None
349
- return _owner_repo_slug_from_origin_url(completed_process.stdout.strip())
350
-
351
-
352
- def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
353
- """Report whether commits in *repository_root* skip the staged PII scan.
354
-
355
- Exempt repositories are named by owner/repo slug through
356
- ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in the
357
- git-ignored ``~/.claude/local-identity.json``. A repository without a readable
358
- origin remote is never exempt (fail-closed to scanning).
359
-
360
- Args:
361
- repository_root: Repository whose index is about to be committed.
362
-
363
- Returns:
364
- True when the repository's origin slug is in the exempt set.
365
- """
366
- origin_slug = _repository_origin_slug(repository_root)
367
- if origin_slug is None:
368
- return False
369
- return origin_slug in pii_exempt_repository_slugs()
268
+ return evaluate_staged_commit(repository_root)
370
269
 
371
270
 
372
271
  def evaluate_bash_command(
@@ -376,7 +275,8 @@ def evaluate_bash_command(
376
275
 
377
276
  Args:
378
277
  bash_command: The Bash or PowerShell tool command string.
379
- working_directory: Directory git should run in, or None for process CWD.
278
+ working_directory: Session working directory, used only when the command
279
+ names no repository path.
380
280
 
381
281
  Returns:
382
282
  Deny reason text, or None when the command is clean or out of scope.
@@ -393,14 +293,26 @@ def evaluate_bash_command(
393
293
  return post_deny_reason
394
294
  if not is_git_commit_shell_command(bash_command):
395
295
  return None
396
- command_directory = extract_git_commit_working_directory(bash_command)
397
- resolved_directory = resolve_directory(command_directory) or working_directory
398
- repository_root = resolve_repository_root(resolved_directory)
399
- if repository_root is None:
400
- return REPOSITORY_ROOT_UNRESOLVED_REASON
401
- if _is_repository_exempt_from_pii_scan(repository_root):
296
+ return _evaluate_commit_staged_content(bash_command, working_directory)
297
+
298
+
299
+ def _evaluate_shell_tool(
300
+ all_tool_input: dict[str, object], payload_by_key: dict[str, object]
301
+ ) -> str | None:
302
+ """Resolve the command and working directory, then gate a shell tool call."""
303
+ command_value = all_tool_input.get("command", "")
304
+ if not isinstance(command_value, str) or not command_value:
402
305
  return None
403
- return evaluate_staged_commit(repository_root)
306
+ working_directory_value = all_tool_input.get("working_directory")
307
+ working_directory = (
308
+ working_directory_value
309
+ if isinstance(working_directory_value, str)
310
+ else None
311
+ )
312
+ if working_directory is None:
313
+ cwd_value = payload_by_key.get("cwd")
314
+ working_directory = cwd_value if isinstance(cwd_value, str) else None
315
+ return evaluate_bash_command(command_value, working_directory=working_directory)
404
316
 
405
317
 
406
318
  def evaluate(payload_by_key: dict[str, object]) -> str | None:
@@ -416,30 +328,12 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
416
328
  tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
417
329
  raw_tool_input = payload_by_key.get("tool_input", {})
418
330
  all_tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
419
-
420
331
  if tool_name in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
421
332
  return evaluate_write_edit_payload(tool_name, all_tool_input)
422
-
423
333
  if tool_name in ALL_SHELL_TOOL_NAMES:
424
- command_value = all_tool_input.get("command", "")
425
- if not isinstance(command_value, str) or not command_value:
426
- return None
427
- working_directory_value = all_tool_input.get("working_directory")
428
- working_directory = (
429
- working_directory_value
430
- if isinstance(working_directory_value, str)
431
- else None
432
- )
433
- if working_directory is None:
434
- cwd_value = payload_by_key.get("cwd")
435
- working_directory = cwd_value if isinstance(cwd_value, str) else None
436
- return evaluate_bash_command(
437
- command_value, working_directory=working_directory
438
- )
439
-
334
+ return _evaluate_shell_tool(all_tool_input, payload_by_key)
440
335
  if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
441
336
  return evaluate_post_body_texts(extract_mcp_body_texts(all_tool_input))
442
-
443
337
  return None
444
338
 
445
339
 
@@ -474,7 +368,7 @@ def main() -> None:
474
368
  deny_reason = evaluate(payload_dictionary)
475
369
  if deny_reason is None:
476
370
  sys.exit(0)
477
- print(json.dumps(build_deny_payload(deny_reason)))
371
+ sys.stdout.write(json.dumps(build_deny_payload(deny_reason)) + "\n")
478
372
  sys.stdout.flush()
479
373
  sys.exit(0)
480
374
 
@@ -0,0 +1,24 @@
1
+ # pii_prevention_blocker_parts
2
+
3
+ The concern modules `pii_prevention_blocker.py` wires together. The entry hook
4
+ imports them and re-exports their surface for the test suite.
5
+
6
+ ## Modules
7
+
8
+ | File | Purpose |
9
+ |---|---|
10
+ | `repository_exemption.py` | Resolves a commit's origin owner/repo slug, decides whether the repository skips the staged PII scan, and resolves the exact values that repository allows past the scan |
11
+ | `repository_resolution.py` | Reads the repository a commit command targets — composing multiple `-C` values and a leading `cd`/`pushd` — and builds the deny reason naming the path when the repository root does not resolve |
12
+ | `__init__.py` | Package marker |
13
+
14
+ ## Subdirectories
15
+
16
+ | Entry | Description |
17
+ |---|---|
18
+ | `config/` | The repository-resolution deny-message template and the session-cwd label (`repository_resolution_constants.py`) |
19
+
20
+ ## Running tests
21
+
22
+ ```bash
23
+ python -m pytest packages/claude-dev-env/hooks/blocking/tests/
24
+ ```
@@ -0,0 +1 @@
1
+ """Decomposed concern modules the ``pii_prevention_blocker`` entry hook wires together."""
@@ -0,0 +1 @@
1
+ """Configuration home for the pii_prevention_blocker parts constants."""
@@ -0,0 +1,28 @@
1
+ """Named constants for the commit repository-resolution deny message.
2
+
3
+ The template carries an ``{attempted_path}`` slot so the refusal names the exact
4
+ directory whose git repository root did not resolve. The cwd label fills that
5
+ slot when the command named no path and the gate fell back to the session
6
+ working directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ REPOSITORY_ROOT_UNRESOLVED_REASON_TEMPLATE: str = (
12
+ "BLOCKED [pii_prevention_blocker]: could not resolve the git repository root "
13
+ "for a commit command targeting {attempted_path} (not a git work tree, git "
14
+ "missing, or bad -C or cd path). Refuse commit until the repository root is "
15
+ "resolvable."
16
+ )
17
+
18
+ REPOSITORY_ROOT_UNRESOLVED_CWD_LABEL: str = "the session working directory"
19
+
20
+ ALL_DIRECTORY_CHANGE_COMMAND_NAMES: frozenset[str] = frozenset(
21
+ {"cd", "pushd", "set-location", "sl"}
22
+ )
23
+
24
+ ALL_DIRECTORY_CHANGE_PATH_OPTION_NAMES: frozenset[str] = frozenset(
25
+ {"-path", "-literalpath"}
26
+ )
27
+
28
+ DIRECTORY_CHANGE_OPTION_TERMINATOR: str = "--"