claude-dev-env 1.92.0 → 1.93.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 (216) hide show
  1. package/CLAUDE.md +5 -39
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +7 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/rules/testing.md +0 -2
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +184 -0
  179. package/skills/orchestrator-refresh/SKILL.md +37 -0
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +32 -164
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
  216. package/skills/team-advisor-refresh/SKILL.md +0 -25
@@ -0,0 +1,182 @@
1
+ """Resolve the NAS host, ssh user, and ssh port for the NAS ssh enforcer hook.
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.
7
+
8
+ ::
9
+
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
14
+
15
+ Each value comes from its ``CLAUDE_NAS_*`` environment variable, then
16
+ ``~/.claude/local-identity.json``, then the placeholder default.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from pathlib import Path
24
+
25
+ NAS_HOST_ENV_VAR = "CLAUDE_NAS_HOST"
26
+ NAS_SSH_USER_ENV_VAR = "CLAUDE_NAS_SSH_USER"
27
+ NAS_SSH_PORT_ENV_VAR = "CLAUDE_NAS_SSH_PORT"
28
+ NAS_JSON_KEY = "nas"
29
+ NAS_HOST_JSON_KEY = "host"
30
+ NAS_SSH_USER_JSON_KEY = "ssh_user"
31
+ NAS_SSH_PORT_JSON_KEY = "ssh_port"
32
+ CLAUDE_HOME_DIRECTORY_NAME = ".claude"
33
+ LOCAL_IDENTITY_FILE_NAME = "local-identity.json"
34
+ PLACEHOLDER_NAS_HOST = "nas.example.local"
35
+ PLACEHOLDER_NAS_SSH_USER = "operator"
36
+ PLACEHOLDER_NAS_SSH_PORT = 22
37
+ OPENSSH_INVOCATION_EXAMPLE_BINARY = '"/c/Windows/System32/OpenSSH/ssh.exe"'
38
+ NAS_SSH_RULE_REFERENCE = "~/.claude/rules/nas-ssh-invocation.md"
39
+
40
+
41
+ def _local_identity_file_path() -> Path:
42
+ return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / LOCAL_IDENTITY_FILE_NAME
43
+
44
+
45
+ def _nas_section_from_local_file() -> dict[str, object]:
46
+ identity_path = _local_identity_file_path()
47
+ if not identity_path.is_file():
48
+ return {}
49
+ try:
50
+ parsed_identity = json.loads(identity_path.read_text(encoding="utf-8"))
51
+ except (OSError, ValueError, UnicodeDecodeError):
52
+ return {}
53
+ if not isinstance(parsed_identity, dict):
54
+ return {}
55
+ nas_section = parsed_identity.get(NAS_JSON_KEY, {})
56
+ return nas_section if isinstance(nas_section, dict) else {}
57
+
58
+
59
+ def nas_host() -> str:
60
+ """Return the NAS host the ssh enforcer guards.
61
+
62
+ ::
63
+
64
+ CLAUDE_NAS_HOST set to a host -> that host
65
+ (env unset, no file) -> "nas.example.local"
66
+
67
+ The environment variable wins, then the git-ignored file, then the
68
+ placeholder default.
69
+
70
+ Returns:
71
+ The NAS hostname or address.
72
+ """
73
+ host_from_environment = os.environ.get(NAS_HOST_ENV_VAR)
74
+ if host_from_environment:
75
+ return host_from_environment
76
+ stored_host = _nas_section_from_local_file().get(NAS_HOST_JSON_KEY)
77
+ if isinstance(stored_host, str) and stored_host:
78
+ return stored_host
79
+ return PLACEHOLDER_NAS_HOST
80
+
81
+
82
+ def nas_ssh_user() -> str:
83
+ """Return the ssh user the enforcer expects for the NAS.
84
+
85
+ ::
86
+
87
+ CLAUDE_NAS_SSH_USER set to a user -> that user
88
+ (env unset, no file) -> "operator"
89
+
90
+ The environment variable wins, then the git-ignored file, then the
91
+ placeholder default.
92
+
93
+ Returns:
94
+ The ssh login name for the NAS.
95
+ """
96
+ user_from_environment = os.environ.get(NAS_SSH_USER_ENV_VAR)
97
+ if user_from_environment:
98
+ return user_from_environment
99
+ stored_user = _nas_section_from_local_file().get(NAS_SSH_USER_JSON_KEY)
100
+ if isinstance(stored_user, str) and stored_user:
101
+ return stored_user
102
+ return PLACEHOLDER_NAS_SSH_USER
103
+
104
+
105
+ def nas_ssh_port() -> int:
106
+ """Return the ssh port the enforcer expects for the NAS.
107
+
108
+ ::
109
+
110
+ CLAUDE_NAS_SSH_PORT set to 2200 -> 2200
111
+ (env unset, no file) -> 22
112
+
113
+ The environment variable wins, then the git-ignored file, then the
114
+ placeholder default.
115
+
116
+ Returns:
117
+ The ssh port number for the NAS.
118
+ """
119
+ port_from_environment = os.environ.get(NAS_SSH_PORT_ENV_VAR)
120
+ if port_from_environment and port_from_environment.isdigit():
121
+ return int(port_from_environment)
122
+ stored_port = _nas_section_from_local_file().get(NAS_SSH_PORT_JSON_KEY)
123
+ if isinstance(stored_port, int):
124
+ return stored_port
125
+ return PLACEHOLDER_NAS_SSH_PORT
126
+
127
+
128
+ def _openssh_invocation_example() -> str:
129
+ return (
130
+ f"{OPENSSH_INVOCATION_EXAMPLE_BINARY} -o BatchMode=yes -o ConnectTimeout=10 "
131
+ f'-p {nas_ssh_port()} {nas_ssh_user()}@{nas_host()} "<cmd>"'
132
+ )
133
+
134
+
135
+ def bare_ssh_binary_deny_message() -> str:
136
+ """Return the deny message for a bare ssh-family word aimed at the NAS.
137
+
138
+ ::
139
+
140
+ ssh -p 22 operator@nas.example.local "ls" -> this message text
141
+ (the text quotes the resolved host, user, and port)
142
+
143
+ Names the Git Bash MSYS-ssh hang and points at the Windows OpenSSH binary
144
+ with batch mode on.
145
+
146
+ Returns:
147
+ The full deny-message text for the bare ssh-family case.
148
+ """
149
+ return (
150
+ f"BLOCKED [nas-ssh-binary]: Git Bash's MSYS ssh reads ~/.ssh/id_ed25519 as "
151
+ f"world-readable through its ACL mapping, rejects the key as bad permissions, "
152
+ f"and falls back to an interactive password prompt that hangs unattended "
153
+ f"sessions against the NAS at {nas_host()}.\n\n"
154
+ f"Use the Windows OpenSSH binary, which authenticates the key without prompting:\n"
155
+ f" {_openssh_invocation_example()}\n\n"
156
+ f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
157
+ )
158
+
159
+
160
+ def missing_batch_mode_deny_message() -> str:
161
+ """Return the deny message for the OpenSSH binary without batch mode.
162
+
163
+ ::
164
+
165
+ "..ssh.exe" -p 22 operator@nas.example.local "ls" -> this message text
166
+ (the text quotes the resolved host, user, and port)
167
+
168
+ Names the interactive-prompt hang and asks for ``-o BatchMode=yes`` so a key
169
+ failure exits loudly.
170
+
171
+ Returns:
172
+ The full deny-message text for the missing-batch-mode case.
173
+ """
174
+ return (
175
+ f"BLOCKED [nas-ssh-binary]: this NAS ssh command uses the Windows OpenSSH binary "
176
+ f"but omits -o BatchMode=yes, so an authentication regression falls back to an "
177
+ f"interactive password prompt that hangs unattended sessions against the NAS at "
178
+ f"{nas_host()}.\n\n"
179
+ f"Add -o BatchMode=yes so a key failure exits loudly rather than prompting:\n"
180
+ f" {_openssh_invocation_example()}\n\n"
181
+ f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
182
+ )
@@ -1,26 +1,15 @@
1
1
  """Constants for the NAS ssh binary enforcer hook.
