claude-dev-env 1.95.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 (240) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  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/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/tests/conftest.py +10 -0
  117. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  118. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  119. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  120. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  121. package/hooks/git-hooks/post_commit.py +3 -4
  122. package/hooks/hooks_constants/CLAUDE.md +2 -2
  123. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  124. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  125. package/hooks/hooks_constants/local_identity.py +59 -8
  126. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  127. package/hooks/hooks_constants/test_local_identity.py +105 -3
  128. package/hooks/pyproject.toml +13 -36
  129. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  130. package/hooks/validation/mypy_validator.py +2 -2
  131. package/hooks/validators/health_check.py +1 -0
  132. package/hooks/validators/mypy_integration.py +2 -0
  133. package/hooks/validators/ruff_integration.py +3 -0
  134. package/hooks/workflow/auto_formatter.py +5 -4
  135. package/package.json +1 -1
  136. package/scripts/CLAUDE.md +4 -0
  137. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  138. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  139. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  140. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  141. package/scripts/grok_headless_runner.py +294 -0
  142. package/scripts/grok_worker_preflight.py +410 -0
  143. package/scripts/invoke_code_review.py +463 -0
  144. package/scripts/resolve_worker_spawn.py +619 -0
  145. package/scripts/spawn_grok_batch.py +672 -0
  146. package/scripts/test_grok_headless_runner.py +626 -0
  147. package/scripts/test_grok_worker_preflight.py +1054 -0
  148. package/scripts/test_invoke_code_review.py +672 -0
  149. package/scripts/test_resolve_worker_spawn.py +1014 -0
  150. package/scripts/test_spawn_grok_batch.py +1017 -0
  151. package/skills/CLAUDE.md +5 -3
  152. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  153. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  154. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  155. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  157. package/skills/autoconverge/CLAUDE.md +3 -3
  158. package/skills/autoconverge/SKILL.md +9 -3
  159. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  160. package/skills/autoconverge/reference/convergence.md +33 -11
  161. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  162. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  163. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  164. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  165. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  166. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  167. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  168. package/skills/autoconverge/workflow/converge.mjs +200 -16
  169. package/skills/bugteam/CLAUDE.md +2 -2
  170. package/skills/bugteam/CONSTRAINTS.md +3 -2
  171. package/skills/bugteam/PROMPTS.md +7 -6
  172. package/skills/bugteam/SKILL.md +18 -13
  173. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  174. package/skills/bugteam/reference/design-rationale.md +1 -1
  175. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  176. package/skills/bugteam/reference/team-setup.md +8 -2
  177. package/skills/codex-review/CLAUDE.md +46 -0
  178. package/skills/codex-review/SKILL.md +181 -0
  179. package/skills/codex-review/reference/CLAUDE.md +15 -0
  180. package/skills/codex-review/reference/cli-contract.md +253 -0
  181. package/skills/codex-review/reference/loop-integration.md +118 -0
  182. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  183. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  189. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  190. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  191. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  193. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  194. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  195. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  196. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  197. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  199. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  200. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  201. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  202. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  203. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  204. package/skills/codex-review/test_skill_scaffold.py +192 -0
  205. package/skills/grok-spawn/CLAUDE.md +28 -0
  206. package/skills/grok-spawn/SKILL.md +226 -0
  207. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  208. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  209. package/skills/grokify/SKILL.md +9 -1
  210. package/skills/grokify/capability-claims.test.mjs +28 -0
  211. package/skills/grokify/evals/README.md +72 -0
  212. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  213. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  214. package/skills/orchestrator/SKILL.md +15 -11
  215. package/skills/orchestrator-refresh/SKILL.md +5 -5
  216. package/skills/pr-converge/SKILL.md +34 -13
  217. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  218. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  219. package/skills/pr-converge/reference/ground-rules.md +1 -1
  220. package/skills/pr-converge/reference/per-tick.md +130 -42
  221. package/skills/pr-converge/reference/state-schema.md +10 -0
  222. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  223. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  224. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  225. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  226. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  227. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  228. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  229. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  230. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  231. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  232. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  234. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  236. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  237. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  238. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  239. package/skills/reviewer-gates/SKILL.md +7 -5
  240. package/skills/team-advisor/SKILL.md +7 -7
