claude-dev-env 1.93.1 → 1.95.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 (262) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/_shared/pr-loop/CLAUDE.md +1 -0
  7. package/_shared/pr-loop/audit-contract.md +1 -1
  8. package/_shared/pr-loop/gh-payloads.md +3 -3
  9. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  10. package/_shared/pr-loop/scripts/README.md +2 -2
  11. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
  13. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  14. package/_shared/pr-loop/state-schema.md +3 -22
  15. package/bin/CLAUDE.md +11 -2
  16. package/bin/ever-shipped-skills.mjs +70 -0
  17. package/bin/install.mjs +138 -13
  18. package/bin/install.prune.test.mjs +457 -0
  19. package/bin/install.test.mjs +10 -4
  20. package/docs/CODE_RULES.md +1 -1
  21. package/hooks/blocking/CLAUDE.md +2 -0
  22. package/hooks/blocking/code_rules_enforcer.py +4 -0
  23. package/hooks/blocking/code_rules_shared.py +82 -0
  24. package/hooks/blocking/code_rules_test_layout.py +9 -3
  25. package/hooks/blocking/pii_commit_command.py +398 -0
  26. package/hooks/blocking/pii_payload_scan.py +133 -0
  27. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  28. package/hooks/blocking/plain_language_blocker.py +138 -4
  29. package/hooks/blocking/sensitive_file_protector.py +114 -48
  30. package/hooks/blocking/tdd_enforcer.py +9 -2
  31. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  32. package/hooks/blocking/test_code_rules_shared.py +181 -0
  33. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  34. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  35. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  36. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  38. package/hooks/hooks_constants/CLAUDE.md +4 -2
  39. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  40. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  41. package/hooks/hooks_constants/local_identity.py +65 -15
  42. package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
  43. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  44. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  46. package/hooks/hooks_constants/test_local_identity.py +68 -0
  47. package/hooks/pyproject.toml +75 -4
  48. package/hooks/validators/CLAUDE.md +1 -1
  49. package/hooks/validators/README.md +2 -0
  50. package/hooks/validators/python_style_checks.py +114 -136
  51. package/hooks/validators/python_style_helpers.py +95 -0
  52. package/hooks/validators/test_python_style_checks.py +0 -164
  53. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  54. package/hooks/validators/test_python_style_fixes.py +251 -0
  55. package/hooks/validators/test_python_style_helpers.py +125 -0
  56. package/package.json +1 -1
  57. package/rules/CLAUDE.md +1 -0
  58. package/rules/anti-corollary-tests.md +69 -0
  59. package/rules/bdd.md +1 -3
  60. package/rules/code-reviews.md +1 -1
  61. package/rules/gh-paginate.md +1 -1
  62. package/rules/plain-language.md +2 -0
  63. package/scripts/CLAUDE.md +10 -1
  64. package/scripts/Get-SessionAccount.ps1 +210 -0
  65. package/scripts/tests/CLAUDE.md +15 -1
  66. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  67. package/skills/CLAUDE.md +6 -27
  68. package/skills/anthropic-plan/SKILL.md +1 -13
  69. package/skills/autoconverge/CLAUDE.md +5 -2
  70. package/skills/autoconverge/SKILL.md +112 -387
  71. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  72. package/skills/autoconverge/reference/closing-report.md +9 -2
  73. package/skills/autoconverge/reference/convergence.md +26 -22
  74. package/skills/autoconverge/reference/headless-safety.md +44 -0
  75. package/skills/autoconverge/reference/multi-pr.md +88 -0
  76. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  77. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  78. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  79. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  80. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  81. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  82. package/skills/autoconverge/workflow/converge.mjs +286 -96
  83. package/skills/bugteam/CLAUDE.md +1 -1
  84. package/skills/bugteam/CONSTRAINTS.md +3 -3
  85. package/skills/bugteam/PROMPTS.md +8 -19
  86. package/skills/bugteam/SKILL.md +13 -14
  87. package/skills/bugteam/reference/CLAUDE.md +1 -1
  88. package/skills/bugteam/reference/README.md +2 -3
  89. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  90. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  91. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  92. package/skills/bugteam/reference/team-setup.md +11 -11
  93. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  94. package/skills/bugteam/scripts/README.md +20 -8
  95. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  96. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  97. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  98. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  99. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  100. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  101. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  102. package/skills/closeout/SKILL.md +153 -0
  103. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  104. package/skills/closeout/reference/issue-body-templates.md +108 -0
  105. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  106. package/skills/copilot-finding-triage/SKILL.md +80 -28
  107. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  108. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  109. package/skills/everything-search/SKILL.md +1 -2
  110. package/skills/grokify/SKILL.md +58 -0
  111. package/skills/grokify/templates/handoff-template.md +92 -0
  112. package/skills/orchestrator/SKILL.md +35 -25
  113. package/skills/orchestrator-refresh/SKILL.md +15 -11
  114. package/skills/pr-converge/CLAUDE.md +1 -1
  115. package/skills/pr-converge/SKILL.md +132 -233
  116. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  117. package/skills/pr-converge/reference/examples.md +37 -25
  118. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  119. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  120. package/skills/pr-converge/reference/per-tick.md +64 -41
  121. package/skills/pr-converge/reference/state-schema.md +27 -2
  122. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  123. package/skills/pr-converge/scripts/README.md +4 -3
  124. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  125. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  126. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  127. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  128. package/skills/pr-converge/scripts/conftest.py +8 -0
  129. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  130. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  131. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  132. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  133. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  134. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  135. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  136. package/skills/pr-fix-protocol/SKILL.md +20 -20
  137. package/skills/privacy-hygiene/SKILL.md +4 -3
  138. package/skills/reviewer-gates/SKILL.md +26 -8
  139. package/skills/session-log/SKILL.md +11 -12
  140. package/skills/session-tidy/SKILL.md +40 -30
  141. package/skills/skill-builder/CLAUDE.md +13 -10
  142. package/skills/skill-builder/SKILL.md +31 -31
  143. package/skills/skill-builder/references/CLAUDE.md +10 -6
  144. package/skills/skill-builder/references/delegation-map.md +13 -4
  145. package/skills/skill-builder/references/description-field.md +113 -0
  146. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  147. package/skills/skill-builder/references/skill-modularity.md +113 -0
  148. package/skills/skill-builder/references/skill-types.md +4 -0
  149. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  150. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  151. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  152. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  153. package/skills/skill-builder/workflows/new-skill.md +34 -10
  154. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  155. package/skills/team-advisor/SKILL.md +15 -11
  156. package/skills/usage-pause/SKILL.md +10 -14
  157. package/system-prompts/software-engineer.xml +7 -6
  158. package/hooks/validators/test_verify_paths.py +0 -32
  159. package/hooks/validators/verify_paths.py +0 -57
  160. package/skills/bdd-protocol/CLAUDE.md +0 -26
  161. package/skills/bdd-protocol/SKILL.md +0 -30
  162. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  163. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  164. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  165. package/skills/bg-agent/CLAUDE.md +0 -17
  166. package/skills/bg-agent/SKILL.md +0 -69
  167. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  168. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  169. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  170. package/skills/caveman/CLAUDE.md +0 -15
  171. package/skills/caveman/SKILL.md +0 -38
  172. package/skills/code/CLAUDE.md +0 -17
  173. package/skills/code/SKILL.md +0 -178
  174. package/skills/deep-research/CLAUDE.md +0 -17
  175. package/skills/deep-research/SKILL.md +0 -80
  176. package/skills/findbugs/CLAUDE.md +0 -20
  177. package/skills/findbugs/SKILL.md +0 -241
  178. package/skills/fixbugs/CLAUDE.md +0 -19
  179. package/skills/fixbugs/SKILL.md +0 -142
  180. package/skills/gh-paginate/CLAUDE.md +0 -18
  181. package/skills/gh-paginate/SKILL.md +0 -84
  182. package/skills/gotcha/CLAUDE.md +0 -33
  183. package/skills/gotcha/SKILL.md +0 -73
  184. package/skills/implement/CLAUDE.md +0 -27
  185. package/skills/implement/SKILL.md +0 -66
  186. package/skills/implement/scripts/CLAUDE.md +0 -22
  187. package/skills/implement/scripts/append_note.py +0 -133
  188. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  189. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  190. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  191. package/skills/implement/scripts/test_append_note.py +0 -191
  192. package/skills/log-audit/CLAUDE.md +0 -20
  193. package/skills/log-audit/SKILL.md +0 -66
  194. package/skills/log-audit/reference/CLAUDE.md +0 -9
  195. package/skills/log-audit/reference/charter.md +0 -52
  196. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  197. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  198. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  199. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  200. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  201. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  202. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  203. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  204. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  205. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  206. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  207. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  208. package/skills/logifix/CLAUDE.md +0 -36
  209. package/skills/logifix/SKILL.md +0 -69
  210. package/skills/logifix/scripts/CLAUDE.md +0 -16
  211. package/skills/logifix/scripts/logifix.ps1 +0 -205
  212. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  213. package/skills/monitor-open-prs/SKILL.md +0 -88
  214. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  215. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  216. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  217. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  218. package/skills/post-audit-findings/SKILL.md +0 -80
  219. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  220. package/skills/pr-consistency-audit/SKILL.md +0 -116
  221. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  222. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  223. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  224. package/skills/pr-review-responder/CLAUDE.md +0 -35
  225. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  226. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  227. package/skills/pr-review-responder/README.md +0 -168
  228. package/skills/pr-review-responder/SKILL.md +0 -203
  229. package/skills/pr-review-responder/TESTING.md +0 -350
  230. package/skills/pr-scope-resolve/SKILL.md +0 -45
  231. package/skills/pre-compact/CLAUDE.md +0 -24
  232. package/skills/pre-compact/SKILL.md +0 -134
  233. package/skills/qbug/CLAUDE.md +0 -40
  234. package/skills/qbug/SKILL.md +0 -387
  235. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  236. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  237. package/skills/refine/CLAUDE.md +0 -44
  238. package/skills/refine/SKILL.md +0 -262
  239. package/skills/refine/templates/CLAUDE.md +0 -17
  240. package/skills/refine/templates/implementation-notes-template.html +0 -56
  241. package/skills/refine/templates/plan-template.md +0 -60
  242. package/skills/research-mode/CLAUDE.md +0 -35
  243. package/skills/research-mode/SKILL.md +0 -53
  244. package/skills/structure-prompt/CLAUDE.md +0 -42
  245. package/skills/structure-prompt/SKILL.md +0 -44
  246. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  247. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  248. package/skills/structure-prompt/reference/block-classification.md +0 -27
  249. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  250. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  251. package/skills/structure-prompt/reference/cleanup.md +0 -33
  252. package/skills/structure-prompt/reference/constraints.md +0 -33
  253. package/skills/structure-prompt/reference/directives.md +0 -37
  254. package/skills/structure-prompt/reference/examples.md +0 -72
  255. package/skills/structure-prompt/reference/instantiation.md +0 -51
  256. package/skills/structure-prompt/reference/output-contract.md +0 -72
  257. package/skills/structure-prompt/reference/per-category.md +0 -23
  258. package/skills/structure-prompt/reference/persona.md +0 -38
  259. package/skills/structure-prompt/reference/research.md +0 -33
  260. package/skills/structure-prompt/reference/structure.md +0 -28
  261. package/skills/verified-build/CLAUDE.md +0 -33
  262. package/skills/verified-build/SKILL.md +0 -33
