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
@@ -0,0 +1,214 @@
1
+ """Per-repository PII-scan exemption and allowlist keyed on origin owner/repo.
2
+
3
+ A commit's repository is exempt from the staged PII scan when its
4
+ ``remote.origin.url`` resolves to an ``owner/repo`` slug named in
5
+ ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in
6
+ ``~/.claude/local-identity.json``. A repository without a readable origin remote
7
+ is never exempt (fail-closed to scanning). Exact values listed under
8
+ ``pii_allowlisted_values`` for the same slug may pass the scan in that
9
+ repository only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ from urllib.parse import urlparse
18
+
19
+ try:
20
+ _blocking_directory = str(Path(__file__).resolve().parent.parent)
21
+ _hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
22
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
23
+ if each_bootstrap_directory not in sys.path:
24
+ sys.path.insert(0, each_bootstrap_directory)
25
+ from hooks_constants.local_identity import (
26
+ pii_allowlisted_values_by_repository,
27
+ pii_exempt_repository_slugs,
28
+ )
29
+ from hooks_constants.pii_prevention_constants import (
30
+ ALL_GIT_ORIGIN_URL_COMMAND,
31
+ ALL_NETWORK_GIT_URL_SCHEMES,
32
+ GIT_COMMAND_TIMEOUT_SECONDS,
33
+ GIT_URL_SUFFIX,
34
+ GITHUB_COM_HOST,
35
+ MINIMUM_OWNER_REPO_SEGMENT_COUNT,
36
+ POSIX_PATH_SEPARATOR,
37
+ SCP_STYLE_PATH_SEPARATOR,
38
+ URL_SCHEME_SEPARATOR,
39
+ USERINFO_HOST_SEPARATOR,
40
+ WINDOWS_PATH_SEPARATOR,
41
+ )
42
+ except ImportError as import_error:
43
+ raise ImportError(
44
+ "repository_exemption: cannot import its sibling constants; "
45
+ "ensure the blocking and hooks directories are importable."
46
+ ) from import_error
47
+
48
+
49
+ def repository_allowlisted_values(repository_root: Path) -> frozenset[str]:
50
+ """Return the exact values the repository at *repository_root* may carry.
51
+
52
+ ::
53
+
54
+ origin slug in the allowlist mapping -> that repo's frozenset of values
55
+ no origin remote, or slug not mapped -> frozenset()
56
+
57
+ Args:
58
+ repository_root: Repository whose origin owner/repo slug keys the
59
+ per-repository PII allowlist.
60
+
61
+ Returns:
62
+ The exact literal values the repository's commits and writes may carry
63
+ past the PII scan, or an empty set when the repository has none.
64
+ """
65
+ origin_slug = _repository_origin_slug(repository_root)
66
+ if origin_slug is None:
67
+ return frozenset()
68
+ return pii_allowlisted_values_by_repository().get(origin_slug, frozenset())
69
+
70
+
71
+ def _strip_trailing_path_separators(origin_url: str) -> str:
72
+ stripped_url = origin_url
73
+ while stripped_url and stripped_url[-1] in (
74
+ POSIX_PATH_SEPARATOR,
75
+ WINDOWS_PATH_SEPARATOR,
76
+ ):
77
+ stripped_url = stripped_url[:-1]
78
+ return stripped_url
79
+
80
+
81
+ def _strip_trailing_git_suffix(origin_url: str) -> str:
82
+ if origin_url.lower().endswith(GIT_URL_SUFFIX):
83
+ return origin_url[: -len(GIT_URL_SUFFIX)]
84
+ return origin_url
85
+
86
+
87
+ def _normalized_origin_url(origin_url: str) -> str:
88
+ return _strip_trailing_git_suffix(_strip_trailing_path_separators(origin_url))
89
+
90
+
91
+ def _host_and_path_from_scheme_url(origin_url: str) -> tuple[str, str] | None:
92
+ parsed_url = urlparse(origin_url)
93
+ if parsed_url.scheme not in ALL_NETWORK_GIT_URL_SCHEMES:
94
+ return None
95
+ maybe_host = parsed_url.hostname
96
+ if maybe_host is None:
97
+ return None
98
+ try:
99
+ _ = parsed_url.port
100
+ except ValueError:
101
+ return None
102
+ repository_path = parsed_url.path.replace(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR).lstrip(
103
+ POSIX_PATH_SEPARATOR
104
+ )
105
+ return maybe_host.lower(), repository_path
106
+
107
+
108
+ def _host_and_path_from_scp_url(origin_url: str) -> tuple[str, str] | None:
109
+ if USERINFO_HOST_SEPARATOR not in origin_url:
110
+ return None
111
+ userinfo_and_host, separator, repository_path = origin_url.partition(SCP_STYLE_PATH_SEPARATOR)
112
+ if not separator or USERINFO_HOST_SEPARATOR not in userinfo_and_host:
113
+ return None
114
+ if not repository_path:
115
+ return None
116
+ host = userinfo_and_host.rsplit(USERINFO_HOST_SEPARATOR, maxsplit=1)[-1]
117
+ if not host:
118
+ return None
119
+ normalized_path = repository_path.replace(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR)
120
+ return host.lower(), normalized_path
121
+
122
+
123
+ def _host_and_repository_path_from_origin_url(
124
+ origin_url: str,
125
+ ) -> tuple[str, str] | None:
126
+ normalized_url = _normalized_origin_url(origin_url)
127
+ if URL_SCHEME_SEPARATOR in normalized_url:
128
+ return _host_and_path_from_scheme_url(normalized_url)
129
+ return _host_and_path_from_scp_url(normalized_url)
130
+
131
+
132
+ def _owner_repo_slug_from_path(repository_path: str) -> str | None:
133
+ all_segments = [
134
+ each_segment for each_segment in repository_path.split(POSIX_PATH_SEPARATOR) if each_segment
135
+ ]
136
+ if len(all_segments) < MINIMUM_OWNER_REPO_SEGMENT_COUNT:
137
+ return None
138
+ owner_name = all_segments[0]
139
+ repository_name = all_segments[1]
140
+ return POSIX_PATH_SEPARATOR.join((owner_name, repository_name)).lower()
141
+
142
+
143
+ def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
144
+ """Return the lowercased owner/repo slug from a github.com origin URL.
145
+
146
+ ::
147
+
148
+ https://github.com/Owner/Repo.git -> owner/repo
149
+ https://github.com/Owner/Repo -> owner/repo
150
+ https://evil.test/Owner/Repo.git -> None
151
+
152
+ Accepts only the exact host ``github.com`` (case-insensitive), in https or
153
+ ssh form. Spoof hosts, unparseable port authorities, and path-shaped origins
154
+ return None so the repository is never exempt.
155
+
156
+ Args:
157
+ origin_url: The ``remote.origin.url`` value, https or ssh form.
158
+
159
+ Returns:
160
+ The ``owner/repo`` slug in lowercase, or None when the host is not
161
+ exactly ``github.com`` or the URL carries no owner/repo tail.
162
+ """
163
+ maybe_host_and_path = _host_and_repository_path_from_origin_url(origin_url)
164
+ if maybe_host_and_path is None:
165
+ return None
166
+ host, repository_path = maybe_host_and_path
167
+ if host != GITHUB_COM_HOST:
168
+ return None
169
+ return _owner_repo_slug_from_path(repository_path)
170
+
171
+
172
+ def _repository_origin_slug(repository_root: Path) -> str | None:
173
+ """Return the lowercased owner/repo slug of the origin remote, or None.
174
+
175
+ Args:
176
+ repository_root: Repository whose origin remote identifies it.
177
+
178
+ Returns:
179
+ The ``owner/repo`` slug in lowercase, or None when the repository has no
180
+ readable origin remote or the URL carries no owner/repo tail.
181
+ """
182
+ try:
183
+ completed_process = subprocess.run(
184
+ list(ALL_GIT_ORIGIN_URL_COMMAND),
185
+ check=False, capture_output=True,
186
+ text=True,
187
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
188
+ cwd=str(repository_root),
189
+ )
190
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
191
+ return None
192
+ if completed_process.returncode != 0:
193
+ return None
194
+ return _owner_repo_slug_from_origin_url(completed_process.stdout.strip())
195
+
196
+
197
+ def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
198
+ """Report whether commits in *repository_root* skip the staged PII scan.
199
+
200
+ Exempt repositories are named by owner/repo slug through
201
+ ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in the
202
+ git-ignored ``~/.claude/local-identity.json``. A repository without a readable
203
+ origin remote is never exempt (fail-closed to scanning).
204
+
205
+ Args:
206
+ repository_root: Repository whose index is about to be committed.
207
+
208
+ Returns:
209
+ True when the repository's origin slug is in the exempt set.
210
+ """
211
+ origin_slug = _repository_origin_slug(repository_root)
212
+ if origin_slug is None:
213
+ return False
214
+ return origin_slug in pii_exempt_repository_slugs()
@@ -0,0 +1,208 @@
1
+ """Resolve the repository a git-commit command targets, not the session cwd.
2
+
3
+ The gate must scan the repository the command names, so a commit that names a
4
+ healthy repo is scanned even when the session working directory is gone::
5
+
6
+ git -C /a -C sub commit -> /a/sub (each -C composes, like chdir)
7
+ git -C /a/b -C /c/d commit -> /c/d (a later absolute -C resets)
8
+ cd "/my repo" && git commit -> /my repo (quoted cd path, shlex-parsed)
9
+ git commit -> None (falls back to the session cwd)
10
+
11
+ The session working directory is used only when the command names no path, so a
12
+ removed or invalid cwd never blocks a commit that targets a valid repo.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ try:
22
+ _blocking_directory = str(Path(__file__).resolve().parent.parent)
23
+ _hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
24
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
25
+ if each_bootstrap_directory not in sys.path:
26
+ sys.path.insert(0, each_bootstrap_directory)
27
+ from pii_commit_command import (
28
+ _all_command_segments,
29
+ _segment_invokes_git_commit,
30
+ _skip_leading_noop_tokens,
31
+ _strip_token_edge_quotes,
32
+ _token_is_git_binary,
33
+ )
34
+ from pii_prevention_blocker_parts.config.repository_resolution_constants import (
35
+ ALL_DIRECTORY_CHANGE_COMMAND_NAMES,
36
+ ALL_DIRECTORY_CHANGE_PATH_OPTION_NAMES,
37
+ DIRECTORY_CHANGE_OPTION_TERMINATOR,
38
+ REPOSITORY_ROOT_UNRESOLVED_CWD_LABEL,
39
+ REPOSITORY_ROOT_UNRESOLVED_REASON_TEMPLATE,
40
+ )
41
+
42
+ from hooks_constants.pii_prevention_constants import (
43
+ ALL_VALUE_TAKING_GIT_OPTIONS,
44
+ GIT_OPTION_WITH_VALUE_STEP,
45
+ GIT_WORKING_DIRECTORY_OPTION,
46
+ SINGLE_DASH_OPTION_PREFIX,
47
+ )
48
+ except ImportError as import_error:
49
+ raise ImportError(
50
+ "repository_resolution: cannot import its sibling helpers; "
51
+ "ensure the blocking and hooks directories are importable."
52
+ ) from import_error
53
+
54
+
55
+ def _consume_token(
56
+ all_following_tokens: list[str], token_index: int, all_values: list[str]
57
+ ) -> int | None:
58
+ """Advance past one token, appending any ``-C`` value; None ends the walk."""
59
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
60
+ if each_token == GIT_WORKING_DIRECTORY_OPTION:
61
+ value_index = token_index + 1
62
+ if value_index >= len(all_following_tokens):
63
+ return None
64
+ all_values.append(_strip_token_edge_quotes(all_following_tokens[value_index]))
65
+ return value_index + 1
66
+ if each_token in ALL_VALUE_TAKING_GIT_OPTIONS:
67
+ return token_index + GIT_OPTION_WITH_VALUE_STEP
68
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
69
+ return token_index + 1
70
+ return None
71
+
72
+
73
+ def _collect_working_directory_values(all_following_tokens: list[str]) -> list[str]:
74
+ """Return every ``-C`` value, in order, from a commit segment's tokens."""
75
+ all_values: list[str] = []
76
+ token_index: int | None = 0
77
+ while token_index is not None and token_index < len(all_following_tokens):
78
+ token_index = _consume_token(all_following_tokens, token_index, all_values)
79
+ return all_values
80
+
81
+
82
+ def _all_commit_working_directory_values(shell_command: str) -> list[str]:
83
+ """Return every ``-C`` value on the command's git-commit segment, in order."""
84
+ for each_segment in _all_command_segments(shell_command):
85
+ if not _segment_invokes_git_commit(each_segment):
86
+ continue
87
+ command_index = _skip_leading_noop_tokens(each_segment)
88
+ if command_index >= len(each_segment):
89
+ continue
90
+ if not _token_is_git_binary(each_segment[command_index]):
91
+ continue
92
+ return _collect_working_directory_values(each_segment[command_index + 1 :])
93
+ return []
94
+
95
+
96
+ def _directory_change_target_from_segment(
97
+ all_segment_tokens: list[str],
98
+ ) -> str | None:
99
+ """Return the destination path of a ``cd``/``pushd`` segment, or None."""
100
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
101
+ if command_index >= len(all_segment_tokens):
102
+ return None
103
+ command_token = _strip_token_edge_quotes(
104
+ all_segment_tokens[command_index]
105
+ ).lower()
106
+ if command_token not in ALL_DIRECTORY_CHANGE_COMMAND_NAMES:
107
+ return None
108
+ token_index = command_index + 1
109
+ while token_index < len(all_segment_tokens):
110
+ each_token = _strip_token_edge_quotes(all_segment_tokens[token_index])
111
+ if each_token == DIRECTORY_CHANGE_OPTION_TERMINATOR:
112
+ token_index += 1
113
+ continue
114
+ if each_token.lower() in ALL_DIRECTORY_CHANGE_PATH_OPTION_NAMES:
115
+ token_index += 1
116
+ continue
117
+ return each_token
118
+ return None
119
+
120
+
121
+ def _leading_directory_change_path(shell_command: str) -> str | None:
122
+ """Return the last ``cd``/``pushd`` path before the first git-commit segment.
123
+
124
+ Token-aware so ``git.exe`` (and other recognized git binary spellings) still
125
+ pick up a leading directory change, which the regex ``git commit`` fallback
126
+ misses.
127
+ """
128
+ last_changed_directory: str | None = None
129
+ for each_segment in _all_command_segments(shell_command):
130
+ if _segment_invokes_git_commit(each_segment):
131
+ return last_changed_directory
132
+ segment_directory = _directory_change_target_from_segment(each_segment)
133
+ if segment_directory is not None:
134
+ last_changed_directory = segment_directory
135
+ return None
136
+
137
+
138
+ def _next_composed_directory(composed_directory: str | None, each_value: str) -> str:
139
+ """Apply one ``-C`` value to the running composition, like a chained chdir."""
140
+ if os.path.isabs(each_value):
141
+ return each_value
142
+ if composed_directory is None:
143
+ return each_value
144
+ return os.path.join(composed_directory, each_value)
145
+
146
+
147
+ def _compose_working_directories(all_values: list[str]) -> str | None:
148
+ """Compose ordered ``-C`` values the way git applies them."""
149
+ composed_directory: str | None = None
150
+ for each_value in all_values:
151
+ composed_directory = _next_composed_directory(composed_directory, each_value)
152
+ return composed_directory
153
+
154
+
155
+ def compose_command_working_directory(shell_command: str) -> str | None:
156
+ """Return the directory a git-commit command targets, or None for the cwd.
157
+
158
+ ::
159
+
160
+ git -C /a -C sub commit -> /a/sub
161
+ cd "/my repo" && git commit -> /my repo
162
+ cd /repo && git.exe commit -> /repo
163
+ git commit -> None
164
+
165
+ Multiple ``-C`` values compose in order (a later absolute value resets); a
166
+ leading ``cd``/``pushd`` supplies the target when the command carries no
167
+ ``-C``.
168
+
169
+ Args:
170
+ shell_command: Bash or PowerShell tool command string.
171
+
172
+ Returns:
173
+ The composed target directory, or None when the command names none and
174
+ the commit runs in the session working directory.
175
+ """
176
+ all_values = _all_commit_working_directory_values(shell_command)
177
+ if all_values:
178
+ return _compose_working_directories(all_values)
179
+ return _leading_directory_change_path(shell_command)
180
+
181
+
182
+ def expand_user_directory(directory: str | None) -> str | None:
183
+ """Expand a leading ``~`` in *directory*, passing None through unchanged.
184
+
185
+ Args:
186
+ directory: A directory path that may carry a leading ``~``, or None.
187
+
188
+ Returns:
189
+ The tilde-expanded path, or None when *directory* is None.
190
+ """
191
+ if directory is None:
192
+ return None
193
+ return os.path.expanduser(directory)
194
+
195
+
196
+ def refusal_reason_for_unresolved_repository(attempted_directory: str | None) -> str:
197
+ """Return the deny reason naming the path whose repository root did not resolve.
198
+
199
+ Args:
200
+ attempted_directory: The directory the gate tried to resolve, or None
201
+ when it fell back to the session working directory.
202
+
203
+ Returns:
204
+ The deny reason text, naming the attempted path so the reader sees which
205
+ directory failed to resolve.
206
+ """
207
+ attempted_path = attempted_directory or REPOSITORY_ROOT_UNRESOLVED_CWD_LABEL
208
+ return REPOSITORY_ROOT_UNRESOLVED_REASON_TEMPLATE.format(attempted_path=attempted_path)
@@ -26,10 +26,14 @@ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
26
26
  ALL_WRITE_EDIT_TOOL_NAMES,