@@ -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,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,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
@@ -0,0 +1,105 @@
1
+ """Behavioral tests for the moved repository-exemption helpers.
2
+
3
+ Covers the origin-URL slug parser and the per-repository exemption decision::
4
+
5
+ https://github.com/Owner/Repo.git -> owner/repo
6
+ https://evil.test/Owner/Repo.git -> None
7
+ ok: origin slug in CLAUDE_PII_EXEMPT_REPOS -> exempt (True)
8
+ flag: origin slug absent from the exempt set -> scanned (False)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+ from pii_prevention_blocker_parts.repository_exemption import (
19
+ _is_repository_exempt_from_pii_scan,
20
+ _owner_repo_slug_from_origin_url,
21
+ repository_allowlisted_values,
22
+ )
23
+
24
+ _ALLOWED_VALUE = "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
25
+
26
+
27
+ def _write_allowlist_identity(identity_path: Path, slug: str) -> None:
28
+ identity_path.write_text(
29
+ json.dumps({"pii_allowlisted_values": {slug: [_ALLOWED_VALUE]}}),
30
+ encoding="utf-8",
31
+ )
32
+
33
+
34
+ def test_repository_allowlisted_values_returns_the_mapped_values(
35
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
36
+ ) -> None:
37
+ identity_path = tmp_path / "local-identity.json"
38
+ _write_allowlist_identity(identity_path, "AllowOwner/allow-repo")
39
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
40
+ repository_root = tmp_path / "repo"
41
+ _init_repo_with_github_origin(repository_root, "AllowOwner/allow-repo")
42
+ assert repository_allowlisted_values(repository_root) == frozenset({_ALLOWED_VALUE})
43
+
44
+
45
+ def test_repository_allowlisted_values_empty_for_an_unmapped_repo(
46
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
47
+ ) -> None:
48
+ identity_path = tmp_path / "local-identity.json"
49
+ _write_allowlist_identity(identity_path, "AllowOwner/allow-repo")
50
+ monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
51
+ repository_root = tmp_path / "repo"
52
+ _init_repo_with_github_origin(repository_root, "OtherOwner/other-repo")
53
+ assert repository_allowlisted_values(repository_root) == frozenset()
54
+
55
+
56
+ def test_github_https_origin_yields_lowercased_owner_repo_slug() -> None:
57
+ assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo.git") == "owner/repo"
58
+
59
+
60
+ def test_github_ssh_origin_yields_lowercased_owner_repo_slug() -> None:
61
+ assert _owner_repo_slug_from_origin_url("git@github.com:Owner/Repo.git") == "owner/repo"
62
+
63
+
64
+ def test_spoofed_host_origin_yields_no_slug() -> None:
65
+ assert _owner_repo_slug_from_origin_url("https://evil.test/Owner/Repo.git") is None
66
+
67
+
68
+ def _init_repo_with_github_origin(repository_root: Path, origin_slug: str) -> None:
69
+ repository_root.mkdir(parents=True, exist_ok=True)
70
+ subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
71
+ origin_url = "https://github.com/" + origin_slug + ".git"
72
+ subprocess.run(
73
+ ["git", "remote", "add", "origin", origin_url],
74
+ cwd=repository_root,
75
+ check=True,
76
+ )
77
+
78
+
79
+ def test_repository_with_exempt_origin_is_exempt(
80
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
81
+ ) -> None:
82
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
83
+ repository_root = tmp_path / "repo"
84
+ _init_repo_with_github_origin(repository_root, "ExemptOwner/exempt-repo")
85
+ assert _is_repository_exempt_from_pii_scan(repository_root) is True
86
+
87
+
88
+ def test_repository_with_unlisted_origin_is_scanned(
89
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
90
+ ) -> None:
91
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
92
+ repository_root = tmp_path / "repo"
93
+ _init_repo_with_github_origin(repository_root, "OtherOwner/other-repo")
94
+ assert _is_repository_exempt_from_pii_scan(repository_root) is False
95
+
96
+
97
+ def test_repository_without_origin_is_never_exempt(tmp_path: Path) -> None:
98
+ repository_root = tmp_path / "repo"
99
+ repository_root.mkdir()
100
+ subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
101
+ assert _is_repository_exempt_from_pii_scan(repository_root) is False
102
+
103
+
104
+ def test_owner_repo_slug_strips_a_trailing_slash_origin() -> None:
105
+ assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo/") == "owner/repo"