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,185 @@
1
+ """Decision tests for the sensitive-file filename guard.
2
+
3
+ Each case drives the real hook script through its production ``__main__``
4
+ stdin path, feeding it the PreToolUse JSON payload Claude Code sends and
5
+ reading the permission decision back off stdout.
6
+
7
+ The guard denies a filename that names a live secret or a lock file, and
8
+ steps aside for a placeholders-only committed template (``.env.example``
9
+ and its ``.sample`` / ``.template`` spellings). The template exemption is
10
+ narrow: ``.env.local`` is a ``.env.*`` filename that is not a template, and
11
+ it stays denied.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ BLOCKING_DIRECTORY = Path(__file__).resolve().parent
24
+ HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
25
+
26
+ WRITE_TOOL_NAME = "Write"
27
+ EDIT_TOOL_NAME = "Edit"
28
+ READ_TOOL_NAME = "Read"
29
+
30
+ DENY_DECISION = "deny"
31
+
32
+ PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
33
+ ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
34
+
35
+ ALL_TEMPLATE_FILENAMES = (
36
+ ".env.example",
37
+ ".env.sample",
38
+ ".env.template",
39
+ ".ENV.Example",
40
+ "settings.json.template",
41
+ )
42
+
43
+ ALL_DENIED_FILENAMES = (
44
+ ".env",
45
+ ".env.local",
46
+ ".env.production",
47
+ "credentials.json",
48
+ "secrets.json",
49
+ "id_rsa",
50
+ "id_ed25519",
51
+ "server.pem",
52
+ "signing.key",
53
+ "bundle.p12",
54
+ "bundle.pfx",
55
+ "package-lock.json",
56
+ "yarn.lock",
57
+ "Pipfile.lock",
58
+ "poetry.lock",
59
+ "pnpm-lock.yaml",
60
+ "composer.lock",
61
+ )
62
+
63
+ ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.PEM")
64
+
65
+ ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
66
+
67
+
68
+ def _run_hook(tool_name: str, file_path: Path, content: str) -> subprocess.CompletedProcess:
69
+ payload = json.dumps(
70
+ {
71
+ "tool_name": tool_name,
72
+ "tool_input": {"file_path": str(file_path), "content": content},
73
+ }
74
+ )
75
+ return _run_hook_with_stdin(payload)
76
+
77
+
78
+ def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
79
+ return subprocess.run(
80
+ [sys.executable, str(HOOK_SCRIPT_PATH)],
81
+ input=stdin_text,
82
+ capture_output=True,
83
+ text=True,
84
+ check=False,
85
+ )
86
+
87
+
88
+ def _permission_decision(completed: subprocess.CompletedProcess) -> str | None:
89
+ if not completed.stdout.strip():
90
+ return None
91
+ parsed_decision = json.loads(completed.stdout)
92
+ return parsed_decision["hookSpecificOutput"]["permissionDecision"]
93
+
94
+
95
+ def _deny_reason(completed: subprocess.CompletedProcess) -> str:
96
+ parsed_decision = json.loads(completed.stdout)
97
+ return parsed_decision["hookSpecificOutput"]["permissionDecisionReason"]
98
+
99
+
100
+ @pytest.mark.parametrize("template_filename", ALL_TEMPLATE_FILENAMES)
101
+ def test_template_filename_is_allowed(tmp_path: Path, template_filename: str) -> None:
102
+ completed = _run_hook(WRITE_TOOL_NAME, tmp_path / template_filename, PLACEHOLDER_TEMPLATE_BODY)
103
+ assert completed.returncode == 0, completed.stderr
104
+ assert _permission_decision(completed) is None, (
105
+ f"{template_filename} is a committed template and must not be denied by filename; "
106
+ f"got stdout {completed.stdout!r}"
107
+ )
108
+
109
+
110
+ @pytest.mark.parametrize("sensitive_filename", ALL_DENIED_FILENAMES)
111
+ def test_sensitive_filename_is_denied(tmp_path: Path, sensitive_filename: str) -> None:
112
+ completed = _run_hook(WRITE_TOOL_NAME, tmp_path / sensitive_filename, PLACEHOLDER_TEMPLATE_BODY)
113
+ assert completed.returncode == 0, completed.stderr
114
+ assert _permission_decision(completed) == DENY_DECISION, (
115
+ f"{sensitive_filename} must stay denied; got stdout {completed.stdout!r}"
116
+ )
117
+
118
+
119
+ @pytest.mark.parametrize("uppercase_filename", ALL_UPPERCASE_DENIED_FILENAMES)
120
+ def test_uppercase_sensitive_filename_is_denied(tmp_path: Path, uppercase_filename: str) -> None:
121
+ completed = _run_hook(WRITE_TOOL_NAME, tmp_path / uppercase_filename, PLACEHOLDER_TEMPLATE_BODY)
122
+ assert completed.returncode == 0, completed.stderr
123
+ assert _permission_decision(completed) == DENY_DECISION, (
124
+ f"{uppercase_filename} names a secret in an uppercase spelling and must stay denied like "
125
+ f"its lowercase form; got stdout {completed.stdout!r}"
126
+ )
127
+
128
+
129
+ @pytest.mark.parametrize("ordinary_filename", ALL_ORDINARY_FILENAMES)
130
+ def test_ordinary_source_file_is_allowed(tmp_path: Path, ordinary_filename: str) -> None:
131
+ completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ordinary_filename, ORDINARY_SOURCE_BODY)
132
+ assert completed.returncode == 0, completed.stderr
133
+ assert _permission_decision(completed) is None
134
+
135
+
136
+ def test_dot_env_local_deny_reason_names_the_file_and_the_pattern(tmp_path: Path) -> None:
137
+ completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ".env.local", PLACEHOLDER_TEMPLATE_BODY)
138
+ assert _permission_decision(completed) == DENY_DECISION
139
+ deny_reason = _deny_reason(completed)
140
+ assert ".env.local" in deny_reason
141
+ assert ".env.*" in deny_reason
142
+
143
+
144
+ def test_edit_to_a_template_is_allowed(tmp_path: Path) -> None:
145
+ completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env.example", PLACEHOLDER_TEMPLATE_BODY)
146
+ assert completed.returncode == 0, completed.stderr
147
+ assert _permission_decision(completed) is None
148
+
149
+
150
+ def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
151
+ completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
152
+ assert completed.returncode == 0, completed.stderr
153
+ assert _permission_decision(completed) == DENY_DECISION
154
+
155
+
156
+ def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
157
+ completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
158
+ assert completed.returncode == 0, completed.stderr
159
+ assert _permission_decision(completed) is None
160
+
161
+
162
+ def test_payload_without_a_file_path_is_allowed() -> None:
163
+ completed = _run_hook_with_stdin(json.dumps({"tool_name": WRITE_TOOL_NAME, "tool_input": {}}))
164
+ assert completed.returncode == 0, completed.stderr
165
+ assert _permission_decision(completed) is None
166
+
167
+
168
+ def test_malformed_payload_fails_open() -> None:
169
+ completed = _run_hook_with_stdin("{not valid json")
170
+ assert completed.returncode == 0, completed.stderr
171
+ assert completed.stdout.strip() == ""
172
+
173
+
174
+ @pytest.mark.parametrize("trailing_suffix_filename", (".env.example.bak", ".env.template.old", ".env.sample.orig"))
175
+ def test_template_suffix_must_be_last_to_earn_the_exemption(
176
+ tmp_path: Path, trailing_suffix_filename: str
177
+ ) -> None:
178
+ completed = _run_hook(
179
+ WRITE_TOOL_NAME, tmp_path / trailing_suffix_filename, PLACEHOLDER_TEMPLATE_BODY
180
+ )
181
+ assert completed.returncode == 0, completed.stderr
182
+ assert _permission_decision(completed) == DENY_DECISION, (
183
+ f"{trailing_suffix_filename} is a copy of a live secrets file, not a template; "
184
+ f"only a trailing template suffix earns the exemption, got {completed.stdout!r}"
185
+ )
@@ -0,0 +1,108 @@
1
+ """End-to-end tests for the absent-but-tracked restore exemption.
2
+
3
+ Drives the real ``tdd_enforcer.py`` entry hook over a Write payload in a temp
4
+ git repository, proving a remove-then-Write rewrite of committed code is allowed
5
+ even when the paired test's freshness window has lapsed, while new untracked
6
+ code and real edits to a present file stay gated.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ SCRIPT_PATH = Path(__file__).parent / "tdd_enforcer.py"
16
+ STALE_MTIME_OFFSET_SECONDS = 700
17
+ DISABLE_EPHEMERAL_ENVIRONMENT = {"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"}
18
+
19
+
20
+ def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
21
+ return subprocess.run(
22
+ [sys.executable, str(SCRIPT_PATH)],
23
+ input=json.dumps(payload),
24
+ text=True,
25
+ capture_output=True,
26
+ check=False,
27
+ env={**os.environ, **DISABLE_EPHEMERAL_ENVIRONMENT},
28
+ )
29
+
30
+
31
+ def _write_payload(file_path: Path, content: str) -> dict:
32
+ return {"tool_name": "Write", "tool_input": {"file_path": str(file_path), "content": content}}
33
+
34
+
35
+ def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
36
+ if not completed.stdout:
37
+ return None
38
+ hook_output = json.loads(completed.stdout).get("hookSpecificOutput", {})
39
+ return hook_output.get("permissionDecision")
40
+
41
+
42
+ def _run_git(repository_root: Path, *arguments: str) -> None:
43
+ subprocess.run(
44
+ ["git", *arguments],
45
+ cwd=str(repository_root),
46
+ check=True,
47
+ capture_output=True,
48
+ env={**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull},
49
+ )
50
+
51
+
52
+ def _init_git_repository(repository_root: Path) -> None:
53
+ _run_git(repository_root, "init", "-q")
54
+ _run_git(repository_root, "config", "user.email", "test@example.com")
55
+ _run_git(repository_root, "config", "user.name", "Test")
56
+ _run_git(repository_root, "config", "commit.gpgsign", "false")
57
+
58
+
59
+ def _commit_tracked_service(repository_root: Path) -> tuple[Path, Path]:
60
+ production_file = repository_root / "service.py"
61
+ production_file.write_text("def serve(): return 1\n")
62
+ sibling_test = repository_root / "test_service.py"
63
+ sibling_test.write_text("def test_serve(): pass\n")
64
+ _run_git(repository_root, "add", "-A")
65
+ _run_git(repository_root, "commit", "-q", "-m", "init")
66
+ return production_file, sibling_test
67
+
68
+
69
+ def test_should_allow_restore_write_of_absent_but_tracked_file_despite_stale_window(
70
+ tmp_path: Path,
71
+ ) -> None:
72
+ repository_root = tmp_path / "repo"
73
+ repository_root.mkdir()
74
+ _init_git_repository(repository_root)
75
+ production_file, sibling_test = _commit_tracked_service(repository_root)
76
+ stale_timestamp = os.stat(sibling_test).st_mtime - STALE_MTIME_OFFSET_SECONDS
77
+ os.utime(sibling_test, (stale_timestamp, stale_timestamp))
78
+ production_file.unlink()
79
+
80
+ completed = _run_hook(_write_payload(production_file, "def serve(): return 2\n"))
81
+
82
+ assert _decision_from(completed) == "allow"
83
+
84
+
85
+ def test_should_still_deny_write_of_absent_untracked_file_without_fresh_test(
86
+ tmp_path: Path,
87
+ ) -> None:
88
+ repository_root = tmp_path / "repo"
89
+ repository_root.mkdir()
90
+ _init_git_repository(repository_root)
91
+ untracked_file = repository_root / "brand_new.py"
92
+
93
+ completed = _run_hook(_write_payload(untracked_file, "def fresh(): return 1\n"))
94
+
95
+ assert _decision_from(completed) == "deny"
96
+
97
+
98
+ def test_should_still_deny_write_of_present_file_with_stale_test(tmp_path: Path) -> None:
99
+ repository_root = tmp_path / "repo"
100
+ repository_root.mkdir()
101
+ _init_git_repository(repository_root)
102
+ production_file, sibling_test = _commit_tracked_service(repository_root)
103
+ stale_timestamp = os.stat(sibling_test).st_mtime - STALE_MTIME_OFFSET_SECONDS
104
+ os.utime(sibling_test, (stale_timestamp, stale_timestamp))
105
+
106
+ completed = _run_hook(_write_payload(production_file, "def serve(): return 2\n"))
107
+
108
+ assert _decision_from(completed) == "deny"
@@ -0,0 +1,105 @@
1
+ """Tests for the session-scratchpad exemption wired into tdd_enforcer.main."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+ from types import ModuleType
11
+
12
+ import pytest
13
+
14
+ TDD_ENFORCER_PATH = Path(__file__).parent / "tdd_enforcer.py"
15
+ FIXED_USER_ID = 5150
16
+ WORKING_DIRECTORY = "/home/user/project"
17
+ SESSION_ID = "tdd-session-987"
18
+ PRODUCTION_CONTENT = "def fulfill_order():\n return True\n"
19
+
20
+
21
+ def _load_tdd_enforcer() -> ModuleType:
22
+ module_spec = importlib.util.spec_from_file_location(
23
+ "tdd_enforcer_scratchpad_under_test", TDD_ENFORCER_PATH
24
+ )
25
+ assert module_spec is not None and module_spec.loader is not None
26
+ loaded_module = importlib.util.module_from_spec(module_spec)
27
+ module_spec.loader.exec_module(loaded_module)
28
+ return loaded_module
29
+
30
+
31
+ _TDD_ENFORCER = _load_tdd_enforcer()
32
+
33
+
34
+ def _install_scratchpad_signals(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> Path:
35
+ monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
36
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
37
+ mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
38
+ scratchpad_directory = (
39
+ temporary_root
40
+ / f"claude-{FIXED_USER_ID}"
41
+ / mangled_working_directory
42
+ / SESSION_ID
43
+ / "scratchpad"
44
+ )
45
+ scratchpad_directory.mkdir(parents=True)
46
+ return scratchpad_directory
47
+
48
+
49
+ def _write_payload(target: Path) -> dict[str, object]:
50
+ return {
51
+ "tool_name": "Write",
52
+ "cwd": WORKING_DIRECTORY,
53
+ "session_id": SESSION_ID,
54
+ "tool_input": {"file_path": str(target), "content": PRODUCTION_CONTENT},
55
+ }
56
+
57
+
58
+ def _run_main(
59
+ monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]
60
+ ) -> tuple[int | None, str]:
61
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
62
+ captured_stdout = io.StringIO()
63
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
64
+ exit_code: int | None = None
65
+ try:
66
+ _TDD_ENFORCER.main()
67
+ except SystemExit as raised_exit:
68
+ raw_code = raised_exit.code
69
+ exit_code = raw_code if isinstance(raw_code, int) else None
70
+ return exit_code, captured_stdout.getvalue()
71
+
72
+
73
+ def _decision_from(stdout_text: str) -> str | None:
74
+ if not stdout_text.strip():
75
+ return None
76
+ parsed = json.loads(stdout_text)
77
+ return parsed.get("hookSpecificOutput", {}).get("permissionDecision")
78
+
79
+
80
+ def test_scratchpad_production_write_is_exempt_from_tdd_gate(
81
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
82
+ ) -> None:
83
+ scratchpad_directory = _install_scratchpad_signals(monkeypatch, tmp_path)
84
+ monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
85
+ throwaway_script = scratchpad_directory / "one_off_probe.py"
86
+
87
+ exit_code, stdout_text = _run_main(monkeypatch, _write_payload(throwaway_script))
88
+
89
+ assert exit_code == 0
90
+ assert _decision_from(stdout_text) != "deny"
91
+
92
+
93
+ def test_identical_non_scratchpad_write_is_still_blocked(
94
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
95
+ ) -> None:
96
+ _install_scratchpad_signals(monkeypatch, tmp_path)
97
+ monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
98
+ outside_directory = tmp_path / "project" / "orders"
99
+ outside_directory.mkdir(parents=True)
100
+ production_module = outside_directory / "one_off_probe.py"
101
+
102
+ exit_code, stdout_text = _run_main(monkeypatch, _write_payload(production_module))
103
+
104
+ assert exit_code == 0
105
+ assert _decision_from(stdout_text) == "deny"
@@ -0,0 +1,10 @@
1
+ """Put the blocking and hooks directories on sys.path for the blocking tests."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _blocking_directory = str(Path(__file__).resolve().parents[1])
7
+ _hooks_directory = str(Path(__file__).resolve().parents[2])
8
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
9
+ if each_bootstrap_directory not in sys.path:
10
+ sys.path.insert(0, each_bootstrap_directory)
@@ -0,0 +1,260 @@
1
+ """Repository-resolution behavior of the PII commit gate (issue #65).
2
+
3
+ The gate scans the repository the command names, not the session working
4
+ directory, so::
5
+
6
+ git -C <healthy repo> commit -> scans <healthy repo> from any cwd
7
+ cd <repo> && git commit -> scans <repo>
8
+ git -C <repo> commit (cwd gone) -> still scans <repo>, never refuses for cwd
9
+ git -C <not-a-repo> commit -> refusal names <not-a-repo>
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+ from pii_prevention_blocker import evaluate, evaluate_bash_command
21
+
22
+ _HOOK_PATH = Path(__file__).resolve().parents[1] / "pii_prevention_blocker.py"
23
+ _ALLOW_SLUG = "AllowOwner/allow-repo"
24
+ _OTHER_SLUG = "OtherOwner/other-repo"
25
+
26
+
27
+ def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
28
+ completed_process = subprocess.run(
29
+ [sys.executable, str(_HOOK_PATH)],
30
+ input=json.dumps(payload),
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=30,
34
+ check=False,
35
+ )
36
+ return completed_process.returncode, completed_process.stdout
37
+
38
+
39
+ def _assembled_fixture_email() -> str:
40
+ return "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
41
+
42
+
43
+ def _assembled_unlisted_email() -> str:
44
+ return "other.person" + "@" + "different-co" + ".example" + ".io"
45
+
46
+
47
+ def _write_allowlist_identity(identity_path: Path, slug: str, value: str) -> None:
48
+ identity_path.write_text(
49
+ json.dumps({"pii_allowlisted_values": {slug: [value]}}),
50
+ encoding="utf-8",
51
+ )
52
+
53
+
54
+ def _init_repo_with_origin_and_staged_value(
55
+ repository_root: Path, origin_slug: str, staged_value: str
56
+ ) -> None:
57
+ repository_root.mkdir(parents=True, exist_ok=True)
58
+ subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
59
+ subprocess.run(
60
+ ["git", "config", "user.email", "dev@example.com"],
61
+ cwd=repository_root,
62
+ check=True,
63
+ )
64
+ subprocess.run(
65
+ ["git", "config", "user.name", "Fixture Dev"], cwd=repository_root, check=True
66
+ )
67
+ subprocess.run(
68
+ ["git", "remote", "add", "origin", "https://github.com/" + origin_slug + ".git"],
69
+ cwd=repository_root,
70
+ check=True,
71
+ )
72
+ (repository_root / "notes.md").write_text(
73
+ "owner email " + staged_value + "\n", encoding="utf-8"
74
+ )
75
+ subprocess.run(["git", "add", "notes.md"], cwd=repository_root, check=True)
76
+
77
+
78
+ def _commit_payload(repository_root: Path) -> dict[str, object]:
79
+ return {
80
+ "tool_name": "Bash",
81
+ "tool_input": {"command": f"git -C {repository_root} commit -m x"},
82
+ }
83
+
84
+
85
+ def test_commit_subprocess_allows_allowlisted_value_in_the_allowlisted_repo(
86
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
87
+ ) -> None:
88
+ allowed_value = _assembled_fixture_email()
89
+ identity_path = tmp_path / "local-identity.json"
90
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
91
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
92
+ repository_root = tmp_path / "repo"
93
+ _init_repo_with_origin_and_staged_value(repository_root, _ALLOW_SLUG, allowed_value)
94
+ return_code, hook_stdout = _run_hook(_commit_payload(repository_root))
95
+ assert return_code == 0
96
+ assert hook_stdout.strip() == ""
97
+
98
+
99
+ def test_commit_subprocess_blocks_allowlisted_value_in_a_different_repo(
100
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
101
+ ) -> None:
102
+ allowed_value = _assembled_fixture_email()
103
+ identity_path = tmp_path / "local-identity.json"
104
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
105
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
106
+ repository_root = tmp_path / "repo"
107
+ _init_repo_with_origin_and_staged_value(repository_root, _OTHER_SLUG, allowed_value)
108
+ _return_code, hook_stdout = _run_hook(_commit_payload(repository_root))
109
+ assert "email" in hook_stdout
110
+
111
+
112
+ def test_commit_blocks_an_unlisted_value_in_the_allowlisted_repo(
113
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
114
+ ) -> None:
115
+ identity_path = tmp_path / "local-identity.json"
116
+ _write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
117
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
118
+ repository_root = tmp_path / "repo"
119
+ _init_repo_with_origin_and_staged_value(
120
+ repository_root, _ALLOW_SLUG, _assembled_unlisted_email()
121
+ )
122
+ deny_reason = evaluate_bash_command(
123
+ f"git -C {repository_root} commit -m x",
124
+ working_directory=str(repository_root),
125
+ )
126
+ assert deny_reason is not None
127
+ assert "email" in deny_reason
128
+
129
+
130
+ def test_commit_with_missing_local_identity_still_blocks_the_value(
131
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
132
+ ) -> None:
133
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(tmp_path / "absent.json"))
134
+ repository_root = tmp_path / "repo"
135
+ _init_repo_with_origin_and_staged_value(
136
+ repository_root, _ALLOW_SLUG, _assembled_fixture_email()
137
+ )
138
+ deny_reason = evaluate_bash_command(
139
+ f"git -C {repository_root} commit -m x",
140
+ working_directory=str(repository_root),
141
+ )
142
+ assert deny_reason is not None
143
+ assert "email" in deny_reason
144
+
145
+
146
+ def _init_repo_with_staged_pii(repository_root: Path) -> None:
147
+ repository_root.mkdir(parents=True, exist_ok=True)
148
+ subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
149
+ subprocess.run(
150
+ ["git", "config", "user.email", "dev@example.com"],
151
+ cwd=repository_root,
152
+ check=True,
153
+ )
154
+ subprocess.run(
155
+ ["git", "config", "user.name", "Fixture Dev"],
156
+ cwd=repository_root,
157
+ check=True,
158
+ )
159
+ (repository_root / "notes.md").write_text(
160
+ "owner email " + _assembled_fixture_email() + "\n",
161
+ encoding="utf-8",
162
+ )
163
+ subprocess.run(["git", "add", "notes.md"], cwd=repository_root, check=True)
164
+
165
+
166
+ def test_dash_c_scans_named_repo_from_unrelated_cwd(tmp_path: Path) -> None:
167
+ repository_root = tmp_path / "healthy_repo"
168
+ _init_repo_with_staged_pii(repository_root)
169
+ deny_reason = evaluate_bash_command(
170
+ f"git -C {repository_root} commit -m x",
171
+ working_directory="/tmp",
172
+ )
173
+ assert deny_reason is not None
174
+ assert "email" in deny_reason
175
+
176
+
177
+ def test_cd_prefix_scans_named_repo_from_unrelated_cwd(tmp_path: Path) -> None:
178
+ repository_root = tmp_path / "healthy_repo"
179
+ _init_repo_with_staged_pii(repository_root)
180
+ deny_reason = evaluate_bash_command(
181
+ f"cd {repository_root} && git commit -m x",
182
+ working_directory="/tmp",
183
+ )
184
+ assert deny_reason is not None
185
+ assert "email" in deny_reason
186
+
187
+
188
+ def test_cd_prefix_with_git_exe_scans_named_repo_from_unrelated_cwd(
189
+ tmp_path: Path,
190
+ ) -> None:
191
+ repository_root = tmp_path / "healthy_repo"
192
+ _init_repo_with_staged_pii(repository_root)
193
+ deny_reason = evaluate_bash_command(
194
+ f"cd {repository_root} && git.exe commit -m x",
195
+ working_directory="/tmp",
196
+ )
197
+ assert deny_reason is not None
198
+ assert "email" in deny_reason
199
+
200
+
201
+ def test_removed_working_directory_does_not_block_named_repo(tmp_path: Path) -> None:
202
+ repository_root = tmp_path / "healthy_repo"
203
+ _init_repo_with_staged_pii(repository_root)
204
+ removed_directory = tmp_path / "gone"
205
+ removed_directory.mkdir()
206
+ removed_directory.rmdir()
207
+ deny_reason = evaluate_bash_command(
208
+ f"git -C {repository_root} commit -m x",
209
+ working_directory=str(removed_directory),
210
+ )
211
+ assert deny_reason is not None
212
+ assert "email" in deny_reason
213
+
214
+
215
+ def test_multiple_dash_c_compose_to_the_named_repo(tmp_path: Path) -> None:
216
+ repository_root = tmp_path / "parent" / "healthy_repo"
217
+ _init_repo_with_staged_pii(repository_root)
218
+ deny_reason = evaluate_bash_command(
219
+ f"git -C {tmp_path / 'parent'} -C healthy_repo commit -m x",
220
+ working_directory="/tmp",
221
+ )
222
+ assert deny_reason is not None
223
+ assert "email" in deny_reason
224
+
225
+
226
+ def test_full_dispatch_blocks_pii_commit_from_unrelated_cwd(tmp_path: Path) -> None:
227
+ repository_root = tmp_path / "healthy_repo"
228
+ _init_repo_with_staged_pii(repository_root)
229
+ deny_reason = evaluate(
230
+ {
231
+ "tool_name": "Bash",
232
+ "tool_input": {"command": f"git -C {repository_root} commit -m x"},
233
+ "cwd": "/tmp",
234
+ }
235
+ )
236
+ assert deny_reason is not None
237
+ assert "email" in deny_reason
238
+
239
+
240
+ def test_unresolvable_named_repo_refusal_names_the_path() -> None:
241
+ deny_reason = evaluate_bash_command(
242
+ "git -C /nonexistent/repo/xyz commit -m x",
243
+ working_directory="/tmp",
244
+ )
245
+ assert deny_reason is not None
246
+ assert "repository root" in deny_reason
247
+ assert "/nonexistent/repo/xyz" in deny_reason
248
+
249
+
250
+ def test_clean_named_repo_passes(tmp_path: Path) -> None:
251
+ repository_root = tmp_path / "clean_repo"
252
+ repository_root.mkdir()
253
+ subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
254
+ (repository_root / "readme.md").write_text("all clear\n", encoding="utf-8")
255
+ subprocess.run(["git", "add", "readme.md"], cwd=repository_root, check=True)
256
+ deny_reason = evaluate_bash_command(
257
+ f"git -C {repository_root} commit -m x",
258
+ working_directory="/tmp",
259
+ )
260
+ assert deny_reason is None