27
27
  ASK_USER_QUESTION_TOOL_NAME,
28
28
  BLOCKQUOTE_LINE_PATTERN,
29
+ DOT_CLAUDE_DIRECTORY_NAME,
29
30
  FENCED_CODE_BLOCK_PATTERN,
30
31
  FILE_PATH_PATTERN,
31
32
  INLINE_CODE_PATTERN,
32
33
  MARKDOWN_EXTENSION,
34
+ PROJECT_ALLOWLIST_FILENAME,
35
+ PROJECT_ROOT_WALK_LIMIT,
36
+ REPOSITORY_MARKER_NAME,
33
37
  URL_PATTERN,
34
38
  USER_FACING_PLAIN_LANGUAGE_NOTICE,
35
39
  )
@@ -49,13 +53,23 @@ def strip_non_prose_regions(text: str) -> str:
49
53
  return without_paths
50
54
 
51
55
 
52
- def find_banned_terms(text: str) -> list[tuple[str, str]]:
56
+ def find_banned_terms(
57
+ text: str, all_allowlisted_terms: frozenset[str] = frozenset()
58
+ ) -> list[tuple[str, str]]:
53
59
  """Return each (matched term, suggested replacement) found in the prose.
54
60
 
55
61
  Each term appears at most once, in first-seen order. Matching is
56
62
  case-insensitive and respects word boundaries; multi-word phrases match as
57
- whole units. Terms in the software-term allowlist are exempt and never
58
- flagged.
63
+ whole units. Terms in the software-term allowlist and terms in the
64
+ caller-supplied per-project allowlist are exempt and never flagged.
65
+
66
+ Args:
67
+ text: The prose to scan.
68
+ all_allowlisted_terms: Lowercased project-vocabulary terms to exempt, on top
69
+ of the built-in software-term allowlist.
70
+
71
+ Returns:
72
+ The (matched term, suggested replacement) pairs, in first-seen order.
59
73
  """
60
74
  prose_text = strip_non_prose_regions(text)
61
75
  all_matches: list[tuple[str, str]] = []
@@ -69,11 +83,128 @@ def find_banned_terms(text: str) -> list[tuple[str, str]]:
69
83
  continue
70
84
  if normalized_term in ALL_SOFTWARE_TERMS:
71
85
  continue
86
+ if normalized_term in all_allowlisted_terms:
87
+ continue
72
88
  seen_terms.add(normalized_term)
73
89
  all_matches.append((normalized_term, each_replacement))
74
90
  return all_matches
75
91
 
76
92
 
93
+ def _allowlist_start_directory(
94
+ tool_name: str, tool_input: dict, payload_by_key: dict[str, object]
95
+ ) -> Path | None:
96
+ """Return the directory to begin the project-allowlist search from.
97
+
98
+ A Write/Edit/MultiEdit on a file starts at that file's parent directory; an
99
+ AskUserQuestion (or a write with no path) starts at the session working
100
+ directory the payload carries.
101
+
102
+ Args:
103
+ tool_name: The intercepted tool's name.
104
+ tool_input: The intercepted tool's input payload.
105
+ payload_by_key: The full PreToolUse payload carrying ``cwd``.
106
+
107
+ Returns:
108
+ The starting directory, or None when neither a file path nor a working
109
+ directory is available.
110
+ """
111
+ if tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
112
+ file_path = tool_input.get("file_path", "")
113
+ if isinstance(file_path, str) and file_path:
114
+ return Path(file_path).parent
115
+ working_directory = payload_by_key.get("cwd", "")
116
+ if isinstance(working_directory, str) and working_directory:
117
+ return Path(working_directory)
118
+ return None
119
+
120
+
121
+ def _find_project_allowlist_file(start_directory: Path) -> Path | None:
122
+ """Walk ancestors from start_directory for a repo-scoped project allowlist file.
123
+
124
+ ::
125
+
126
+ parent/ .claude/allow -> ignored (above the repo root)
127
+ parent/repo/ <- .git .claude/allow -> applied (repo root reached)
128
+ parent/repo/pkg/ .claude/allow -> applied (inside the repo tree)
129
+
130
+ The allowlist must live inside the repository so it is reviewed like any
131
+ other committed config. The walk checks each directory for the allowlist,
132
+ then for the ``.git`` repository marker; it accepts an allowlist at or below
133
+ the first ``.git``-bearing directory, stops at that repository root, and
134
+ never ascends past it. When no ``.git`` marker appears within the walk
135
+ limit, the directory is not inside a repository and no allowlist applies, so
136
+ a global file such as ``~/.claude/plain-language-allow.json`` never loosens
137
+ the gate for every project.
138
+
139
+ Args:
140
+ start_directory: The directory to begin the upward walk from.
141
+
142
+ Returns:
143
+ The nearest in-repository ``.claude/plain-language-allow.json`` at or
144
+ below the repository root, or None when the walk finds no repository
145
+ root or no allowlist within it.
146
+ """
147
+ nearest_allowlist: Path | None = None
148
+ current_directory = start_directory
149
+ for _ in range(PROJECT_ROOT_WALK_LIMIT):
150
+ candidate = current_directory / DOT_CLAUDE_DIRECTORY_NAME / PROJECT_ALLOWLIST_FILENAME
151
+ if nearest_allowlist is None and candidate.is_file():
152
+ nearest_allowlist = candidate
153
+ if (current_directory / REPOSITORY_MARKER_NAME).exists():
154
+ return nearest_allowlist
155
+ if current_directory.parent == current_directory:
156
+ break
157
+ current_directory = current_directory.parent
158
+ return None
159
+
160
+
161
+ def _parse_project_allowlist_file(allowlist_path: Path) -> frozenset[str]:
162
+ """Read a JSON array of allowlist words into a lowercased term set.
163
+
164
+ Malformed JSON, an unreadable file, or any non-list shape yields an empty
165
+ set, so the hook falls back to the standard check rather than crashing.
166
+
167
+ Args:
168
+ allowlist_path: The allowlist file to read.
169
+
170
+ Returns:
171
+ The lowercased string entries, or an empty set on any read/parse fault.
172
+ """
173
+ try:
174
+ raw_text = allowlist_path.read_text(encoding="utf-8")
175
+ parsed_entries = json.loads(raw_text)
176
+ except (OSError, ValueError):
177
+ return frozenset()
178
+ if not isinstance(parsed_entries, list):
179
+ return frozenset()
180
+ return frozenset(
181
+ each_entry.lower() for each_entry in parsed_entries if isinstance(each_entry, str)
182
+ )
183
+
184
+
185
+ def _load_project_allowlist(
186
+ tool_name: str, tool_input: dict, payload_by_key: dict[str, object]
187
+ ) -> frozenset[str]:
188
+ """Load the per-project domain-vocabulary allowlist for the write's project.
189
+
190
+ Args:
191
+ tool_name: The intercepted tool's name.
192
+ tool_input: The intercepted tool's input payload.
193
+ payload_by_key: The full PreToolUse payload carrying ``cwd``.
194
+
195
+ Returns:
196
+ The lowercased allowlist terms for the project the write targets, or an
197
+ empty set when no allowlist applies.
198
+ """
199
+ start_directory = _allowlist_start_directory(tool_name, tool_input, payload_by_key)
200
+ if start_directory is None:
201
+ return frozenset()
202
+ allowlist_path = _find_project_allowlist_file(start_directory)
203
+ if allowlist_path is None:
204
+ return frozenset()
205
+ return _parse_project_allowlist_file(allowlist_path)
206
+
207
+
77
208
  def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
78
209
  """Return a deny reason naming each flagged term and its plain replacement."""
79
210
  swap_phrases = ", ".join(
@@ -197,7 +328,10 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
197
328
  if not prose_text:
198
329
  return None
199
330
 
200
- all_matches = find_banned_terms(prose_text)
331
+ all_allowlisted_terms = _load_project_allowlist(
332
+ raw_tool_name, raw_tool_input, payload_by_key
333
+ )
334
+ all_matches = find_banned_terms(prose_text, all_allowlisted_terms)
201
335
  if not all_matches:
202
336
  return None
203
337
 
@@ -46,14 +46,18 @@ def _read_body_file_contents(file_path: str) -> str | None:
46
46
  resolved_target = given_path.resolve()
47
47
  try:
48
48
  resolved_target.relative_to(allowed_root)
49
- except ValueError:
50
- raise PathTraversalError("symlink target resolves outside allowed root")
49
+ except ValueError as traversal_error:
50
+ raise PathTraversalError(
51
+ "symlink target resolves outside allowed root"
52
+ ) from traversal_error
51
53
  resolved_path = given_path.resolve()
52
54
  if not given_path.is_absolute():
53
55
  try:
54
56
  resolved_path.relative_to(allowed_root)
55
- except ValueError:
56
- raise PathTraversalError("relative path resolves outside allowed root")
57
+ except ValueError as traversal_error:
58
+ raise PathTraversalError(
59
+ "relative path resolves outside allowed root"
60
+ ) from traversal_error
57
61
  try:
58
62
  with open(resolved_path, "r", encoding=UTF8_ENCODING, errors="replace") as body_file:
59
63
  return body_file.read()
@@ -72,7 +72,7 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
72
72
  try:
73
73
  completed_process = subprocess.run(
74
74
  list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
75
- capture_output=True,
75
+ check=False, capture_output=True,
76
76
  text=True,
77
77
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
78
78
  cwd=working_directory,
@@ -102,7 +102,7 @@ def list_staged_python_files(repository_root: Path) -> list[str]:
102
102
  try:
103
103
  completed_process = subprocess.run(
104
104
  list(ALL_STAGED_PYTHON_FILES_COMMAND),
105
- capture_output=True,
105
+ check=False, capture_output=True,
106
106
  text=True,
107
107
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
108
108
  cwd=str(repository_root),
@@ -145,7 +145,7 @@ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
145
145
  str(repository_root),
146
146
  "--staged",
147
147
  ],
148
- capture_output=True,
148
+ check=False, capture_output=True,
149
149
  text=True,
150
150
  encoding="utf-8",
151
151
  errors="replace",