claude-dev-env 1.93.0 → 1.94.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 (220) hide show
  1. package/_shared/pr-loop/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/audit-contract.md +1 -1
  3. package/_shared/pr-loop/gh-payloads.md +3 -3
  4. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  5. package/_shared/pr-loop/scripts/README.md +2 -2
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
  7. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  9. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
  10. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  11. package/_shared/pr-loop/state-schema.md +3 -22
  12. package/bin/CLAUDE.md +1 -1
  13. package/bin/install.mjs +2 -7
  14. package/bin/install.test.mjs +10 -4
  15. package/hooks/blocking/CLAUDE.md +2 -0
  16. package/hooks/blocking/pii_commit_command.py +398 -0
  17. package/hooks/blocking/pii_payload_scan.py +133 -0
  18. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  19. package/hooks/blocking/pii_scanner.py +26 -1
  20. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  21. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  22. package/hooks/blocking/test_pii_scanner.py +27 -2
  23. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  24. package/hooks/hooks_constants/CLAUDE.md +2 -2
  25. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  26. package/hooks/hooks_constants/local_identity.py +65 -15
  27. package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
  28. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  29. package/hooks/hooks_constants/test_local_identity.py +68 -0
  30. package/package.json +1 -1
  31. package/scripts/CLAUDE.md +10 -1
  32. package/scripts/Get-SessionAccount.ps1 +210 -0
  33. package/scripts/tests/CLAUDE.md +15 -1
  34. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  35. package/skills/CLAUDE.md +3 -25
  36. package/skills/anthropic-plan/SKILL.md +1 -13
  37. package/skills/autoconverge/CLAUDE.md +5 -2
  38. package/skills/autoconverge/SKILL.md +112 -387
  39. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  40. package/skills/autoconverge/reference/closing-report.md +9 -2
  41. package/skills/autoconverge/reference/convergence.md +26 -22
  42. package/skills/autoconverge/reference/headless-safety.md +44 -0
  43. package/skills/autoconverge/reference/multi-pr.md +88 -0
  44. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  45. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  46. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  47. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  49. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  50. package/skills/autoconverge/workflow/converge.mjs +284 -94
  51. package/skills/bugteam/CLAUDE.md +1 -1
  52. package/skills/bugteam/CONSTRAINTS.md +3 -3
  53. package/skills/bugteam/PROMPTS.md +8 -19
  54. package/skills/bugteam/SKILL.md +13 -14
  55. package/skills/bugteam/reference/CLAUDE.md +1 -1
  56. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  57. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  58. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  59. package/skills/bugteam/reference/team-setup.md +11 -11
  60. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  61. package/skills/bugteam/scripts/README.md +20 -8
  62. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  63. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  64. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  65. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  66. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  67. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  68. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  69. package/skills/copilot-finding-triage/SKILL.md +80 -28
  70. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  71. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  72. package/skills/everything-search/SKILL.md +1 -2
  73. package/skills/grokify/SKILL.md +58 -0
  74. package/skills/grokify/templates/handoff-template.md +92 -0
  75. package/skills/orchestrator/SKILL.md +12 -8
  76. package/skills/orchestrator-refresh/SKILL.md +3 -3
  77. package/skills/pr-converge/SKILL.md +132 -233
  78. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  79. package/skills/pr-converge/reference/examples.md +37 -25
  80. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  81. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  82. package/skills/pr-converge/reference/per-tick.md +64 -41
  83. package/skills/pr-converge/reference/state-schema.md +27 -2
  84. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  85. package/skills/pr-converge/scripts/README.md +4 -3
  86. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  87. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  88. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  89. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  90. package/skills/pr-converge/scripts/conftest.py +8 -0
  91. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  92. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  93. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  94. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  95. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  96. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  97. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  98. package/skills/privacy-hygiene/SKILL.md +8 -7
  99. package/skills/reviewer-gates/SKILL.md +26 -8
  100. package/skills/session-log/SKILL.md +11 -12
  101. package/skills/session-tidy/SKILL.md +40 -30
  102. package/skills/skill-builder/CLAUDE.md +13 -10
  103. package/skills/skill-builder/SKILL.md +31 -31
  104. package/skills/skill-builder/references/CLAUDE.md +10 -6
  105. package/skills/skill-builder/references/delegation-map.md +13 -4
  106. package/skills/skill-builder/references/description-field.md +113 -0
  107. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  108. package/skills/skill-builder/references/skill-modularity.md +113 -0
  109. package/skills/skill-builder/references/skill-types.md +4 -0
  110. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  111. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  112. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  113. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  114. package/skills/skill-builder/workflows/new-skill.md +34 -10
  115. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  116. package/skills/usage-pause/SKILL.md +10 -14
  117. package/skills/bdd-protocol/CLAUDE.md +0 -26
  118. package/skills/bdd-protocol/SKILL.md +0 -30
  119. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  120. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  121. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  122. package/skills/bg-agent/CLAUDE.md +0 -17
  123. package/skills/bg-agent/SKILL.md +0 -69
  124. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  125. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  127. package/skills/caveman/CLAUDE.md +0 -15
  128. package/skills/caveman/SKILL.md +0 -38
  129. package/skills/code/CLAUDE.md +0 -17
  130. package/skills/code/SKILL.md +0 -178
  131. package/skills/deep-research/CLAUDE.md +0 -17
  132. package/skills/deep-research/SKILL.md +0 -80
  133. package/skills/findbugs/CLAUDE.md +0 -20
  134. package/skills/findbugs/SKILL.md +0 -241
  135. package/skills/fixbugs/CLAUDE.md +0 -19
  136. package/skills/fixbugs/SKILL.md +0 -142
  137. package/skills/gh-paginate/CLAUDE.md +0 -18
  138. package/skills/gh-paginate/SKILL.md +0 -84
  139. package/skills/gotcha/CLAUDE.md +0 -33
  140. package/skills/gotcha/SKILL.md +0 -73
  141. package/skills/implement/CLAUDE.md +0 -27
  142. package/skills/implement/SKILL.md +0 -66
  143. package/skills/implement/scripts/CLAUDE.md +0 -22
  144. package/skills/implement/scripts/append_note.py +0 -133
  145. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  146. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  147. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  148. package/skills/implement/scripts/test_append_note.py +0 -191
  149. package/skills/log-audit/CLAUDE.md +0 -20
  150. package/skills/log-audit/SKILL.md +0 -66
  151. package/skills/log-audit/reference/CLAUDE.md +0 -9
  152. package/skills/log-audit/reference/charter.md +0 -52
  153. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  154. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  155. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  156. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  157. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  158. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  159. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  160. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  161. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  162. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  163. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  164. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  165. package/skills/logifix/CLAUDE.md +0 -36
  166. package/skills/logifix/SKILL.md +0 -69
  167. package/skills/logifix/scripts/CLAUDE.md +0 -16
  168. package/skills/logifix/scripts/logifix.ps1 +0 -205
  169. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  170. package/skills/monitor-open-prs/SKILL.md +0 -88
  171. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  172. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  173. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  174. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  175. package/skills/post-audit-findings/SKILL.md +0 -80
  176. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  177. package/skills/pr-consistency-audit/SKILL.md +0 -116
  178. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  179. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  180. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  181. package/skills/pr-fix-protocol/SKILL.md +0 -65
  182. package/skills/pr-review-responder/CLAUDE.md +0 -35
  183. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  184. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  185. package/skills/pr-review-responder/README.md +0 -168
  186. package/skills/pr-review-responder/SKILL.md +0 -203
  187. package/skills/pr-review-responder/TESTING.md +0 -350
  188. package/skills/pr-scope-resolve/SKILL.md +0 -45
  189. package/skills/pre-compact/CLAUDE.md +0 -24
  190. package/skills/pre-compact/SKILL.md +0 -134
  191. package/skills/qbug/CLAUDE.md +0 -40
  192. package/skills/qbug/SKILL.md +0 -387
  193. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  194. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  195. package/skills/refine/CLAUDE.md +0 -44
  196. package/skills/refine/SKILL.md +0 -262
  197. package/skills/refine/templates/CLAUDE.md +0 -17
  198. package/skills/refine/templates/implementation-notes-template.html +0 -56
  199. package/skills/refine/templates/plan-template.md +0 -60
  200. package/skills/research-mode/CLAUDE.md +0 -35
  201. package/skills/research-mode/SKILL.md +0 -53
  202. package/skills/structure-prompt/CLAUDE.md +0 -42
  203. package/skills/structure-prompt/SKILL.md +0 -44
  204. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  205. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  206. package/skills/structure-prompt/reference/block-classification.md +0 -27
  207. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  208. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  209. package/skills/structure-prompt/reference/cleanup.md +0 -33
  210. package/skills/structure-prompt/reference/constraints.md +0 -33
  211. package/skills/structure-prompt/reference/directives.md +0 -37
  212. package/skills/structure-prompt/reference/examples.md +0 -72
  213. package/skills/structure-prompt/reference/instantiation.md +0 -51
  214. package/skills/structure-prompt/reference/output-contract.md +0 -72
  215. package/skills/structure-prompt/reference/per-category.md +0 -23
  216. package/skills/structure-prompt/reference/persona.md +0 -38
  217. package/skills/structure-prompt/reference/research.md +0 -33
  218. package/skills/structure-prompt/reference/structure.md +0 -28
  219. package/skills/verified-build/CLAUDE.md +0 -33
  220. package/skills/verified-build/SKILL.md +0 -33
