claude-dev-env 7.0.0 → 7.1.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -1,30 +1,12 @@
1
- """Golden differential and behavior tests for the PostToolUse dispatcher.
2
-
3
- The golden differential test runs a payload through every hosted PostToolUse
4
- hook as its own subprocess (the production path), records each hook's block
5
- decision, computes the expected aggregate, then runs the dispatcher on the same
6
- payload and asserts an equal block-or-allow decision and the union of reasons.
7
-
8
- Two focused tests pin the side-effecting behavior the dispatcher must not
9
- change: the formatter formats only on a Write of a file git does not yet track
10
- and never blocks; and the type-checker still blocks on a real type error when
11
- run through the dispatcher. Two more tests pin that non-block stdout from a
12
- side-effect hook survives on both the allow and block paths.
13
-
14
- Crash and early-exit tests exercise the aggregator directly: an early hook
15
- crash before mypy does not drop mypy's block, a non-blocking hook crash leaves
16
- the decision allow, and a blocking hook crash surfaces a block.
17
- """
1
+ """Behavior tests for the nonblocking PostToolUse dispatcher roster."""
18
2
 
19
3
  from __future__ import annotations
20
4
 
21
- import importlib.util
22
5
  import json
23
6
  import os
24
7
  import subprocess
25
8
  import sys
26
9
  from pathlib import Path
27
- from types import ModuleType
28
10
 
29
11
  _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
30
12
  if _HOOKS_DIR not in sys.path:
@@ -56,34 +38,8 @@ _WRITE_TOOL_NAME = "Write"
56
38
  _EDIT_TOOL_NAME = "Edit"
57
39
 
58
40
 
59
- def _load_module(module_filename: str, module_directory: Path) -> ModuleType:
60
- """Load a hook module by file path so its module-level constants are readable.
61
-
62
- Args:
63
- module_filename: The module filename to load.
64
- module_directory: The directory holding the module.
65
-
66
- Returns:
67
- The loaded module object.
68
- """
69
- module_path = module_directory / module_filename
70
- spec = importlib.util.spec_from_file_location(module_path.stem, module_path)
71
- assert spec is not None and spec.loader is not None
72
- loaded_module = importlib.util.module_from_spec(spec)
73
- spec.loader.exec_module(loaded_module)
74
- return loaded_module
75
-
76
-
77
41
  def _resolve_extra_arguments(each_entry: PostHostedHookEntry) -> list[str]:
78
- """Resolve a hook entry's relative argument paths to absolute argv values.
79
-
80
- Args:
81
- each_entry: The hosted hook entry whose extra arguments to resolve.
82
-
83
- Returns:
84
- The resolved argument list. The plugin-root placeholder resolves to the
85
- plugin root absolute path; every other entry resolves relative to it.
86
- """
42
+ """Resolve a hook entry's relative arguments against the plugin root."""
87
43
  resolved_arguments: list[str] = []
88
44
  for each_relative_path in each_entry.extra_argument_relative_paths:
89
45
  if each_relative_path == PLUGIN_ROOT_PLACEHOLDER:
@@ -94,17 +50,10 @@ def _resolve_extra_arguments(each_entry: PostHostedHookEntry) -> list[str]:
94
50
 
95
51
 
96
52
  def _environment_without_git_hook_variables() -> dict[str, str]:
97
- """Return the process environment with git's hook-scoped variables removed.
98
-
99
- Git exports variables such as GIT_DIR and GIT_INDEX_FILE to hook
100
- processes. When this suite runs under a git hook (the pre-commit gate
101
- replays staged test files), those variables leak into the hook
102
- subprocesses and point their project discovery at the wrong checkout.
103
- Stripping them restores the cwd-based discovery the hooks use in
104
- production.
53
+ """Return the environment without variables Git adds to hook processes.
105
54
 