@@ -28,6 +28,7 @@ import pii_prevention_blocker as blocker_module # noqa: E402
28
28
  from pii_prevention_blocker import ( # noqa: E402
29
29
  evaluate,
30
30
  evaluate_bash_command,
31
+ evaluate_post_body_texts,
31
32
  evaluate_staged_commit,
32
33
  evaluate_write_edit_payload,
33
34
  extract_git_commit_working_directory,
@@ -903,3 +904,301 @@ def test_git_exe_dash_c_commit_scans_named_repo_not_cwd(tmp_path: Path) -> None:
903
904
  assert dash_c_scan is not None
904
905
  assert "email" in dash_c_scan
905
906
  assert SYNTHETIC_REAL_EMAIL not in dash_c_scan
907
+
908
+
909
+ def _add_repository_origin(repository_root: Path, origin_url: str) -> None:
910
+ subprocess.run(
911
+ ["git", "remote", "add", "origin", origin_url],
912
+ cwd=repository_root,
913
+ check=True,
914
+ capture_output=True,
915
+ text=True,
916
+ )
917
+
918
+
919
+ def test_exempt_repository_commit_skips_staged_pii_scan(
920
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
921
+ ) -> None:
922
+ repository_root = tmp_path / "repo"
923
+ _init_repo_with_staged_email(repository_root)
924
+ _add_repository_origin(
925
+ repository_root, "https://github.com/ExemptOwner/exempt-repo.git"
926
+ )
927
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
928
+ deny_reason = evaluate_bash_command(
929
+ "git commit -m test",
930
+ working_directory=str(repository_root),
931
+ )
932
+ assert deny_reason is None
933
+
934
+
935
+ def test_exempt_repository_matches_ssh_origin_url(
936
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
937
+ ) -> None:
938
+ repository_root = tmp_path / "repo"
939
+ _init_repo_with_staged_email(repository_root)
940
+ _add_repository_origin(
941
+ repository_root, "git@github.com:ExemptOwner/exempt-repo.git"
942
+ )
943
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "exemptowner/EXEMPT-REPO")
944
+ deny_reason = evaluate_bash_command(
945
+ "git commit -m test",
946
+ working_directory=str(repository_root),
947
+ )
948
+ assert deny_reason is None
949
+
950
+
951
+ def test_non_exempt_repository_commit_still_scans_staged_pii(
952
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
953
+ ) -> None:
954
+ repository_root = tmp_path / "repo"
955
+ _init_repo_with_staged_email(repository_root)
956
+ _add_repository_origin(
957
+ repository_root, "https://github.com/OtherOwner/other-repo.git"
958
+ )
959
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
960
+ deny_reason = evaluate_bash_command(
961
+ "git commit -m test",
962
+ working_directory=str(repository_root),
963
+ )
964
+ assert deny_reason is not None
965
+ assert "email" in deny_reason
966
+
967
+
968
+ def test_repository_without_origin_is_not_exempt(
969
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
970
+ ) -> None:
971
+ repository_root = tmp_path / "repo"
972
+ _init_repo_with_staged_email(repository_root)
973
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
974
+ deny_reason = evaluate_bash_command(
975
+ "git commit -m test",
976
+ working_directory=str(repository_root),
977
+ )
978
+ assert deny_reason is not None
979
+ assert "email" in deny_reason
980
+
981
+
982
+ def test_owner_repo_slug_from_https_origin_with_git_suffix() -> None:
983
+ assert (
984
+ blocker_module._owner_repo_slug_from_origin_url(
985
+ "https://github.com/Owner/repo.git"
986
+ )
987
+ == "owner/repo"
988
+ )
989
+
990
+
991
+ def test_owner_repo_slug_from_https_origin_without_git_suffix() -> None:
992
+ assert (
993
+ blocker_module._owner_repo_slug_from_origin_url(
994
+ "https://github.com/Owner/repo"
995
+ )
996
+ == "owner/repo"
997
+ )
998
+
999
+
1000
+ def test_owner_repo_slug_from_scp_ssh_origin() -> None:
1001
+ assert (
1002
+ blocker_module._owner_repo_slug_from_origin_url(
1003
+ "git@github.com:Owner/repo.git"
1004
+ )
1005
+ == "owner/repo"
1006
+ )
1007
+
1008
+
1009
+ def test_owner_repo_slug_from_ssh_scheme_origin() -> None:
1010
+ assert (
1011
+ blocker_module._owner_repo_slug_from_origin_url(
1012
+ "ssh://git@github.com/Owner/repo.git"
1013
+ )
1014
+ == "owner/repo"
1015
+ )
1016
+
1017
+
1018
+ def test_owner_repo_slug_from_trailing_slash_git_suffix() -> None:
1019
+ assert (
1020
+ blocker_module._owner_repo_slug_from_origin_url(
1021
+ "https://github.com/Owner/repo.git/"
1022
+ )
1023
+ == "owner/repo"
1024
+ )
1025
+
1026
+
1027
+ def test_owner_repo_slug_rejects_spoofed_evil_host() -> None:
1028
+ assert (
1029
+ blocker_module._owner_repo_slug_from_origin_url(
1030
+ "https://evil.test/ExemptOwner/exempt-repo.git"
1031
+ )
1032
+ is None
1033
+ )
1034
+
1035
+
1036
+ def test_owner_repo_slug_rejects_notgithub_com_host() -> None:
1037
+ assert (
1038
+ blocker_module._owner_repo_slug_from_origin_url(
1039
+ "https://notgithub.com/Owner/repo.git"
1040
+ )
1041
+ is None
1042
+ )
1043
+
1044
+
1045
+ def test_owner_repo_slug_rejects_github_com_suffix_host() -> None:
1046
+ assert (
1047
+ blocker_module._owner_repo_slug_from_origin_url(
1048
+ "https://github.com.evil.test/Owner/repo.git"
1049
+ )
1050
+ is None
1051
+ )
1052
+
1053
+
1054
+ def test_owner_repo_slug_rejects_invalid_https_port_authority() -> None:
1055
+ assert (
1056
+ blocker_module._owner_repo_slug_from_origin_url(
1057
+ "https://github.com:443.evil.test/Owner/repo.git"
1058
+ )
1059
+ is None
1060
+ )
1061
+
1062
+
1063
+ def test_owner_repo_slug_rejects_invalid_ssh_port_authority() -> None:
1064
+ assert (
1065
+ blocker_module._owner_repo_slug_from_origin_url(
1066
+ "ssh://git@github.com:22.evil.test/Owner/repo.git"
1067
+ )
1068
+ is None
1069
+ )
1070
+
1071
+
1072
+ def test_owner_repo_slug_from_https_origin_with_valid_port() -> None:
1073
+ assert (
1074
+ blocker_module._owner_repo_slug_from_origin_url(
1075
+ "https://github.com:443/Owner/repo.git"
1076
+ )
1077
+ == "owner/repo"
1078
+ )
1079
+
1080
+
1081
+ def test_owner_repo_slug_from_ssh_origin_with_valid_port() -> None:
1082
+ assert (
1083
+ blocker_module._owner_repo_slug_from_origin_url(
1084
+ "ssh://git@github.com:22/Owner/repo.git"
1085
+ )
1086
+ == "owner/repo"
1087
+ )
1088
+
1089
+
1090
+ def test_owner_repo_slug_rejects_path_shaped_origin() -> None:
1091
+ assert (
1092
+ blocker_module._owner_repo_slug_from_origin_url(
1093
+ "C:/repos/ExemptOwner/exempt-repo"
1094
+ )
1095
+ is None
1096
+ )
1097
+
1098
+
1099
+ def test_spoofed_origin_host_still_denies_staged_email(
1100
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1101
+ ) -> None:
1102
+ repository_root = tmp_path / "repo"
1103
+ _init_repo_with_staged_email(repository_root)
1104
+ _add_repository_origin(
1105
+ repository_root, "https://evil.test/ExemptOwner/exempt-repo.git"
1106
+ )
1107
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1108
+ deny_reason = evaluate_bash_command(
1109
+ "git commit -m test",
1110
+ working_directory=str(repository_root),
1111
+ )
1112
+ assert deny_reason is not None
1113
+ assert "email" in deny_reason
1114
+
1115
+
1116
+ def test_invalid_port_authority_origin_still_denies_staged_email(
1117
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1118
+ ) -> None:
1119
+ repository_root = tmp_path / "repo"
1120
+ _init_repo_with_staged_email(repository_root)
1121
+ _add_repository_origin(
1122
+ repository_root,
1123
+ "https://github.com:443.evil.test/ExemptOwner/exempt-repo.git",
1124
+ )
1125
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1126
+ deny_reason = evaluate_bash_command(
1127
+ "git commit -m test",
1128
+ working_directory=str(repository_root),
1129
+ )
1130
+ assert deny_reason is not None
1131
+ assert "email" in deny_reason
1132
+
1133
+
1134
+ def test_path_shaped_origin_still_denies_staged_email(
1135
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1136
+ ) -> None:
1137
+ repository_root = tmp_path / "repo"
1138
+ _init_repo_with_staged_email(repository_root)
1139
+ _add_repository_origin(repository_root, "C:/repos/ExemptOwner/exempt-repo")
1140
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1141
+ deny_reason = evaluate_bash_command(
1142
+ "git commit -m test",
1143
+ working_directory=str(repository_root),
1144
+ )
1145
+ assert deny_reason is not None
1146
+ assert "email" in deny_reason
1147
+
1148
+
1149
+ def test_trailing_slash_git_suffix_origin_still_exempts(
1150
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1151
+ ) -> None:
1152
+ repository_root = tmp_path / "repo"
1153
+ _init_repo_with_staged_email(repository_root)
1154
+ _add_repository_origin(
1155
+ repository_root, "https://github.com/ExemptOwner/exempt-repo.git/"
1156
+ )
1157
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1158
+ deny_reason = evaluate_bash_command(
1159
+ "git commit -m test",
1160
+ working_directory=str(repository_root),
1161
+ )
1162
+ assert deny_reason is None
1163
+
1164
+
1165
+ def test_ssh_scheme_origin_still_exempts(
1166
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1167
+ ) -> None:
1168
+ repository_root = tmp_path / "repo"
1169
+ _init_repo_with_staged_email(repository_root)
1170
+ _add_repository_origin(
1171
+ repository_root, "ssh://git@github.com/ExemptOwner/exempt-repo.git"
1172
+ )
1173
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1174
+ deny_reason = evaluate_bash_command(
1175
+ "git commit -m test",
1176
+ working_directory=str(repository_root),
1177
+ )
1178
+ assert deny_reason is None
1179
+
1180
+
1181
+ def test_write_still_scans_when_repository_is_exempt(
1182
+ monkeypatch: pytest.MonkeyPatch,
1183
+ ) -> None:
1184
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1185
+ deny_reason = evaluate_write_edit_payload(
1186
+ "Write",
1187
+ {
1188
+ "file_path": "notes.md",
1189
+ "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
1190
+ },
1191
+ )
1192
+ assert deny_reason is not None
1193
+ assert "email" in deny_reason
1194
+
1195
+
1196
+ def test_post_body_still_scans_when_repository_is_exempt(
1197
+ monkeypatch: pytest.MonkeyPatch,
1198
+ ) -> None:
1199
+ monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1200
+ deny_reason = evaluate_post_body_texts(
1201
+ [f"Reach me at {SYNTHETIC_REAL_EMAIL}"]
1202
+ )
1203
+ assert deny_reason is not None
1204
+ assert "email" in deny_reason
@@ -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)
@@ -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
+ )