2
2
 
3
- Holds the Bash tool name, the NAS host-token pattern, the ssh-family command
4
- basenames, the Windows OpenSSH binary path suffixes, the launcher-wrapper set, the
5
- shell control-operator tokens and their split pattern, the leading-assignment and
6
- launcher-duration patterns, the batch-mode pattern, and the two deny messages.
3
+ Holds the Bash tool name, the ssh-family command basenames, the Windows OpenSSH
4
+ binary path suffixes, the launcher-wrapper set, the shell control-operator tokens
5
+ and their split pattern, the leading-assignment and launcher-duration patterns,
6
+ and the batch-mode pattern.
7
7
  """
8
8
 
9
9
  import re
10
10
 
11
11
  BASH_TOOL_NAME = "Bash"
12
12
 
13
- NAS_HOST_TOKEN_PATTERN = re.compile(r"(?:^|@)192\.168\.1\.100(?::|$)")
14
- """Match the NAS address only as a connection host.
15
-
16
- Anchored to token start or an ``@`` and followed by ``:`` or token end, so the
17
- address is matched as a bare host (``192.168.1.100``), a ``user@host``
18
- (``jon@192.168.1.100``), or a ``host:path`` target (``192.168.1.100:/vol1/``,
19
- ``jon@192.168.1.100:/vol1/``). The address inside a remote-command argument
20
- (``"ping 192.168.1.100"``) or a remote path component on another host
21
- (``jon@other:/backup/192.168.1.100/``) does not count as a NAS connection.
22
- """
23
-
24
13
  ALL_SSH_FAMILY_COMMAND_BASENAMES = frozenset(
25
14
  {"ssh", "scp", "sftp", "ssh.exe", "scp.exe", "sftp.exe"}
26
15
  )
@@ -35,25 +24,3 @@ CONTROL_OPERATOR_SPLIT_PATTERN = re.compile(r"(&&|\|\||;|\|&|\||(?<!>)&(?!>))")
35
24
  LEADING_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
36
25
  LAUNCHER_DURATION_PATTERN = re.compile(r"^\d+[a-z]*$", re.IGNORECASE)
37
26
  BATCH_MODE_PATTERN = re.compile(r"batchmode\s*=\s*yes", re.IGNORECASE)
38
-
39
- BARE_SSH_BINARY_MESSAGE = (
40
- "BLOCKED [nas-ssh-binary]: Git Bash's MSYS ssh reads ~/.ssh/id_ed25519 as "
41
- "world-readable through its ACL mapping, rejects the key as bad permissions, "
42
- "and falls back to an interactive password prompt that hangs unattended "
43
- "sessions against the NAS at 192.168.1.100.\n\n"
44
- "Use the Windows OpenSSH binary, which authenticates the key without prompting:\n"
45
- ' "/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes -o ConnectTimeout=10 '
46
- '-p 9222 jon@192.168.1.100 "<cmd>"\n\n'
47
- "See ~/.claude/rules/nas-ssh-invocation.md for full guidance."
48
- )
49
-
50
- MISSING_BATCH_MODE_MESSAGE = (
51
- "BLOCKED [nas-ssh-binary]: this NAS ssh command uses the Windows OpenSSH binary "
52
- "but omits -o BatchMode=yes, so an authentication regression falls back to an "
53
- "interactive password prompt that hangs unattended sessions against the NAS at "
54
- "192.168.1.100.\n\n"
55
- "Add -o BatchMode=yes so a key failure exits loudly rather than prompting:\n"
56
- ' "/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes -o ConnectTimeout=10 '
57
- '-p 9222 jon@192.168.1.100 "<cmd>"\n\n'
58
- "See ~/.claude/rules/nas-ssh-invocation.md for full guidance."
59
- )
@@ -0,0 +1,299 @@
1
+ """Named constants imported by `pii_prevention_blocker` and `pii_scanner`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from hooks_constants.hardcoded_user_path_constants import HARDCODED_USER_PATH_PATTERN
8
+
9
+ BASH_TOOL_NAME: str = "Bash"
10
+ POWERSHELL_TOOL_NAME: str = "PowerShell"
11
+ WRITE_TOOL_NAME: str = "Write"
12
+ EDIT_TOOL_NAME: str = "Edit"
13
+ MULTI_EDIT_TOOL_NAME: str = "MultiEdit"
14
+
15
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
16
+ {WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
17
+ )
18
+
19
+ ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset(
20
+ {BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
21
+ )
22
+
23
+ ALL_GIT_BINARY_BASENAMES: frozenset[str] = frozenset({"git", "git.exe"})
24
+ GIT_COMMIT_SUBCOMMAND: str = "commit"
25
+ GIT_WORKING_DIRECTORY_OPTION: str = "-C"
26
+ ALL_VALUE_TAKING_GIT_OPTIONS: frozenset[str] = frozenset(
27
+ {GIT_WORKING_DIRECTORY_OPTION, "-c", "--git-dir", "--work-tree", "--namespace"}
28
+ )
29
+ GIT_OPTION_WITH_VALUE_STEP: int = 2
30
+ ALL_SHELL_COMMAND_SEPARATOR_TOKENS: frozenset[str] = frozenset(
31
+ {"&&", "||", ";", "|", "&"}
32
+ )
33
+ ALL_SHELL_KEYWORD_TOKENS: frozenset[str] = frozenset(
34
+ {"then", "do", "else", "elif"}
35
+ )
36
+ ALL_COMMAND_WRAPPER_TOKENS: frozenset[str] = frozenset(
37
+ {
38
+ "sudo",
39
+ "doas",
40
+ "env",
41
+ "time",
42
+ "timeout",
43
+ "nice",
44
+ "ionice",
45
+ "chrt",
46
+ "xargs",
47
+ "command",
48
+ "stdbuf",
49
+ "nohup",
50
+ "setsid",
51
+ "flock",
52
+ }
53
+ )
54
+ ALL_LEADING_SKIPPABLE_COMMAND_TOKENS: frozenset[str] = (
55
+ ALL_SHELL_KEYWORD_TOKENS | ALL_COMMAND_WRAPPER_TOKENS
56
+ )
57
+ ALL_ONE_OPERAND_WRAPPER_TOKENS: frozenset[str] = frozenset({"timeout", "flock"})
58
+ ALL_BASH_FAMILY_INTERPRETER_BASENAMES: frozenset[str] = frozenset(
59
+ {
60
+ "bash",
61
+ "sh",
62
+ "bash.exe",
63
+ "sh.exe",
64
+ }
65
+ )
66
+ ALL_POWERSHELL_INTERPRETER_BASENAMES: frozenset[str] = frozenset(
67
+ {
68
+ "pwsh",
69
+ "pwsh.exe",
70
+ "powershell",
71
+ "powershell.exe",
72
+ }
73
+ )
74
+ ALL_SHELL_INTERPRETER_BASENAMES: frozenset[str] = (
75
+ ALL_BASH_FAMILY_INTERPRETER_BASENAMES | ALL_POWERSHELL_INTERPRETER_BASENAMES
76
+ )
77
+ SUBSHELL_GROUP_OPEN_TOKEN: str = "("
78
+ SHELL_INLINE_COMMAND_FLAG: str = "-c"
79
+ POWERSHELL_INLINE_COMMAND_FLAG: str = "-command"
80
+ INLINE_COMMAND_FLAG_CLUSTER_CHARACTER: str = "c"
81
+ INLINE_COMMAND_TOKEN_JOINER: str = " "
82
+ SINGLE_DASH_OPTION_PREFIX: str = "-"
83
+ DOUBLE_DASH_OPTION_PREFIX: str = "--"
84
+ ALL_SHELL_QUOTE_CHARACTERS: frozenset[str] = frozenset({'"', "'"})
85
+ ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS: frozenset[str] = frozenset({"\n", "\r"})
86
+ ENVIRONMENT_ASSIGNMENT_PATTERN: re.Pattern[str] = re.compile(
87
+ r"^[A-Za-z_][A-Za-z0-9_]*="
88
+ )
89
+ LINE_CONTINUATION_PATTERN: re.Pattern[str] = re.compile(r"\\\r?\n")
90
+ POWERSHELL_LINE_CONTINUATION_PATTERN: re.Pattern[str] = re.compile(r"`\r?\n")
91
+
92
+ MCP_GITHUB_TOOL_PREFIX: str = "mcp__plugin_github_github__"
93
+
94
+ HOOK_SCRIPT_BASENAME: str = "pii_prevention_blocker.py"
95
+
96
+ ALL_EXACT_LEGAL_NOTICE_BASENAMES: frozenset[str] = frozenset(
97
+ {
98
+ "license",
99
+ "license.md",
100
+ "license.txt",
101
+ "copying",
102
+ "copying.md",
103
+ "copying.txt",
104
+ "notice",
105
+ "notice.md",
106
+ "notice.txt",
107
+ }
108
+ )
109
+
110
+ ALL_SELF_MODULE_PATH_SUFFIXES: tuple[str, ...] = (
111
+ "/hooks/blocking/pii_prevention_blocker.py",
112
+ "/hooks/blocking/pii_scanner.py",
113
+ "/hooks/hooks_constants/pii_prevention_constants.py",
114
+ )
115
+
116
+ PYTHON_SOURCE_FILE_SUFFIX: str = ".py"
117
+ CONFTEST_BASENAME: str = "conftest.py"
118
+ TEST_MODULE_BASENAME_PREFIX: str = "test_"
119
+ TEST_MODULE_BASENAME_SUFFIX: str = "_test.py"
120
+ TESTS_PATH_SEGMENT: str = "/tests/"
121
+ TESTS_PATH_PREFIX: str = "tests/"
122
+ SPEC_BASENAME_MARKER: str = ".spec."
123
+ TEST_BASENAME_MARKER: str = ".test."
124
+ ALL_SOURCE_TEST_FILE_SUFFIXES: tuple[str, ...] = (
125
+ ".py",
126
+ ".ts",
127
+ ".tsx",
128
+ ".js",
129
+ ".jsx",
130
+ ".mjs",
131
+ ".cjs",
132
+ )
133
+ ALL_SAFE_EMAIL_DOMAINS: frozenset[str] = frozenset(
134
+ {
135
+ "example.com",
136
+ "example.org",
137
+ "example.net",
138
+ "example.edu",
139
+ "localhost",
140
+ "invalid",
141
+ "test",
142
+ "local",
143
+ "test.local",
144
+ }
145
+ )
146
+
147
+ ALL_PLACEHOLDER_HOME_USERNAMES: frozenset[str] = frozenset(
148
+ {
149
+ "example",
150
+ "user",
151
+ "username",
152
+ "your-user",
153
+ "your_user",
154
+ "you",
155
+ "name",
156
+ "alice",
157
+ "bob",
158
+ "carol",
159
+ "dave",
160
+ "placeholder",
161
+ "path",
162
+ "me",
163
+ "someone",
164
+ "default",
165
+ "admin",
166
+ "runner",
167
+ "container",
168
+ "ubuntu",
169
+ }
170
+ )
171
+
172
+ ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset(
173
+ {
174
+ "192.168.1.100",
175
+ }
176
+ )
177
+
178
+ MAXIMUM_FINDINGS_PER_SCAN: int = 12
179
+ MAXIMUM_STAGED_FILE_BYTES: int = 1_000_000
180
+ MAXIMUM_OFFENDING_PREVIEW_LENGTH: int = 80
181
+ GIT_COMMAND_TIMEOUT_SECONDS: int = 10
182
+
183
+ ALL_STAGED_FILES_COMMAND: tuple[str, ...] = (
184
+ "git",
185
+ "diff",
186
+ "--cached",
187
+ "--name-only",
188
+ "--diff-filter=ACMR",
189
+ )
190
+
191
+ ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX: tuple[str, ...] = ("git", "show")
192
+ STAGED_BLOB_PREFIX: str = ":"
193
+
194
+ BODY_FILE_ENCODING: str = "utf-8"
195
+ NULL_BYTE_MARKER: bytes = b"\x00"
196
+ MESSAGE_LINE_SEPARATOR: str = "\n"
197
+ MINIMUM_ENV_STYLE_USERNAME_LENGTH: int = 3
198
+ IPV4_VERSION_NUMBER: int = 4
199
+
200
+ ALL_RFC1918_NETWORK_CIDRS: tuple[str, ...] = (
201
+ "10.0.0.0/8",
202
+ "172.16.0.0/12",
203
+ "192.168.0.0/16",
204
+ )
205
+
206
+ CATEGORY_EMAIL: str = "email"
207
+ CATEGORY_HOME_PATH: str = "home-path"
208
+ CATEGORY_PRIVATE_IP: str = "private-ip"
209
+ CATEGORY_SECRET: str = "secret"
210
+
211
+ ALL_REDACTED_PREVIEW_CATEGORIES: frozenset[str] = frozenset(
212
+ {CATEGORY_EMAIL, CATEGORY_SECRET}
213
+ )
214
+ REDACTED_PREVIEW_PREFIX_LENGTH: int = 4
215
+ REDACTED_PREVIEW_SUFFIX_LENGTH: int = 4
216
+ REDACTED_PREVIEW_REVEALED_LENGTH: int = (
217
+ REDACTED_PREVIEW_PREFIX_LENGTH + REDACTED_PREVIEW_SUFFIX_LENGTH
218
+ )
219
+ REDACTED_PREVIEW_MINIMUM_HIDDEN_LENGTH: int = REDACTED_PREVIEW_REVEALED_LENGTH
220
+ REDACTED_PREVIEW_ELLIPSIS: str = "…"
221
+ REDACTED_SHORT_PREVIEW: str = "[redacted]"
222
+ MINIMUM_LENGTH_FOR_PARTIAL_REDACTION: int = (
223
+ REDACTED_PREVIEW_REVEALED_LENGTH + REDACTED_PREVIEW_MINIMUM_HIDDEN_LENGTH
224
+ )
225
+
226
+ EMAIL_PATTERN: re.Pattern[str] = re.compile(
227
+ r"(?i)\b([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})\b"
228
+ )
229
+
230
+ IPV4_PATTERN: re.Pattern[str] = re.compile(
231
+ r"\b((?:25[0-5]|2[0-4]\d|1?\d?\d)\."
232
+ r"(?:25[0-5]|2[0-4]\d|1?\d?\d)\."
233
+ r"(?:25[0-5]|2[0-4]\d|1?\d?\d)\."
234
+ r"(?:25[0-5]|2[0-4]\d|1?\d?\d))\b"
235
+ )
236
+
237
+ GITHUB_TOKEN_PATTERN: re.Pattern[str] = re.compile(
238
+ r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b"
239
+ )
240
+
241
+ GITHUB_FINE_GRAINED_TOKEN_PATTERN: re.Pattern[str] = re.compile(
242
+ r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"
243
+ )
244
+
245
+ AWS_ACCESS_KEY_PATTERN: re.Pattern[str] = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
246
+
247
+ PEM_PRIVATE_KEY_HEADER_PATTERN: re.Pattern[str] = re.compile(
248
+ r"-----BEGIN (?:RSA |OPENSSH |EC |DSA |ENCRYPTED )?PRIVATE KEY-----"
249
+ )
250
+
251
+ HOME_PATH_PATTERN: re.Pattern[str] = HARDCODED_USER_PATH_PATTERN
252
+
253
+ ALL_HOME_DIRECTORY_PATH_MARKERS: tuple[str, ...] = ("/users/", "/home/")
254
+
255
+ ALL_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
256
+ GITHUB_TOKEN_PATTERN,
257
+ GITHUB_FINE_GRAINED_TOKEN_PATTERN,
258
+ AWS_ACCESS_KEY_PATTERN,
259
+ PEM_PRIVATE_KEY_HEADER_PATTERN,
260
+ )
261
+
262
+ ANGLE_BRACKET_PLACEHOLDER_PATTERN: re.Pattern[str] = re.compile(r"^<[^>]+>$")
263
+
264
+ CORRECTIVE_MESSAGE_HEADER: str = (
265
+ "BLOCKED [pii_prevention_blocker]: high-confidence personal data or secret "
266
+ "material must not land in the repository or on a durable GitHub post."
267
+ )
268
+
269
+ CORRECTIVE_MESSAGE_FOOTER: str = (
270
+ "Remediate: replace with placeholders (user@example.com, C:/Users/example/), "
271
+ "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."
275
+ )
276
+
277
+ FINDING_LINE_TEMPLATE: str = " [{category}] {preview}"
278
+
279
+ STAGED_LIST_FAILURE_REASON: str = (
280
+ "BLOCKED [pii_prevention_blocker]: could not list staged files for PII scan "
281
+ "(git diff --cached failed). Refuse commit until the index is readable."
282
+ )
283
+
284
+ REPOSITORY_ROOT_UNRESOLVED_REASON: str = (
285
+ "BLOCKED [pii_prevention_blocker]: could not resolve the git repository root "
286
+ "for a commit command (not a git work tree, git missing, or bad -C path). "
287
+ "Refuse commit until the repository root is resolvable."
288
+ )
289
+
290
+ STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE: str = (
291
+ "BLOCKED [pii_prevention_blocker]: staged file '{relative_path}' could not be "
292
+ "scanned for PII ({reason}). Refuse commit, shrink the blob, or keep binary "
293
+ "assets free of embedded secrets."
294
+ )
295
+
296
+ STAGED_BLOB_REASON_GIT_SHOW_FAILED: str = "git show of staged blob failed"
297
+ STAGED_BLOB_REASON_OVERSIZED: str = "blob exceeds MAXIMUM_STAGED_FILE_BYTES"
298
+ STAGED_BLOB_REASON_NULL_BYTES: str = "blob contains null bytes (binary/unscannable)"
299
+ STAGED_BLOB_REASON_DECODE_FAILED: str = "blob is not valid UTF-8 text"
@@ -80,6 +80,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
80
80
  script_relative_path="blocking/sensitive_file_protector.py",
81
81
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
82
82
  ),
83
+ HostedHookEntry(
84
+ script_relative_path="blocking/pii_prevention_blocker.py",
85
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
86
+ ),
83
87
  HostedHookEntry(
84
88
  script_relative_path="validation/hook_format_validator.py",
85
89
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
@@ -0,0 +1,15 @@
1
+ """Constants for the python-style checks validator.
2
+
3
+ Two counts the checks compare against. One says how many blank lines belong
4
+ between one top-level function and the next. The other says how many
5
+ command-line arguments the runner needs before it has a file to check.
6
+ """
7
+
8
+ __all__ = [
9
+ "EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS",
10
+ "MINIMUM_ARGUMENT_COUNT",
11
+ ]
12
+
13
+ EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS: int = 2
14
+
15
+ MINIMUM_ARGUMENT_COUNT: int = 2
@@ -0,0 +1,26 @@
1
+ """Constants for the Stop-hook dispatcher.
2
+
3
+ Holds the ordered hosted-hook roster for the Stop chain. The dispatcher imports
4
+ this roster to run every Stop hook in registration order inside one process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "ALL_STOP_HOSTED_HOOK_PATHS",
11
+ "BLOCK_DECISION",
12
+ "DECISION_KEY",
13
+ "REASON_KEY",
14
+ ]
15
+
16
+ BLOCK_DECISION = "block"
17
+ DECISION_KEY = "decision"
18
+ REASON_KEY = "reason"
19
+
20
+ ALL_STOP_HOSTED_HOOK_PATHS: tuple[str, ...] = (
21
+ "blocking/hedging_language_blocker.py",
22
+ "blocking/question_to_user_enforcer.py",
23
+ "blocking/intent_only_ending_blocker.py",
24
+ "blocking/session_handoff_blocker.py",
25
+ "diagnostic/hook_log_stop_wrapper.py",
26
+ )
@@ -0,0 +1,87 @@
1
+ """Tests for the Bash and PowerShell dispatcher hosted-hook roster.
2
+
3
+ The roster order and per-hook applicable-tool sets fix the firing sequence and
4
+ tool coverage the dispatcher must reproduce, so these tests pin both against the
5
+ registration order the standalone chain used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
14
+ if _HOOKS_DIR not in sys.path:
15
+ sys.path.insert(0, _HOOKS_DIR)
16
+
17
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
18
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
19
+ ALL_BASH_HOSTED_HOOK_ENTRIES,
20
+ BASH_TOOL_NAME,
21
+ POWERSHELL_TOOL_NAME,
22
+ )
23
+
24
+ _EXPECTED_BASH_ORDER = (
25
+ "blocking/es_exe_path_rewriter.py",
26
+ "blocking/destructive_command_blocker.py",
27
+ "blocking/gh_body_arg_blocker.py",
28
+ "blocking/nas_ssh_binary_enforcer.py",
29
+ "blocking/volatile_path_in_post_blocker.py",
30
+ "blocking/pii_prevention_blocker.py",
31
+ "blocking/conventional_pr_title_gate.py",
32
+ "blocking/reviewer_spawn_gate.py",
33
+ "blocking/block_main_commit.py",
34
+ "blocking/precommit_code_rules_gate.py",
35
+ "blocking/session_edit_stage_gate.py",
36
+ "blocking/pr_description_enforcer.py",
37
+ "blocking/test_preflight_check.py",
38
+ "blocking/convergence_gate_blocker.py",
39
+ "blocking/windows_rmtree_blocker.py",
40
+ "blocking/gh_pr_author_enforcer.py",
41
+ "blocking/verified_commit_gate.py",
42
+ "blocking/verdict_directory_write_blocker.py",
43
+ )
44
+
45
+ _POWERSHELL_APPLICABLE = (
46
+ "blocking/pii_prevention_blocker.py",
47
+ "blocking/verified_commit_gate.py",
48
+ "blocking/verdict_directory_write_blocker.py",
49
+ )
50
+
51
+
52
+ def test_roster_lists_all_bash_hooks_in_registration_order() -> None:
53
+ """The roster reproduces the standalone Bash chain order exactly."""
54
+ actual_order = tuple(
55
+ each_entry.script_relative_path for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
56
+ )
57
+ assert actual_order == _EXPECTED_BASH_ORDER
58
+
59
+
60
+ def test_every_hook_applies_to_the_bash_tool() -> None:
61
+ """Every hosted hook runs on a Bash tool call."""
62
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES:
63
+ assert BASH_TOOL_NAME in each_entry.applicable_tool_names
64
+
65
+
66
+ def test_powershell_applicable_hooks_include_pii_and_verified_commit_pair() -> None:
67
+ """PowerShell runs the PII gate plus the verified-commit pair."""
68
+ powershell_hooks = tuple(
69
+ each_entry.script_relative_path
70
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
71
+ if POWERSHELL_TOOL_NAME in each_entry.applicable_tool_names
72
+ )
73
+ assert powershell_hooks == _POWERSHELL_APPLICABLE
74
+
75
+
76
+ def test_powershell_hooks_carry_the_shared_tool_set() -> None:
77
+ """The PowerShell-applicable hooks name both Bash and PowerShell."""
78
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES:
79
+ if each_entry.script_relative_path in _POWERSHELL_APPLICABLE:
80
+ assert each_entry.applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
81
+
82
+
83
+ def test_every_roster_path_points_at_an_existing_hook() -> None:
84
+ """Each roster path names a hook script present on disk, catching a typo."""
85
+ hooks_root = Path(__file__).resolve().parent.parent
86
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES:
87
+ assert (hooks_root / each_entry.script_relative_path).is_file()
@@ -13,9 +13,9 @@ from hooks_constants.hardcoded_user_path_constants import HARDCODED_USER_PATH_PA
13
13
 
14
14
 
15
15
  def test_pattern_matches_windows_user_home() -> None:
16
- match = HARDCODED_USER_PATH_PATTERN.search("C:/Users/jon/notes")
16
+ match = HARDCODED_USER_PATH_PATTERN.search("C:/Users/example/notes")
17
17
  assert match is not None
18
- assert match.group(0) == "C:/Users/jon"
18
+ assert match.group(0) == "C:/Users/example"
19
19
 
20
20
 
21
21
  def test_pattern_matches_macos_user_home() -> None: