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,181 @@
1
+ """Tests for the harness session scratchpad exemption in code_rules_shared."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ import pytest
10
+
11
+ SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
12
+ FIXED_USER_ID = 4242
13
+ WORKING_DIRECTORY = "/home/user/project"
14
+ SESSION_ID = "session-abc-123"
15
+
16
+
17
+ def _load_shared_module() -> ModuleType:
18
+ module_spec = importlib.util.spec_from_file_location(
19
+ "code_rules_shared_under_test", SHARED_MODULE_PATH
20
+ )
21
+ assert module_spec is not None and module_spec.loader is not None
22
+ loaded_module = importlib.util.module_from_spec(module_spec)
23
+ module_spec.loader.exec_module(loaded_module)
24
+ return loaded_module
25
+
26
+
27
+ _SHARED_MODULE = _load_shared_module()
28
+
29
+
30
+ def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
31
+ monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
32
+
33
+
34
+ def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
35
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
36
+
37
+
38
+ def _build_scratchpad_directory(temporary_root: Path) -> Path:
39
+ mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
40
+ scratchpad_directory = (
41
+ temporary_root
42
+ / f"claude-{FIXED_USER_ID}"
43
+ / mangled_working_directory
44
+ / SESSION_ID
45
+ / "scratchpad"
46
+ )
47
+ scratchpad_directory.mkdir(parents=True)
48
+ return scratchpad_directory
49
+
50
+
51
+ def _session_payload() -> dict[str, str]:
52
+ return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
53
+
54
+
55
+ def test_returns_true_for_file_inside_reconstructed_scratchpad(
56
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
57
+ ) -> None:
58
+ _install_fixed_user_id(monkeypatch)
59
+ _point_temporary_directory_at(monkeypatch, tmp_path)
60
+ scratchpad_directory = _build_scratchpad_directory(tmp_path)
61
+ throwaway_script = scratchpad_directory / "one_off_tool.py"
62
+
63
+ assert (
64
+ _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
65
+ is True
66
+ )
67
+
68
+
69
+ def test_returns_false_for_file_outside_scratchpad(
70
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
71
+ ) -> None:
72
+ _install_fixed_user_id(monkeypatch)
73
+ _point_temporary_directory_at(monkeypatch, tmp_path)
74
+ _build_scratchpad_directory(tmp_path)
75
+ project_module = tmp_path / "elsewhere" / "orders.py"
76
+
77
+ assert (
78
+ _SHARED_MODULE.is_under_session_scratchpad(str(project_module), _session_payload()) is False
79
+ )
80
+
81
+
82
+ def test_resolves_symlink_into_scratchpad_to_true(
83
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
84
+ ) -> None:
85
+ _install_fixed_user_id(monkeypatch)
86
+ _point_temporary_directory_at(monkeypatch, tmp_path)
87
+ scratchpad_directory = _build_scratchpad_directory(tmp_path)
88
+ real_script = scratchpad_directory / "real_tool.py"
89
+ real_script.write_text("value = 1\n")
90
+ link_outside = tmp_path / "link_to_scratchpad.py"
91
+ link_outside.symlink_to(real_script)
92
+
93
+ assert _SHARED_MODULE.is_under_session_scratchpad(str(link_outside), _session_payload()) is True
94
+
95
+
96
+ def test_symlink_resolving_outside_scratchpad_is_false(
97
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
98
+ ) -> None:
99
+ _install_fixed_user_id(monkeypatch)
100
+ _point_temporary_directory_at(monkeypatch, tmp_path)
101
+ scratchpad_directory = _build_scratchpad_directory(tmp_path)
102
+ real_target_outside = tmp_path / "outside_target.py"
103
+ real_target_outside.write_text("value = 1\n")
104
+ link_inside_scratchpad = scratchpad_directory / "sneaky_link.py"
105
+ link_inside_scratchpad.symlink_to(real_target_outside)
106
+
107
+ assert (
108
+ _SHARED_MODULE.is_under_session_scratchpad(str(link_inside_scratchpad), _session_payload())
109
+ is False
110
+ )
111
+
112
+
113
+ def test_missing_session_id_signal_returns_false(
114
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
115
+ ) -> None:
116
+ _install_fixed_user_id(monkeypatch)
117
+ _point_temporary_directory_at(monkeypatch, tmp_path)
118
+ scratchpad_directory = _build_scratchpad_directory(tmp_path)
119
+ throwaway_script = scratchpad_directory / "one_off_tool.py"
120
+ payload_without_session = {"cwd": WORKING_DIRECTORY}
121
+
122
+ assert (
123
+ _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_session)
124
+ is False
125
+ )
126
+
127
+
128
+ def test_missing_working_directory_signal_returns_false(
129
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
130
+ ) -> None:
131
+ _install_fixed_user_id(monkeypatch)
132
+ _point_temporary_directory_at(monkeypatch, tmp_path)
133
+ scratchpad_directory = _build_scratchpad_directory(tmp_path)
134
+ throwaway_script = scratchpad_directory / "one_off_tool.py"
135
+ payload_without_cwd = {"session_id": SESSION_ID}
136
+
137
+ assert (
138
+ _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
139
+ is False
140
+ )
141
+
142
+
143
+ def test_absent_getuid_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
144
+ _install_fixed_user_id(monkeypatch)
145
+ _point_temporary_directory_at(monkeypatch, tmp_path)
146
+ scratchpad_directory = _build_scratchpad_directory(tmp_path)
147
+ throwaway_script = scratchpad_directory / "one_off_tool.py"
148
+ monkeypatch.delattr(os, "getuid", raising=False)
149
+
150
+ assert (
151
+ _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
152
+ is False
153
+ )
154
+
155
+
156
+ def test_nonexistent_scratchpad_directory_returns_false(
157
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
158
+ ) -> None:
159
+ _install_fixed_user_id(monkeypatch)
160
+ _point_temporary_directory_at(monkeypatch, tmp_path)
161
+ mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
162
+ unbuilt_target = (
163
+ tmp_path
164
+ / f"claude-{FIXED_USER_ID}"
165
+ / mangled_working_directory
166
+ / SESSION_ID
167
+ / "scratchpad"
168
+ / "one_off_tool.py"
169
+ )
170
+
171
+ assert (
172
+ _SHARED_MODULE.is_under_session_scratchpad(str(unbuilt_target), _session_payload()) is False
173
+ )
174
+
175
+
176
+ def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
177
+ _install_fixed_user_id(monkeypatch)
178
+ _point_temporary_directory_at(monkeypatch, tmp_path)
179
+ _build_scratchpad_directory(tmp_path)
180
+
181
+ assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
@@ -0,0 +1,154 @@
1
+ """End-to-end tests for the inventory/orphan pending-intent deadlock resolution.
2
+
3
+ Each test drives the two real hook binaries through their production ``main()``
4
+ stdin path over a temp directory tree whose ``CLAUDE.md`` inventory names two
5
+ sibling files. A shared ``session_id`` and an isolated ``TMPDIR`` make the two
6
+ hooks read one shared intent-records file, so a deny by one hook opens the gate
7
+ for the sibling's second write — in either add order.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ BLOCKING_DIRECTORY = Path(__file__).resolve().parent
17
+
18
+ INVENTORY_HOOK = "package_inventory_stale_blocker.py"
19
+ ORPHAN_HOOK = "claude_md_orphan_file_blocker.py"
20
+
21
+ SESSION_ID = "deadlock-session"
22
+
23
+ INVENTORY_TABLE_HEADER = "# pkg\n\n| File | Purpose |\n|---|---|\n"
24
+ ALPHA_ROW = "| `alpha.py` | Does A |\n"
25
+ BETA_ROW = "| `beta.py` | Does B |\n"
26
+ GAMMA_ROW = "| `gamma.py` | Does G |\n"
27
+ EPSILON_ROW = "| `epsilon.py` | Does E |\n"
28
+
29
+ BASE_INVENTORY = INVENTORY_TABLE_HEADER + ALPHA_ROW + BETA_ROW
30
+ INVENTORY_WITH_GAMMA = BASE_INVENTORY + GAMMA_ROW
31
+ INVENTORY_WITH_EPSILON = BASE_INVENTORY + EPSILON_ROW
32
+
33
+ MODULE_BODY = "x = 1\n"
34
+
35
+
36
+ def _package_directory(tmp_path: Path) -> Path:
37
+ """Return a package directory holding a CLAUDE.md table naming two siblings."""
38
+ package_directory = tmp_path / "pkg"
39
+ package_directory.mkdir()
40
+ (package_directory / "CLAUDE.md").write_text(BASE_INVENTORY, encoding="utf-8")
41
+ (package_directory / "alpha.py").write_text(MODULE_BODY, encoding="utf-8")
42
+ (package_directory / "beta.py").write_text(MODULE_BODY, encoding="utf-8")
43
+ return package_directory
44
+
45
+
46
+ def _run_hook(
47
+ hook_filename: str, tool_name: str, tool_input: dict, temp_directory: Path
48
+ ) -> subprocess.CompletedProcess:
49
+ """Run one hook binary over a payload, sharing an isolated intent-records store."""
50
+ payload = json.dumps(
51
+ {"tool_name": tool_name, "tool_input": tool_input, "session_id": SESSION_ID}
52
+ )
53
+ hook_environment = dict(os.environ)
54
+ hook_environment["TMPDIR"] = str(temp_directory)
55
+ return subprocess.run(
56
+ [sys.executable, str(BLOCKING_DIRECTORY / hook_filename)],
57
+ input=payload,
58
+ capture_output=True,
59
+ text=True,
60
+ check=False,
61
+ env=hook_environment,
62
+ )
63
+
64
+
65
+ def _decision(completed: subprocess.CompletedProcess) -> str | None:
66
+ """Return the permission decision a hook emitted, or None when it allowed."""
67
+ if not completed.stdout.strip():
68
+ return None
69
+ return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"]
70
+
71
+
72
+ def _reason(completed: subprocess.CompletedProcess) -> str:
73
+ """Return the deny reason a hook emitted."""
74
+ return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecisionReason"]
75
+
76
+
77
+ def test_file_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
78
+ package_directory = _package_directory(tmp_path)
79
+ new_file = package_directory / "gamma.py"
80
+ claude_md = package_directory / "CLAUDE.md"
81
+
82
+ first_write = _run_hook(
83
+ INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
84
+ )
85
+ assert _decision(first_write) == "deny"
86
+ assert "gamma.py" in _reason(first_write)
87
+ assert "recorded" in _reason(first_write)
88
+
89
+ row_write = _run_hook(
90
+ ORPHAN_HOOK,
91
+ "Write",
92
+ {"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
93
+ tmp_path,
94
+ )
95
+ assert _decision(row_write) is None
96
+
97
+ claude_md.write_text(INVENTORY_WITH_GAMMA, encoding="utf-8")
98
+ retry_write = _run_hook(
99
+ INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
100
+ )
101
+ assert _decision(retry_write) is None
102
+
103
+
104
+ def test_row_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
105
+ package_directory = _package_directory(tmp_path)
106
+ new_file = package_directory / "gamma.py"
107
+ claude_md = package_directory / "CLAUDE.md"
108
+
109
+ first_row = _run_hook(
110
+ ORPHAN_HOOK,
111
+ "Write",
112
+ {"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
113
+ tmp_path,
114
+ )
115
+ assert _decision(first_row) == "deny"
116
+ assert "gamma.py" in _reason(first_row)
117
+ assert "recorded" in _reason(first_row)
118
+
119
+ file_write = _run_hook(
120
+ INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
121
+ )
122
+ assert _decision(file_write) is None
123
+
124
+ new_file.write_text(MODULE_BODY, encoding="utf-8")
125
+ retry_row = _run_hook(
126
+ ORPHAN_HOOK,
127
+ "Write",
128
+ {"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
129
+ tmp_path,
130
+ )
131
+ assert _decision(retry_row) is None
132
+
133
+
134
+ def test_new_file_with_no_row_attempt_stays_blocked(tmp_path: Path) -> None:
135
+ package_directory = _package_directory(tmp_path)
136
+ new_file = package_directory / "delta.py"
137
+ file_write = _run_hook(
138
+ INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
139
+ )
140
+ assert _decision(file_write) == "deny"
141
+ assert "delta.py" in _reason(file_write)
142
+
143
+
144
+ def test_row_naming_uncreated_file_stays_blocked(tmp_path: Path) -> None:
145
+ package_directory = _package_directory(tmp_path)
146
+ claude_md = package_directory / "CLAUDE.md"
147
+ row_write = _run_hook(
148
+ ORPHAN_HOOK,
149
+ "Write",
150
+ {"file_path": str(claude_md), "content": INVENTORY_WITH_EPSILON},
151
+ tmp_path,
152
+ )
153
+ assert _decision(row_write) == "deny"
154
+ assert "epsilon.py" in _reason(row_write)
@@ -0,0 +1,168 @@
1
+ """Behavior tests for the Write/Edit payload PII evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ _HOOK_DIR = Path(__file__).parent
13
+ _HOOKS_DIR = _HOOK_DIR.parent
14
+
15
+ try:
16
+ from pii_payload_scan import evaluate_post_body_texts, evaluate_write_edit_payload
17
+ except ImportError:
18
+ for each_bootstrap_directory in (str(_HOOK_DIR), str(_HOOKS_DIR)):
19
+ if each_bootstrap_directory not in sys.path:
20
+ sys.path.insert(0, each_bootstrap_directory)
21
+ from pii_payload_scan import evaluate_post_body_texts, evaluate_write_edit_payload
22
+
23
+ _ALLOW_SLUG = "AllowOwner/allow-repo"
24
+ _OTHER_SLUG = "OtherOwner/other-repo"
25
+
26
+
27
+ def test_post_body_with_pii_is_blocked_by_default() -> None:
28
+ deny_reason = evaluate_post_body_texts([_assembled_fixture_email()])
29
+ assert deny_reason is not None
30
+ assert "post body" in deny_reason
31
+
32
+
33
+ def test_post_body_allowlisted_values_plumb_through_to_the_scan() -> None:
34
+ allowed_value = _assembled_fixture_email()
35
+ deny_reason = evaluate_post_body_texts(
36
+ [allowed_value], all_allowlisted_values=frozenset({allowed_value})
37
+ )
38
+ assert deny_reason is None
39
+
40
+
41
+ def test_post_body_unlisted_value_still_blocked_beside_allowlist() -> None:
42
+ deny_reason = evaluate_post_body_texts(
43
+ [_assembled_unlisted_email()],
44
+ all_allowlisted_values=frozenset({_assembled_fixture_email()}),
45
+ )
46
+ assert deny_reason is not None
47
+ assert "post body" in deny_reason
48
+
49
+
50
+ def _assembled_fixture_email() -> str:
51
+ return "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
52
+
53
+
54
+ def _assembled_unlisted_email() -> str:
55
+ return "other.person" + "@" + "different-co" + ".example" + ".io"
56
+
57
+
58
+ def _write_allowlist_identity(identity_path: Path, slug: str, value: str) -> None:
59
+ identity_path.write_text(
60
+ json.dumps({"pii_allowlisted_values": {slug: [value]}}),
61
+ encoding="utf-8",
62
+ )
63
+
64
+
65
+ def _init_repo_with_github_origin(repository_root: Path, origin_slug: str) -> None:
66
+ repository_root.mkdir(parents=True, exist_ok=True)
67
+ subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
68
+ origin_url = "https://github.com/" + origin_slug + ".git"
69
+ subprocess.run(
70
+ ["git", "remote", "add", "origin", origin_url],
71
+ cwd=repository_root,
72
+ check=True,
73
+ )
74
+
75
+
76
+ def _write_deny_reason(target_path: Path, value: str) -> str | None:
77
+ content = "owner email " + value + "\n"
78
+ return evaluate_write_edit_payload(
79
+ "Write", {"file_path": str(target_path), "content": content}
80
+ )
81
+
82
+
83
+ def test_write_payload_with_a_fixture_email_is_flagged(tmp_path: Path) -> None:
84
+ deny_reason = _write_deny_reason(tmp_path / "notes.md", _assembled_fixture_email())
85
+ assert deny_reason is not None
86
+ assert "email" in deny_reason
87
+
88
+
89
+ def test_allowlisted_value_under_the_allowlisted_repo_is_allowed(
90
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
91
+ ) -> None:
92
+ allowed_value = _assembled_fixture_email()
93
+ identity_path = tmp_path / "local-identity.json"
94
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
95
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
96
+ repository_root = tmp_path / "repo"
97
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
98
+ assert _write_deny_reason(repository_root / "notes.md", allowed_value) is None
99
+
100
+
101
+ def test_allowlisted_value_in_a_different_repo_is_blocked(
102
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
103
+ ) -> None:
104
+ allowed_value = _assembled_fixture_email()
105
+ identity_path = tmp_path / "local-identity.json"
106
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
107
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
108
+ repository_root = tmp_path / "repo"
109
+ _init_repo_with_github_origin(repository_root, _OTHER_SLUG)
110
+ deny_reason = _write_deny_reason(repository_root / "notes.md", allowed_value)
111
+ assert deny_reason is not None
112
+ assert "email" in deny_reason
113
+
114
+
115
+ def test_unlisted_value_in_the_allowlisted_repo_is_blocked(
116
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
117
+ ) -> None:
118
+ identity_path = tmp_path / "local-identity.json"
119
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
120
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
121
+ repository_root = tmp_path / "repo"
122
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
123
+ deny_reason = _write_deny_reason(
124
+ repository_root / "notes.md", _assembled_unlisted_email()
125
+ )
126
+ assert deny_reason is not None
127
+ assert "email" in deny_reason
128
+
129
+
130
+ def test_clean_write_payload_skips_repository_resolution(
131
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
132
+ ) -> None:
133
+ identity_path = tmp_path / "local-identity.json"
134
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
135
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
136
+ repository_root = tmp_path / "repo"
137
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
138
+
139
+ def _fail_if_called(_working_directory: str | None) -> None:
140
+ raise AssertionError(
141
+ "resolve_repository_root should not run for a payload with no PII"
142
+ )
143
+
144
+ monkeypatch.setattr(
145
+ sys.modules["pii_payload_scan"], "resolve_repository_root", _fail_if_called
146
+ )
147
+ deny_reason = evaluate_write_edit_payload(
148
+ "Write",
149
+ {
150
+ "file_path": str(repository_root / "notes.md"),
151
+ "content": "nothing sensitive here\n",
152
+ },
153
+ )
154
+ assert deny_reason is None
155
+
156
+
157
+ def test_allowlisted_value_under_missing_nested_parent_is_allowed(
158
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
159
+ ) -> None:
160
+ allowed_value = _assembled_fixture_email()
161
+ identity_path = tmp_path / "local-identity.json"
162
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
163
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
164
+ repository_root = tmp_path / "repo"
165
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
166
+ nested_target = repository_root / "new_dir" / "deeper" / "notes.md"
167
+ assert not nested_target.parent.exists()
168
+ assert _write_deny_reason(nested_target, allowed_value) is None
@@ -0,0 +1,184 @@
1
+ """Tests for the per-project domain-vocabulary allowlist in plain_language_blocker."""
2
+
3
+ import importlib.util
4
+ import json
5
+ from pathlib import Path
6
+ from types import ModuleType
7
+
8
+ BLOCKER_PATH = Path(__file__).parent / "plain_language_blocker.py"
9
+ ALLOWLIST_RELATIVE_PATH = Path(".claude") / "plain-language-allow.json"
10
+ ALWAYS_HEAVY_WORD = "utilize"
11
+
12
+
13
+ def _load_blocker() -> ModuleType:
14
+ module_spec = importlib.util.spec_from_file_location(
15
+ "plain_language_blocker_allowlist_under_test", BLOCKER_PATH
16
+ )
17
+ assert module_spec is not None and module_spec.loader is not None
18
+ loaded_module = importlib.util.module_from_spec(module_spec)
19
+ module_spec.loader.exec_module(loaded_module)
20
+ return loaded_module
21
+
22
+
23
+ _BLOCKER = _load_blocker()
24
+
25
+
26
+ def _init_repo(root: Path) -> Path:
27
+ (root / ".git").mkdir(parents=True, exist_ok=True)
28
+ return root
29
+
30
+
31
+ def _write_allowlist(project_root: Path, all_words: list[str]) -> None:
32
+ allowlist_path = project_root / ALLOWLIST_RELATIVE_PATH
33
+ allowlist_path.parent.mkdir(parents=True, exist_ok=True)
34
+ allowlist_path.write_text(json.dumps(all_words), encoding="utf-8")
35
+
36
+
37
+ def _write_raw_allowlist(project_root: Path, raw_text: str) -> None:
38
+ allowlist_path = project_root / ALLOWLIST_RELATIVE_PATH
39
+ allowlist_path.parent.mkdir(parents=True, exist_ok=True)
40
+ allowlist_path.write_text(raw_text, encoding="utf-8")
41
+
42
+
43
+ def _markdown_write_payload(project_root: Path, prose: str) -> dict[str, object]:
44
+ document_path = project_root / "docs" / "notes.md"
45
+ return {
46
+ "tool_name": "Write",
47
+ "cwd": str(project_root),
48
+ "tool_input": {"file_path": str(document_path), "content": prose},
49
+ }
50
+
51
+
52
+ def _ask_user_question_payload(project_root: Path, prose: str) -> dict[str, object]:
53
+ return {
54
+ "tool_name": "AskUserQuestion",
55
+ "cwd": str(project_root),
56
+ "tool_input": {"questions": [{"question": prose, "options": []}]},
57
+ }
58
+
59
+
60
+ def test_allowlisted_word_passes_in_markdown_write(tmp_path: Path) -> None:
61
+ prose = "Please submit the release notes."
62
+ project_without_allowlist = _init_repo(tmp_path / "control")
63
+ project_with_allowlist = _init_repo(tmp_path / "domain")
64
+ _write_allowlist(project_with_allowlist, ["submit"])
65
+
66
+ control_deny_reason = _BLOCKER.evaluate(
67
+ _markdown_write_payload(project_without_allowlist, prose)
68
+ )
69
+ allowlisted_deny_reason = _BLOCKER.evaluate(
70
+ _markdown_write_payload(project_with_allowlist, prose)
71
+ )
72
+
73
+ assert control_deny_reason is not None and "submit" in control_deny_reason
74
+ assert allowlisted_deny_reason is None
75
+
76
+
77
+ def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
78
+ prose = "Which theme should we identify first?"
79
+ project_without_allowlist = _init_repo(tmp_path / "control")
80
+ project_with_allowlist = _init_repo(tmp_path / "domain")
81
+ _write_allowlist(project_with_allowlist, ["identify"])
82
+
83
+ control_deny_reason = _BLOCKER.evaluate(
84
+ _ask_user_question_payload(project_without_allowlist, prose)
85
+ )
86
+ allowlisted_deny_reason = _BLOCKER.evaluate(
87
+ _ask_user_question_payload(project_with_allowlist, prose)
88
+ )
89
+
90
+ assert control_deny_reason is not None and "identify" in control_deny_reason
91
+ assert allowlisted_deny_reason is None
92
+
93
+
94
+ def test_non_allowlisted_heavy_word_still_blocked(tmp_path: Path) -> None:
95
+ _init_repo(tmp_path)
96
+ _write_allowlist(tmp_path, ["submit"])
97
+
98
+ deny_reason = _BLOCKER.evaluate(
99
+ _markdown_write_payload(tmp_path, f"Please {ALWAYS_HEAVY_WORD} the cache now.")
100
+ )
101
+
102
+ assert deny_reason is not None and ALWAYS_HEAVY_WORD in deny_reason
103
+
104
+
105
+ def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
106
+ prose = "Please SUBMIT the release notes."
107
+ project_without_allowlist = _init_repo(tmp_path / "control")
108
+ project_with_allowlist = _init_repo(tmp_path / "domain")
109
+ _write_allowlist(project_with_allowlist, ["Submit"])
110
+
111
+ control_deny_reason = _BLOCKER.evaluate(
112
+ _markdown_write_payload(project_without_allowlist, prose)
113
+ )
114
+ allowlisted_deny_reason = _BLOCKER.evaluate(
115
+ _markdown_write_payload(project_with_allowlist, prose)
116
+ )
117
+
118
+ assert control_deny_reason is not None and "submit" in control_deny_reason
119
+ assert allowlisted_deny_reason is None
120
+
121
+
122
+ def test_malformed_allowlist_json_is_ignored(tmp_path: Path) -> None:
123
+ _init_repo(tmp_path)
124
+ _write_raw_allowlist(tmp_path, "{ this is not valid json ")
125
+
126
+ deny_reason = _BLOCKER.evaluate(
127
+ _markdown_write_payload(tmp_path, "Please submit the release notes.")
128
+ )
129
+
130
+ assert deny_reason is not None and "submit" in deny_reason
131
+
132
+
133
+ def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) -> None:
134
+ project_with_allowlist = _init_repo(tmp_path / "project_a")
135
+ project_without_allowlist = _init_repo(tmp_path / "project_b")
136
+ _write_allowlist(project_with_allowlist, ["submit"])
137
+
138
+ deny_reason = _BLOCKER.evaluate(
139
+ _markdown_write_payload(project_without_allowlist, "Please submit the notes.")
140
+ )
141
+
142
+ assert deny_reason is not None and "submit" in deny_reason
143
+
144
+
145
+ def test_allowlist_above_the_repo_root_is_not_applied(tmp_path: Path) -> None:
146
+ _write_allowlist(tmp_path, ["submit"])
147
+ repository_root = _init_repo(tmp_path / "repo")
148
+
149
+ deny_reason = _BLOCKER.evaluate(
150
+ _markdown_write_payload(repository_root, "Please submit the notes.")
151
+ )
152
+
153
+ assert deny_reason is not None and "submit" in deny_reason
154
+
155
+
156
+ def test_allowlist_at_the_repo_root_is_applied(tmp_path: Path) -> None:
157
+ repository_root = _init_repo(tmp_path / "repo")
158
+ _write_allowlist(repository_root, ["submit"])
159
+
160
+ deny_reason = _BLOCKER.evaluate(
161
+ _markdown_write_payload(repository_root, "Please submit the notes.")
162
+ )
163
+
164
+ assert deny_reason is None
165
+
166
+
167
+ def test_allowlist_without_a_repo_root_is_not_applied(tmp_path: Path) -> None:
168
+ _write_allowlist(tmp_path, ["submit"])
169
+
170
+ deny_reason = _BLOCKER.evaluate(
171
+ _markdown_write_payload(tmp_path, "Please submit the notes.")
172
+ )
173
+
174
+ assert deny_reason is not None and "submit" in deny_reason
175
+
176
+
177
+ def test_find_banned_terms_skips_allowlisted_terms() -> None:
178
+ matched_without_allowlist = _BLOCKER.find_banned_terms("Please submit the notes.")
179
+ matched_with_allowlist = _BLOCKER.find_banned_terms(
180
+ "Please submit the notes.", frozenset({"submit"})
181
+ )
182
+
183
+ assert any(each_term == "submit" for each_term, _ in matched_without_allowlist)
184
+ assert all(each_term != "submit" for each_term, _ in matched_with_allowlist)