@@ -17,6 +17,7 @@ _hooks_directory = str(Path(__file__).resolve().parent.parent)
17
17
  if _hooks_directory not in sys.path:
18
18
  sys.path.insert(0, _hooks_directory)
19
19
 
20
+ from hooks_constants.local_identity import nas_host # noqa: E402
20
21
  from hooks_constants.pii_prevention_constants import ( # noqa: E402
21
22
  ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
22
23
  ALL_EXACT_LEGAL_NOTICE_BASENAMES,
@@ -273,9 +274,33 @@ def _find_home_paths(text: str) -> list[PiiFinding]:
273
274
  return all_findings
274
275
 
275
276
 
277
+ def _all_allowlisted_private_ip_addresses() -> frozenset[str]:
278
+ """Return every private IPv4 address the scanner must leave unflagged.
279
+
280
+ ::
281
+
282
+ CLAUDE_NAS_HOST=10.20.30.40 -> static set + {"10.20.30.40"}
283
+ CLAUDE_NAS_HOST=nas.local -> static set only (a name, not an address)
284
+ (env unset, no file) -> static set only
285
+
286
+ The committed set carries no real address. The maintainer's own NAS host
287
+ resolves at scan time from ``CLAUDE_NAS_HOST`` or
288
+ ``~/.claude/local-identity.json``, so their ssh traffic and git-ignored
289
+ local config stay unflagged while the repository stays publishable.
290
+
291
+ Returns:
292
+ The static allowlist, joined with the locally configured NAS host when
293
+ that host is itself a private IPv4 address.
294
+ """
295
+ configured_nas_host = nas_host()
296
+ if not _is_private_ipv4_address(configured_nas_host):
297
+ return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES
298
+ return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES | {configured_nas_host}
299
+
300
+
276
301
  def _find_private_ips(text: str) -> list[PiiFinding]:
277
302
  all_findings: list[PiiFinding] = []
278
- allowlisted_addresses = ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES
303
+ allowlisted_addresses = _all_allowlisted_private_ip_addresses()
279
304
  for each_match in IPV4_PATTERN.finditer(text):
280
305
  address_text = each_match.group(1)
281
306
  if address_text in allowlisted_addresses:
@@ -124,8 +124,8 @@ def test_flags_field_read_only_by_test_module(neutral_root: Path) -> None:
124
124
  "from os_update_workflow.config import ThemeUpdateConfig\n"
125
125
  "\n"
126
126
  "def test_debug_port_default() -> None:\n"
127
- " cfg = ThemeUpdateConfig(portal_url='x', debug_port=9222, timeout_seconds=30)\n"
128
- " assert cfg.debug_port == 9222\n"
127
+ " cfg = ThemeUpdateConfig(portal_url='x', debug_port=8471, timeout_seconds=30)\n"
128
+ " assert cfg.debug_port == 8471\n"
129
129
  " assert cfg.portal_url == 'x'\n"
130
130
  " assert cfg.timeout_seconds == 30\n"
131
131
  )
@@ -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
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
+ import pytest
9
+
8
10
  _HOOK_DIR = Path(__file__).parent
9
11
  _HOOKS_DIR = _HOOK_DIR.parent
10
12
  if str(_HOOK_DIR) not in sys.path:
@@ -26,6 +28,10 @@ SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
26
28
  SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
27
29
  SYNTHETIC_PRIVATE_IP = "192.168.42.17"
28
30
  SYNTHETIC_PUBLIC_IP = "8.8.8.8"
31
+ NAS_HOST_ENVIRONMENT_VARIABLE = "CLAUDE_NAS_HOST"
32
+ SYNTHETIC_NAS_PRIVATE_IP = "10.20.30.40"
33
+ SYNTHETIC_NAS_HOSTNAME = "nas.example.local"
34
+ SYNTHETIC_NAS_SSH_COMMAND = f"ssh -p 2200 operator@{SYNTHETIC_NAS_PRIVATE_IP} uptime"
29
35
 
30
36
 
31
37
  def test_flags_real_email_and_allows_example_domain() -> None:
@@ -69,8 +75,27 @@ def test_allows_loopback_unspecified_and_link_local_addresses() -> None:
69
75
  assert scan_text_for_pii("peer 169.254.10.20") == []
70
76
 
71
77
 
72
- def test_allows_product_nas_private_ip() -> None:
73
- assert scan_text_for_pii("ssh -p 9222 jon@192.168.1.100 uptime") == []
78
+ def test_allows_the_nas_host_resolved_from_local_configuration(
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ ) -> None:
81
+ monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
82
+ assert scan_text_for_pii(SYNTHETIC_NAS_SSH_COMMAND) == []
83
+
84
+
85
+ def test_flags_private_ip_that_is_not_the_configured_nas_host(
86
+ monkeypatch: pytest.MonkeyPatch,
87
+ ) -> None:
88
+ monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
89
+ all_lan_hits = scan_text_for_pii(f"peer {SYNTHETIC_PRIVATE_IP}")
90
+ assert [each.matched_text for each in all_lan_hits] == [SYNTHETIC_PRIVATE_IP]
91
+
92
+
93
+ def test_flags_private_ip_when_the_configured_nas_host_is_a_hostname(
94
+ monkeypatch: pytest.MonkeyPatch,
95
+ ) -> None:
96
+ monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_HOSTNAME)
97
+ all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
98
+ assert any(each.category == "private-ip" for each in all_lan_hits)
74
99
 
75
100
 
76
101
  def test_flags_github_token_aws_key_and_pem_header() -> None:
@@ -16,8 +16,18 @@ import subprocess
16
16
  import sys
17
17
 
18
18
  _HOOK_DIR = pathlib.Path(__file__).parent
19
+ _HOOKS_DIR = _HOOK_DIR.parent
19
20
  if str(_HOOK_DIR) not in sys.path:
20
21
  sys.path.insert(0, str(_HOOK_DIR))
22
+ if str(_HOOKS_DIR) not in sys.path:
23
+ sys.path.insert(0, str(_HOOKS_DIR))
24
+
25
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
26
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
27
+ ALL_BASH_HOSTED_HOOK_ENTRIES,
28
+ BASH_TOOL_NAME,
29
+ POWERSHELL_TOOL_NAME,
30
+ )
21
31
 
22
32
  guard_spec = importlib.util.spec_from_file_location(
23
33
  "verdict_directory_write_blocker",
@@ -708,18 +718,43 @@ def _pretooluse_commands_for_matcher(matcher_substring: str) -> list[str]:
708
718
  return matching_commands
709
719
 
710
720
 
711
- def test_guard_is_registered_on_bash() -> None:
721
+ _VERDICT_GUARD_SCRIPT_RELATIVE_PATH = "blocking/verdict_directory_write_blocker.py"
722
+ _BASH_DISPATCHER_BASENAME = "bash_pre_tool_use_dispatcher.py"
723
+
724
+
725
+ def _verdict_guard_hosted_entries() -> list:
726
+ return [
727
+ each_entry
728
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
729
+ if each_entry.script_relative_path == _VERDICT_GUARD_SCRIPT_RELATIVE_PATH
730
+ ]
731
+
732
+
733
+ def _assert_bash_dispatcher_registered_for_matcher(matcher_substring: str) -> None:
734
+ matching_commands = _pretooluse_commands_for_matcher(matcher_substring)
712
735
  assert any(
713
- "verdict_directory_write_blocker.py" in each_command
714
- for each_command in _pretooluse_commands_for_matcher("Bash")
736
+ _BASH_DISPATCHER_BASENAME in each_command
737
+ for each_command in matching_commands
738
+ ), (
739
+ f"bash_pre_tool_use_dispatcher.py must be registered on matcher containing "
740
+ f"{matcher_substring!r}; commands={matching_commands!r}"
715
741
  )
716
742
 
717
743
 
744
+ def test_guard_is_registered_on_bash() -> None:
745
+ _assert_bash_dispatcher_registered_for_matcher("Bash")
746
+ matching_entries = _verdict_guard_hosted_entries()
747
+ assert len(matching_entries) == 1
748
+ assert matching_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
749
+ assert BASH_TOOL_NAME in matching_entries[0].applicable_tool_names
750
+
751
+
718
752
  def test_guard_is_registered_on_powershell() -> None:
719
- assert any(
720
- "verdict_directory_write_blocker.py" in each_command
721
- for each_command in _pretooluse_commands_for_matcher("PowerShell")
722
- )
753
+ _assert_bash_dispatcher_registered_for_matcher("PowerShell")
754
+ matching_entries = _verdict_guard_hosted_entries()
755
+ assert len(matching_entries) == 1
756
+ assert matching_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
757
+ assert POWERSHELL_TOOL_NAME in matching_entries[0].applicable_tool_names
723
758
 
724
759
 
725
760
  def test_hook_subprocess_imports_real_config_when_parent_holds_shadowing_config(
@@ -38,7 +38,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
38
38
  | `hosted_hook_runner.py` | `run_hook_capturing_output()` — shared runner that runs one dispatcher-hosted hook in-process via runpy and returns its captured stdout and crash flag |
39
39
  | `inline_tuple_string_magic_constants.py` | Patterns for detecting magic strings in inline tuple literals |
40
40
  | `js_conventions_constants.py` | Banned identifier set, boolean-prefix pattern, and declaration/JSDoc patterns for the JavaScript convention checks |
41
- | `local_identity.py` | Loader for the NAS host, ssh user, and ssh port the ssh enforcer guards, resolved from the `CLAUDE_NAS_*` env vars or `~/.claude/local-identity.json` with placeholder defaults; also composes the enforcer's two deny messages that quote those values |
41
+ | `local_identity.py` | Loader for local machine values: NAS host, ssh user, and ssh port the ssh enforcer guards (`CLAUDE_NAS_*` / `~/.claude/local-identity.json` with placeholder defaults), plus the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`); also composes the ssh enforcer's two deny messages that quote the NAS values |
42
42
  | `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
43
43
  | `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
44
44
  | `nas_ssh_binary_enforcer_constants.py` | Bash tool name, ssh-family basenames, OpenSSH binary path suffixes, launcher-wrapper set, shell control-operator tokens and split pattern, leading-assignment and duration patterns, and the batch-mode pattern for the NAS ssh binary enforcer |
@@ -85,5 +85,5 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
85
85
  - Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
86
86
  - Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
87
87
  - `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, `hosted_hook_runner.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
88
- - `local_identity.py` is a loader: it reads the environment or `~/.claude/local-identity.json` to resolve the private NAS values, keeping a committed placeholder default.
88
+ - `local_identity.py` is a loader: it reads the environment or `~/.claude/local-identity.json` to resolve private NAS values (with committed placeholder defaults) and the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`).
89
89
 
@@ -22,8 +22,6 @@ ALL_CONFIG_DIRECTORY_NAMES = frozenset(
22
22
  "pr_converge_skill_constants",
23
23
  "pr_converge_scripts_constants",
24
24
  "bugteam_scripts_constants",
25
- "implement_scripts_constants",
26
- "log_audit_constants",
27
25
  "usage_pause_constants",
28
26
  "dev_env_scripts_constants",
29
27
  }
@@ -1,19 +1,23 @@
1
- """Resolve the NAS host, ssh user, and ssh port for the NAS ssh enforcer hook.
1
+ """Resolve local identity values for NAS ssh and PII exempt-repo hooks.
2
2
 
3
- The enforcer ships into ``~/.claude/`` and cannot read a repository file, so it
4
- reads the real NAS values from the environment or a git-ignored file in the
5
- Claude home directory, and it composes the two deny messages that quote those
6
- values. The committed defaults are placeholders.
3
+ The enforcer hooks ship into ``~/.claude/`` and cannot read a repository file,
4
+ so they read real machine values from the environment or a git-ignored file in
5
+ the Claude home directory. This module supplies NAS host/user/port, the two
6
+ NAS deny messages that quote those values, and the owner/repo slug set used by
7
+ ``pii_exempt_repository_slugs`` (``CLAUDE_PII_EXEMPT_REPOS`` /
8
+ ``pii_exempt_repositories``). The committed NAS defaults are placeholders.
7
9
 
8
10
  ::
9
11
 
10
- CLAUDE_NAS_HOST set to a host -> nas_host() returns that host
11
- CLAUDE_NAS_SSH_PORT set to 2200 -> nas_ssh_port() returns 2200
12
- (env unset, no file) -> nas_host() == "nas.example.local"
13
- nas_ssh_port() == 22
12
+ CLAUDE_NAS_HOST set to a host -> nas_host() returns that host
13
+ CLAUDE_NAS_SSH_PORT set to 2200 -> nas_ssh_port() returns 2200
14
+ CLAUDE_PII_EXEMPT_REPOS="Owner/repo" -> {"owner/repo"}
15
+ (env unset, no file) -> nas_host() == "nas.example.local"
16
+ nas_ssh_port() == 22
17
+ pii_exempt_repository_slugs() == frozenset()
14
18
 
15
- Each value comes from its ``CLAUDE_NAS_*`` environment variable, then
16
- ``~/.claude/local-identity.json``, then the placeholder default.
19
+ Each value comes from its environment variable, then
20
+ ``~/.claude/local-identity.json``, then the placeholder default (NAS only).
17
21
  """
18
22
 
19
23
  from __future__ import annotations
@@ -23,6 +27,9 @@ import os
23
27
  from pathlib import Path
24
28
 
25
29
  NAS_HOST_ENV_VAR = "CLAUDE_NAS_HOST"
30
+ PII_EXEMPT_REPOS_ENV_VAR = "CLAUDE_PII_EXEMPT_REPOS"
31
+ PII_EXEMPT_REPOS_JSON_KEY = "pii_exempt_repositories"
32
+ PII_EXEMPT_REPOS_SEPARATOR = ","
26
33
  NAS_SSH_USER_ENV_VAR = "CLAUDE_NAS_SSH_USER"
27
34
  NAS_SSH_PORT_ENV_VAR = "CLAUDE_NAS_SSH_PORT"
28
35
  NAS_JSON_KEY = "nas"
@@ -42,7 +49,7 @@ def _local_identity_file_path() -> Path:
42
49
  return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / LOCAL_IDENTITY_FILE_NAME
43
50
 
44
51
 
45
- def _nas_section_from_local_file() -> dict[str, object]:
52
+ def _identity_dictionary_from_local_file() -> dict[str, object]:
46
53
  identity_path = _local_identity_file_path()
47
54
  if not identity_path.is_file():
48
55
  return {}
@@ -50,12 +57,55 @@ def _nas_section_from_local_file() -> dict[str, object]:
50
57
  parsed_identity = json.loads(identity_path.read_text(encoding="utf-8"))
51
58
  except (OSError, ValueError, UnicodeDecodeError):
52
59
  return {}
53
- if not isinstance(parsed_identity, dict):
54
- return {}
55
- nas_section = parsed_identity.get(NAS_JSON_KEY, {})
60
+ return parsed_identity if isinstance(parsed_identity, dict) else {}
61
+
62
+
63
+ def _nas_section_from_local_file() -> dict[str, object]:
64
+ nas_section = _identity_dictionary_from_local_file().get(NAS_JSON_KEY, {})
56
65
  return nas_section if isinstance(nas_section, dict) else {}
57
66
 
58
67
 
68
+ def _normalized_slug_set(all_raw_slugs: list[object]) -> frozenset[str]:
69
+ return frozenset(
70
+ each_slug.strip().lower()
71
+ for each_slug in all_raw_slugs
72
+ if isinstance(each_slug, str) and each_slug.strip()
73
+ )
74
+
75
+
76
+ def pii_exempt_repository_slugs() -> frozenset[str]:
77
+ """Return owner/repo slugs whose commits skip the staged PII scan.
78
+
79
+ ::
80
+
81
+ CLAUDE_PII_EXEMPT_REPOS="Owner/repo-a, Other/b" -> {"owner/repo-a", "other/b"}
82
+ CLAUDE_PII_EXEMPT_REPOS=" , , " + file lists A -> {"a"} (whitespace falls through)
83
+ (env unset, file lists ["Owner/repo-a"]) -> {"owner/repo-a"}
84
+ (env unset, no file) -> frozenset()
85
+
86
+ A non-empty environment variable (comma-separated) wins. When the variable
87
+ is unset or normalizes to no slugs, the ``pii_exempt_repositories`` list in
88
+ the git-ignored ``~/.claude/local-identity.json`` is used. Slugs are
89
+ lowercased so matching is case-insensitive.
90
+
91
+ Returns:
92
+ Lowercased ``owner/repo`` slugs exempt from staged-commit PII scanning.
93
+ """
94
+ slugs_from_environment = os.environ.get(PII_EXEMPT_REPOS_ENV_VAR)
95
+ if slugs_from_environment is not None:
96
+ normalized_environment_slugs = _normalized_slug_set(
97
+ list(slugs_from_environment.split(PII_EXEMPT_REPOS_SEPARATOR))
98
+ )
99
+ if normalized_environment_slugs:
100
+ return normalized_environment_slugs
101
+ stored_slugs = _identity_dictionary_from_local_file().get(
102
+ PII_EXEMPT_REPOS_JSON_KEY
103
+ )
104
+ if not isinstance(stored_slugs, list):
105
+ return frozenset()
106
+ return _normalized_slug_set(stored_slugs)
107
+
108
+
59
109
  def nas_host() -> str:
60
110
  """Return the NAS host the ssh enforcer guards.
61
111
 
@@ -169,11 +169,7 @@ ALL_PLACEHOLDER_HOME_USERNAMES: frozenset[str] = frozenset(
169
169
  }
170
170
  )
171
171
 
172
- ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset(
173
- {
174
- "192.168.1.100",
175
- }
176
- )
172
+ ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset()
177
173
 
178
174
  MAXIMUM_FINDINGS_PER_SCAN: int = 12
179
175
  MAXIMUM_STAGED_FILE_BYTES: int = 1_000_000
@@ -191,6 +187,22 @@ ALL_STAGED_FILES_COMMAND: tuple[str, ...] = (
191
187
  ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX: tuple[str, ...] = ("git", "show")
192
188
  STAGED_BLOB_PREFIX: str = ":"
193
189
 
190
+ ALL_GIT_ORIGIN_URL_COMMAND: tuple[str, ...] = (
191
+ "git",
192
+ "config",
193
+ "--get",
194
+ "remote.origin.url",
195
+ )
196
+ GIT_URL_SUFFIX: str = ".git"
197
+ GITHUB_COM_HOST: str = "github.com"
198
+ ALL_NETWORK_GIT_URL_SCHEMES: frozenset[str] = frozenset({"http", "https", "ssh"})
199
+ SCP_STYLE_PATH_SEPARATOR: str = ":"
200
+ USERINFO_HOST_SEPARATOR: str = "@"
201
+ URL_SCHEME_SEPARATOR: str = "://"
202
+ WINDOWS_PATH_SEPARATOR: str = "\\"
203
+ POSIX_PATH_SEPARATOR: str = "/"
204
+ MINIMUM_OWNER_REPO_SEGMENT_COUNT: int = 2
205
+
194
206
  BODY_FILE_ENCODING: str = "utf-8"
195
207
  NULL_BYTE_MARKER: bytes = b"\x00"
196
208
  MESSAGE_LINE_SEPARATOR: str = "\n"
@@ -269,9 +281,9 @@ CORRECTIVE_MESSAGE_HEADER: str = (
269
281
  CORRECTIVE_MESSAGE_FOOTER: str = (
270
282
  "Remediate: replace with placeholders (user@example.com, C:/Users/example/), "
271
283
  "move secrets to an env or secret store, and run the privacy-hygiene skill "
272
- "for a full sweep. Add intentional LAN hosts to "
273
- "ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES in hooks_constants when tooling must "
274
- "keep a host such as a NAS."
284
+ "for a full sweep. Your own NAS host is allowlisted at scan time from "
285
+ "CLAUDE_NAS_HOST or ~/.claude/local-identity.json, so set it there rather "
286
+ "than committing the address."
275
287
  )
276
288
 
277
289
  FINDING_LINE_TEMPLATE: str = " [{category}] {preview}"