106
- Returns:
107
- A copy of the environment without any GIT_-prefixed variable.
55
+ Removing GIT_-prefixed variables keeps subprocess project discovery rooted in
56
+ the current checkout.
108
57
  """
109
58
  return {
110
59
  each_name: each_value
@@ -116,15 +65,7 @@ def _environment_without_git_hook_variables() -> dict[str, str]:
116
65
  def _run_hook_subprocess(
117
66
  each_entry: PostHostedHookEntry, payload_text: str
118
67
  ) -> subprocess.CompletedProcess[str]:
119
- """Run one hosted hook script as a subprocess, returning the completed process.
120
-
121
- Args:
122
- each_entry: The hosted hook entry naming the script and its arguments.
123
- payload_text: The JSON payload to send on stdin.
124
-
125
- Returns:
126
- The completed subprocess result with stdout and stderr captured.
127
- """
68
+ """Run one hosted hook subprocess and capture its streams."""
128
69
  script_path = str(_HOOKS_ROOT / each_entry.script_relative_path)
129
70
  command = [sys.executable, script_path, *_resolve_extra_arguments(each_entry)]
130
71
  return subprocess.run(
@@ -139,14 +80,7 @@ def _run_hook_subprocess(
139
80
 
140
81
 
141
82
  def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
142
- """Run the PostToolUse dispatcher as a subprocess.
143
-
144
- Args:
145
- payload_text: The JSON payload to send on stdin.
146
-
147
- Returns:
148
- The completed subprocess result with stdout and stderr captured.
149
- """
83
+ """Run the PostToolUse dispatcher and capture its streams."""
150
84
  return subprocess.run(
151
85
  [sys.executable, _DISPATCHER_SCRIPT, str(_PLUGIN_ROOT)],
152
86
  check=False,
@@ -159,16 +93,7 @@ def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
159
93
 
160
94
 
161
95
  def _parse_block_decision(completed_process: subprocess.CompletedProcess[str]) -> tuple[bool, str]:
162
- """Parse one hook's subprocess result into (is_block, reason_text).
163
-
164
- Args:
165
- completed_process: The completed subprocess from running a hook.
166
-
167
- Returns:
168
- A (is_block, reason_text) pair where is_block is True when the hook
169
- emitted a PostToolUse block decision, and reason_text carries the
170
- block reason.
171
- """
96
+ """Parse a hook's subprocess output into a block flag and reason."""
172
97
  stdout_text = completed_process.stdout.strip()
173
98
  if not stdout_text:
174
99
  return False, ""
@@ -184,15 +109,7 @@ def _parse_block_decision(completed_process: subprocess.CompletedProcess[str]) -
184
109
 
185
110
 
186
111
  def _compute_expected_aggregate(payload_text: str) -> tuple[bool, list[str]]:
187
- """Run each hosted hook individually and compute the expected aggregate.
188
-
189
- Args:
190
- payload_text: The JSON payload text to send to each hook.
191
-
192
- Returns:
193
- A (should_block, all_block_reasons) pair where should_block is True when
194
- any hook blocks, and all_block_reasons collects every blocking reason.
195
- """
112
+ """Run each hosted hook and return whether any blocked plus all reasons."""
196
113
  all_block_reasons: list[str] = []
197
114
  for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES:
198
115
  completed_process = _run_hook_subprocess(each_entry, payload_text)
@@ -203,15 +120,7 @@ def _compute_expected_aggregate(payload_text: str) -> tuple[bool, list[str]]:
203
120
 
204
121
 
205
122
  def _write_payload(file_path: str, content: str) -> str:
206
- """Build a Write tool payload JSON string.
207
-
208
- Args:
209
- file_path: The target file path.
210
- content: The file content written.
211
-
212
- Returns:
213
- JSON-encoded payload string.
214
- """
123
+ """Build a JSON payload for a Write tool call."""
215
124
  return json.dumps(
216
125
  {
217
126
  "tool_name": _WRITE_TOOL_NAME,
@@ -221,16 +130,7 @@ def _write_payload(file_path: str, content: str) -> str:
221
130
 
222
131
 
223
132
  def _edit_payload(file_path: str, old_string: str, new_string: str) -> str:
224
- """Build an Edit tool payload JSON string.
225
-
226
- Args:
227
- file_path: The target file path.
228
- old_string: The text replaced.
229
- new_string: The replacement text.
230
-
231
- Returns:
232
- JSON-encoded payload string.
233
- """
133
+ """Build a JSON payload for an Edit tool call."""
234
134
  return json.dumps(
235
135
  {
236
136
  "tool_name": _EDIT_TOOL_NAME,
@@ -269,60 +169,6 @@ def _assert_dispatcher_matches_individual_hooks(payload_text: str) -> None:
269
169
  )
270
170
 
271
171
 
272
- def _post_tool_use_dispatcher_harness_timeout_seconds() -> int:
273
- """Return the PostToolUse dispatcher's harness timeout from hooks.json.
274
-
275
- Returns:
276
- The timeout the Write|Edit PostToolUse entry declares for the dispatcher.
277
- """
278
- hooks_json_path = _HOOKS_ROOT / "hooks.json"
279
- parsed_hooks = json.loads(hooks_json_path.read_text(encoding="utf-8"))
280
- for each_entry in parsed_hooks["hooks"]["PostToolUse"]:
281
- for each_hook in each_entry["hooks"]:
282
- if "post_tool_use_dispatcher.py" in each_hook["command"]:
283
- return int(each_hook["timeout"])
284
- raise AssertionError("PostToolUse dispatcher entry not found in hooks.json")
285
-
286
-
287
- def _hosted_hooks_worst_case_internal_seconds() -> int:
288
- """Return the summed worst-case internal subprocess budget of the hosted hooks.
289
-
290
- The hosted hooks run sequentially in one dispatcher process. The
291
- type-checker and the formatter (its slower JS path) each carry their own
292
- internal subprocess timeout; their sum is the dispatcher process's
293
- worst-case runtime when each hook runs to its own ceiling.
294
-
295
- Returns:
296
- The summed worst-case internal subprocess seconds across the hosted hooks.
297
- """
298
- mypy_validator = _load_module("mypy_validator.py", _VALIDATION_DIR)
299
- auto_formatter = _load_module("auto_formatter.py", _HOOKS_ROOT / "workflow")
300
- slowest_formatter_seconds = max(
301
- auto_formatter.PYTHON_FORMAT_TIMEOUT_SECONDS,
302
- auto_formatter.JS_FORMAT_TIMEOUT_SECONDS,
303
- )
304
- return mypy_validator.MYPY_TIMEOUT_SECONDS + slowest_formatter_seconds
305
-
306
-
307
- def test_dispatcher_harness_timeout_clears_summed_hosted_hook_budgets() -> None:
308
- """The dispatcher harness timeout exceeds the summed worst-case hosted-hook budget.
309
-
310
- The hosted hooks run sequentially under one harness timeout. When the
311
- harness timeout does not clear their summed worst-case internal budgets, a
312
- near-full slow type-check run consumes the budget and the harness kills the
313
- dispatcher before the formatter gets its turn. The harness timeout must
314
- exceed the sum so every hosted hook runs to completion.
315
- """
316
- harness_timeout_seconds = _post_tool_use_dispatcher_harness_timeout_seconds()
317
- summed_internal_seconds = _hosted_hooks_worst_case_internal_seconds()
318
- assert harness_timeout_seconds > summed_internal_seconds, (
319
- "PostToolUse dispatcher harness timeout "
320
- f"({harness_timeout_seconds}s) must exceed the summed worst-case internal "
321
- f"budgets of the hosted hooks ({summed_internal_seconds}s), or a slow "
322
- "type-check run starves the formatter."
323
- )
324
-
325
-
326
172
  def test_clean_edit_of_plain_text_allows() -> None:
327
173
  """Dispatcher allows an Edit of a non-Python plain-text file (no hook blocks)."""
328
174
  plain_text_path = str(_VALIDATION_DIR / "CLAUDE.md")
@@ -331,7 +177,7 @@ def test_clean_edit_of_plain_text_allows() -> None:
331
177
 
332
178
 
333
179
  def test_clean_write_of_nonexistent_path_allows() -> None:
334
- """Dispatcher allows a Write whose path does not exist (mypy skips)."""
180
+ """Dispatcher allows a Write whose path does not exist (blocking_hook skips)."""
335
181
  missing_path = str(_VALIDATION_DIR / "does_not_exist_dispatcher_probe.txt")
336
182
  payload_text = _write_payload(missing_path, "hello world\n")
337
183
  _assert_dispatcher_matches_individual_hooks(payload_text)
@@ -364,56 +210,8 @@ def test_empty_payload_allows_fail_open() -> None:
364
210
  )
365
211
 
366
212
 
367
- def test_type_checker_still_blocks_on_type_error_through_dispatcher() -> None:
368
- """The type-checker's block on a real type error survives through the dispatcher.
369
-
370
- Writes a Python file with a genuine type error inside this repository so
371
- mypy_validator discovers the project root and blocks, then runs the same
372
- payload through the dispatcher and asserts the dispatcher emits the block.
373
- """
374
- type_error_file = _VALIDATION_DIR / "dispatcher_type_error_probe.py"
375
- type_error_file.write_text(
376
- "def add_one(value: int) -> int:\n return value + 1\n\n\nadd_one('not an int')\n",
377
- encoding="utf-8",
378
- )
379
- try:
380
- payload_text = _write_payload(str(type_error_file), type_error_file.read_text())
381
- direct_block, direct_reason = _parse_block_decision(
382
- _run_hook_subprocess(ALL_POST_HOSTED_HOOK_ENTRIES[0], payload_text)
383
- )
384
- assert direct_block, (
385
- "Precondition failed: mypy_validator did not block a real type error "
386
- f"directly. Reason: {direct_reason!r}. Is mypy installed and the file "
387
- "inside the git project?"
388
- )
389
- dispatcher_result = _run_dispatcher(payload_text)
390
- dispatcher_is_block, dispatcher_reason = _parse_block_decision(dispatcher_result)
391
- assert dispatcher_is_block, (
392
- "Dispatcher must block when the type-checker blocks on a type error. "
393
- f"Dispatcher stdout: {dispatcher_result.stdout!r}"
394
- )
395
- assert direct_reason in dispatcher_reason, (
396
- "Dispatcher block reason must carry the type-checker's reason.\n"
397
- f"Expected: {direct_reason!r}\n"
398
- f"Dispatcher reason: {dispatcher_reason!r}"
399
- )
400
- finally:
401
- type_error_file.unlink(missing_ok=True)
402
-
403
-
404
213
  def test_formatter_formats_only_untracked_write_and_never_blocks(tmp_path: Path) -> None:
405
- """The formatter acts only on an untracked-file Write and never blocks.
406
-
407
- Writes an unformatted Python file into a git repo at tmp_path so the file is
408
- untracked, runs a Write payload through the dispatcher, and asserts the file
409
- is formatted on disk and the dispatcher does not block. Then runs an Edit
410
- payload for the same path and asserts the formatter leaves an unformatted
411
- file untouched, proving the Write-untracked gate still holds through the
412
- dispatcher.
413
-
414
- Args:
415
- tmp_path: Pytest temp directory hosting the throwaway git repository.
416
- """
214
+ """Verify formatting applies to untracked Write payloads but not Edit payloads."""
417
215
  subprocess.run(
418
216
  ["git", "init", str(tmp_path)],
419
217
  check=True,
@@ -447,13 +245,7 @@ def test_formatter_formats_only_untracked_write_and_never_blocks(tmp_path: Path)
447
245
 
448
246
 
449
247
  def test_non_block_stdout_preserved_in_aggregator_allow_path() -> None:
450
- """Aggregator preserves non-block hook stdout on the allow path.
451
-
452
- A side-effect hook writes informational text to stdout without emitting a
453
- block decision. The aggregator must carry that text into
454
- all_non_block_stdout so the dispatcher can write it to the real stdout on
455
- the allow path.
456
- """
248
+ """Preserve informational stdout when the aggregate allows."""
457
249
  informational_text = "https://htmlpreview.github.io/?https://gist.github.com/abc/123"
458
250
  all_results = [
459
251
  PostHostedHookResult(captured_stdout="", did_crash=False, is_blocking=True),
@@ -473,23 +265,18 @@ def test_non_block_stdout_preserved_in_aggregator_allow_path() -> None:
473
265
 
474
266
 
475
267
  def test_non_block_stdout_preserved_in_aggregator_block_path() -> None:
476
- """Aggregator preserves non-block hook stdout even when another hook blocks.
477
-
478
- When mypy_validator blocks and a side-effect hook wrote informational
479
- text, both the block reason and the informational text survive in the
480
- aggregated decision so _emit_block_decision can forward both to stdout.
481
- """
482
- mypy_block_json = json.dumps({"decision": BLOCK_DECISION, "reason": "[MYPY] Type errors: x"})
268
+ """Preserve informational stdout when another hook blocks."""
269
+ blocking_hook_block_json = json.dumps({"decision": BLOCK_DECISION, "reason": "[BLOCKING_HOOK] Type errors: x"})
483
270
  informational_text = "https://htmlpreview.github.io/?https://gist.github.com/abc/456"
484
271
  all_results = [
485
- PostHostedHookResult(captured_stdout=mypy_block_json, did_crash=False, is_blocking=True),
272
+ PostHostedHookResult(captured_stdout=blocking_hook_block_json, did_crash=False, is_blocking=True),
486
273
  PostHostedHookResult(
487
274
  captured_stdout=informational_text, did_crash=False, is_blocking=False
488
275
  ),
489
276
  ]
490
277
  aggregated_decision = aggregate_post_hosted_hook_results(all_results)
491
278
  assert aggregated_decision.should_block, (
492
- "Aggregator must block when mypy_validator blocks"
279
+ "Aggregator must block when blocking_hook_validator blocks"
493
280
  )
494
281
  assert informational_text in aggregated_decision.all_non_block_stdout, (
495
282
  "Aggregator must preserve non-block hook stdout even on the block path.\n"
@@ -499,39 +286,29 @@ def test_non_block_stdout_preserved_in_aggregator_block_path() -> None:
499
286
 
500
287
 
501
288
  def test_early_hook_crash_does_not_drop_later_blocking_hook_block() -> None:
502
- """A crash in an early hook does not prevent a later blocking hook's block.
503
-
504
- Simulates a scenario where a non-blocking hook crashes before mypy_validator
505
- runs and blocks. The aggregated decision must still block, proving the
506
- dispatcher continues past a crash to collect all results.
507
- """
508
- mypy_block_json = json.dumps(
509
- {"decision": BLOCK_DECISION, "reason": "[MYPY] Type errors: y"}
289
+ """Keep a later blocking hook's decision after an earlier hook crashes."""
290
+ blocking_hook_block_json = json.dumps(
291
+ {"decision": BLOCK_DECISION, "reason": "[BLOCKING_HOOK] Type errors: y"}
510
292
  )
511
293
  all_results = [
512
294
  PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=False),
513
295
  PostHostedHookResult(
514
- captured_stdout=mypy_block_json, did_crash=False, is_blocking=True
296
+ captured_stdout=blocking_hook_block_json, did_crash=False, is_blocking=True
515
297
  ),
516
298
  ]
517
299
  aggregated_decision = aggregate_post_hosted_hook_results(all_results)
518
300
  assert aggregated_decision.should_block, (
519
- "An early non-blocking hook crash must not prevent mypy_validator's block "
301
+ "An early non-blocking hook crash must not prevent blocking_hook_validator's block "
520
302
  "from reaching the aggregated decision"
521
303
  )
522
- assert any("[MYPY]" in each_reason for each_reason in aggregated_decision.all_block_reasons), (
523
- "The mypy block reason must survive in the aggregated decision.\n"
304
+ assert any("[BLOCKING_HOOK]" in each_reason for each_reason in aggregated_decision.all_block_reasons), (
305
+ "The blocking_hook block reason must survive in the aggregated decision.\n"
524
306
  f"Block reasons: {aggregated_decision.all_block_reasons!r}"
525
307
  )
526
308
 
527
309
 
528
310
  def test_non_blocking_hook_crash_leaves_decision_allow() -> None:
529
- """A crash in a non-blocking hook does not change an allow to a block.
530
-
531
- A side-effect hook such as auto_formatter carries is_blocking=False. Its
532
- crash must not surface a blocking signal — the aggregated decision stays
533
- allow.
534
- """
311
+ """Keep the aggregate allowed after a nonblocking hook crashes."""
535
312
  all_results = [
536
313
  PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=False),
537
314
  ]
@@ -542,12 +319,7 @@ def test_non_blocking_hook_crash_leaves_decision_allow() -> None:
542
319
 
543
320
 
544
321
  def test_empty_reason_block_still_blocks_with_fallback_reason() -> None:
545
- """A block decision carrying an empty reason still blocks with a fallback reason.
546
-
547
- A blocking hook that emits decision=block with an empty reason string must
548
- still block. The aggregator substitutes EMPTY_REASON_BLOCK_FALLBACK so the
549
- block is not silently downgraded to allow.
550
- """
322
+ """Use a fallback reason when a block decision has no reason."""
551
323
  empty_reason_block_json = json.dumps({"decision": BLOCK_DECISION, "reason": ""})
552
324
  all_results = [
553
325
  PostHostedHookResult(
@@ -567,50 +339,8 @@ def test_empty_reason_block_still_blocks_with_fallback_reason() -> None:
567
339
  )
568
340
 
569
341
 
570
- def test_block_path_emits_single_parseable_json_object_on_stdout() -> None:
571
- """On the block path the dispatcher emits a single parseable JSON object on stdout.
572
-
573
- Writes a Python file with a real type error inside this repository so
574
- mypy_validator discovers the project root and blocks, then asserts the
575
- dispatcher's whole stdout parses as one JSON block object — no leading
576
- informational text mixed onto the same stream.
577
- """
578
- type_error_file = _VALIDATION_DIR / "dispatcher_block_stdout_probe.py"
579
- type_error_file.write_text(
580
- "def add_one(value: int) -> int:\n return value + 1\n\n\nadd_one('not an int')\n",
581
- encoding="utf-8",
582
- )
583
- try:
584
- payload_text = _write_payload(str(type_error_file), type_error_file.read_text())
585
- completed_direct = _run_hook_subprocess(ALL_POST_HOSTED_HOOK_ENTRIES[0], payload_text)
586
- direct_block, _direct_reason = _parse_block_decision(completed_direct)
587
- assert direct_block, (
588
- "Precondition failed: mypy_validator did not block a real type error directly. "
589
- "Is mypy installed and the file inside the git project?\n"
590
- f"stdout: {completed_direct.stdout!r}\nstderr: {completed_direct.stderr!r}"
591
- )
592
-
593
- dispatcher_result = _run_dispatcher(payload_text)
594
- parsed_stdout = json.loads(dispatcher_result.stdout.strip())
595
- assert isinstance(parsed_stdout, dict), (
596
- "Dispatcher stdout on the block path must be a single JSON object.\n"
597
- f"Got: {dispatcher_result.stdout!r}"
598
- )
599
- assert parsed_stdout.get("decision") == BLOCK_DECISION, (
600
- "The single JSON object on stdout must carry the block decision.\n"
601
- f"Got: {parsed_stdout!r}"
602
- )
603
- finally:
604
- type_error_file.unlink(missing_ok=True)
605
-
606
-
607
342
  def test_blocking_hook_crash_surfaces_a_block() -> None:
608
- """A crash in a blocking hook surfaces a block with a crash reason.
609
-
610
- When a blocking hook (such as mypy_validator) crashes before emitting any
611
- output, the aggregator must still block so a bad write does not silently
612
- pass. The block reason must reference the dispatcher's crash signal.
613
- """
343
+ """Surface a block with a crash reason when a blocking hook crashes."""
614
344
  all_results = [
615
345
  PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=True),
616
346
  ]
@@ -630,3 +360,10 @@ def test_blocking_hook_crash_surfaces_a_block() -> None:
630
360
  "BLOCKING_CRASH_DENY_REASON constant.\n"
631
361
  f"Got: {aggregated_decision.all_block_reasons!r}"
632
362
  )
363
+
364
+ def test_live_roster_contains_only_nonblocking_hooks() -> None:
365
+ assert ALL_POST_HOSTED_HOOK_ENTRIES
366
+ assert all(not each_entry.is_blocking for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES)
367
+ assert tuple(each_entry.script_relative_path for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES) == (
368
+ "workflow/auto_formatter.py",
369
+ )
@@ -914,7 +914,11 @@ def validate_proposed_file(
914
914
  temporary_file = _temporary_path_preserving_directory_signal(
915
915
  Path(temporary_directory), file_path
916
916
  )
917
- temporary_file.write_text(proposed_content, encoding="utf-8")
917
+ temporary_file.write_text(
918
+ proposed_content,
919
+ encoding="utf-8",
920
+ newline="",
921
+ )
918
922
  resolved_config_source_path = (
919
923
  config_source_path
920
924
  if config_source_path is not None
@@ -26,7 +26,58 @@ SECRET_PATTERNS: frozenset[str] = frozenset({
26
26
  SQL_EXECUTE_PATTERN = re.compile(r"\.execute\s*\(\s*f['\"]|\.execute\s*\([^)]*\.format\(", re.IGNORECASE)
27
27
 
28
28
 
29
+ def _normalize_metadata_name(metadata_text: str) -> str:
30
+ return "".join(
31
+ each_character for each_character in metadata_text.lower() if each_character.isalnum()
32
+ )
33
+
34
+
35
+ def _is_schema_key_metadata(variable_name: str, literal_text: str) -> bool:
36
+ return variable_name == "token_key" and _normalize_metadata_name(literal_text) == "token"
37
+
38
+
39
+ def _is_header_metadata(variable_name: str, literal_text: str) -> bool:
40
+ if not variable_name.endswith("_header"):
41
+ return False
42
+ header_name = variable_name.removesuffix("_header")
43
+ return _normalize_metadata_name(literal_text) == _normalize_metadata_name(header_name)
44
+
45
+
46
+ def _is_endpoint_template_metadata(variable_name: str, literal_text: str) -> bool:
47
+ if not variable_name.endswith("_endpoint_template"):
48
+ return False
49
+ endpoint_template_pattern = re.compile(r"^/[A-Za-z0-9_{}./-]+$")
50
+ if endpoint_template_pattern.fullmatch(literal_text) is None or "{" not in literal_text:
51
+ return False
52
+ endpoint_name = variable_name.removesuffix("_endpoint_template")
53
+ normalized_endpoint = _normalize_metadata_name(literal_text)
54
+ return all(each_word in normalized_endpoint for each_word in endpoint_name.split("_"))
55
+
56
+
57
+ def _is_resource_name_metadata(variable_name: str, literal_text: str) -> bool:
58
+ if not variable_name.endswith("_resource_name"):
59
+ return False
60
+ all_label_words = literal_text.lower().split()
61
+ resource_name = variable_name.removesuffix("_resource_name")
62
+ return (
63
+ len(all_label_words) > 1
64
+ and all(each_word.isalpha() for each_word in all_label_words)
65
+ and _normalize_metadata_name(all_label_words[-1]) == _normalize_metadata_name(resource_name)
66
+ )
67
+
68
+
69
+ def _is_secret_metadata(variable_name: str, literal_text: str) -> bool:
70
+ if _is_schema_key_metadata(variable_name, literal_text):
71
+ return True
72
+ if _is_header_metadata(variable_name, literal_text):
73
+ return True
74
+ if _is_endpoint_template_metadata(variable_name, literal_text):
75
+ return True
76
+ return _is_resource_name_metadata(variable_name, literal_text)
77
+
78
+
29
79
  def check_hardcoded_secrets(tree: ast.AST, filename: str) -> List[Violation]:
80
+ MINIMUM_SECRET_LENGTH = 3
30
81
  violations: List[Violation] = []
31
82
 
32
83
  for node in ast.walk(tree):
@@ -35,8 +86,12 @@ def check_hardcoded_secrets(tree: ast.AST, filename: str) -> List[Violation]:
35
86
  if isinstance(target, ast.Name):
36
87
  var_name = target.id.lower()
37
88
  if any(pattern in var_name for pattern in SECRET_PATTERNS):
38
- if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
39
- if len(node.value.value) > 3:
89
+ if isinstance(node.value, ast.Constant) and isinstance(
90
+ node.value.value, str
91
+ ):
92
+ if len(node.value.value) > MINIMUM_SECRET_LENGTH and not _is_secret_metadata(
93
+ var_name, node.value.value
94
+ ):
40
95
  violations.append(
41
96
  Violation(
42
97
  filename,
@@ -51,7 +51,6 @@ def test_write_edit_hook_commands_reads_the_real_hooks_json_roster() -> None:
51
51
  all_commands = hook_timing_harness.write_edit_hook_commands(_HOOKS_JSON_PATH)
52
52
  all_labels = [each_label for each_label, _each_command in all_commands]
53
53
  assert all_labels == [
54
- "run_all_validators",
55
54
  "pre_tool_use_dispatcher",
56
55
  "post_tool_use_dispatcher",
57
56
  "session_file_edit_tracker",
@@ -169,17 +168,15 @@ def test_run_hosted_command_once_milliseconds_drives_the_real_tracker() -> None:
169
168
 
170
169
 
171
170
  def test_measure_hosted_command_wall_times_covers_the_real_roster() -> None:
172
- """A live run against the real hooks.json roster returns all four hooks.
171
+ """A live run against the real hooks.json roster returns all three hooks.
173
172
 
174
173
  Runs each registered command once against a real repository file,
175
- including ``run_all_validators``, so this is the one place the suite pays
176
- its full wall time to prove the roster end to end.
174
+ so this is the one place the suite pays its full wall time to prove the roster end to end.
177
175
  """
178
176
  all_wall_times_by_label = hook_timing_harness.measure_hosted_command_wall_times(
179
177
  _HOOKS_JSON_PATH, _PACKAGE_ROOT, _REAL_TARGET_FILE, 1
180
178
  )
181
179
  assert set(all_wall_times_by_label) == {
182
- "run_all_validators",
183
180
  "pre_tool_use_dispatcher",
184
181
  "post_tool_use_dispatcher",
185
182
  "session_file_edit_tracker",
@@ -12,17 +12,12 @@ from pathlib import Path
12
12
 
13
13
  import pytest
14
14
 
15
- from hooks_constants.pre_tool_use_dispatcher_constants import (
16
- ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
17
- )
18
-
19
15
  from .run_all_validators import run_validators_entrypoint_subprocess
20
16
 
21
17
  pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
22
18
 
23
19
  HOOKS_JSON_PATH = Path(__file__).resolve().parents[1] / "hooks.json"
24
20
  VALIDATORS_RUNNER_COMMAND_MARKER = "validators.run_all_validators"
25
- MATCHER_TOOL_NAME_SEPARATOR = "|"
26
21
  DENY_DECISION_FRAGMENT = '"permissionDecision": "deny"'
27
22
  MAGIC_VALUE_VALIDATOR_NAME = "Magic Values"
28
23
 
@@ -91,7 +86,7 @@ def write_clean_module(target_directory: Path, module_name: str) -> Path:
91
86
 
92
87
 
93
88
  class TestMutationToolRegistration:
94
- def test_validators_gate_is_registered_on_every_mutation_tool(self) -> None:
89
+ def test_validators_gate_has_no_live_mutation_registration(self) -> None:
95
90
  hooks_document = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
96
91
  all_matchers = [
97
92
  each_group["matcher"]
@@ -99,11 +94,8 @@ class TestMutationToolRegistration:
99
94
  for each_hook in each_group.get("hooks", [])
100
95
  if VALIDATORS_RUNNER_COMMAND_MARKER in each_hook.get("command", "")
101
96
  ]
102
- assert len(all_matchers) == 1, all_matchers
103
- assert (
104
- set(all_matchers[0].split(MATCHER_TOOL_NAME_SEPARATOR))
105
- == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
106
- )
97
+ assert all_matchers == [], all_matchers
98
+
107
99
 
108
100
 
109
101
  class TestMultiEditPayloadLane: