claude-dev-env 1.71.0 → 1.73.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.
- package/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +676 -0
- package/hooks/blocking/code_rules_enforcer.py +26 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +75 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +123 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +203 -8
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse dispatcher that hosts Write, Edit, and MultiEdit blocking hooks.
|
|
3
|
+
|
|
4
|
+
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
5
|
+
the payload's tool name, runs each hook in-process via runpy in the fixed order
|
|
6
|
+
declared in the constants module, aggregates the results, and emits one deny
|
|
7
|
+
decision when any hook denied (carrying every denying reason) or exits zero to
|
|
8
|
+
allow.
|
|
9
|
+
|
|
10
|
+
The per-hook coverage matrix:
|
|
11
|
+
- Write -> Group A (10 hooks) + Group B (5 hooks) = 15 hooks
|
|
12
|
+
- Edit -> Group A (10 hooks) + Group B (5 hooks) = 15 hooks
|
|
13
|
+
- MultiEdit -> Group B only (5 hooks)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import runpy
|
|
21
|
+
import sys
|
|
22
|
+
import traceback
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
28
|
+
if _hooks_directory not in sys.path:
|
|
29
|
+
sys.path.insert(0, _hooks_directory)
|
|
30
|
+
|
|
31
|
+
from plain_language_blocker import ( # noqa: E402
|
|
32
|
+
build_deny_payload as build_plain_language_deny_payload,
|
|
33
|
+
)
|
|
34
|
+
from plain_language_blocker import evaluate as evaluate_plain_language # noqa: E402
|
|
35
|
+
from state_description_blocker import ( # noqa: E402
|
|
36
|
+
build_deny_payload as build_state_description_deny_payload,
|
|
37
|
+
)
|
|
38
|
+
from state_description_blocker import evaluate as evaluate_state_description # noqa: E402
|
|
39
|
+
|
|
40
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
41
|
+
ALL_HOSTED_HOOK_ENTRIES,
|
|
42
|
+
ALLOW_DECISION,
|
|
43
|
+
BLOCKING_CRASH_EXIT_CODE,
|
|
44
|
+
DENY_DECISION,
|
|
45
|
+
EXIT_CODE_TWO_DENY_REASON,
|
|
46
|
+
HOOK_EVENT_NAME,
|
|
47
|
+
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
|
|
48
|
+
STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
49
|
+
HostedHookEntry,
|
|
50
|
+
)
|
|
51
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
52
|
+
|
|
53
|
+
NativeEvaluator = Callable[[dict[str, object]], str | None]
|
|
54
|
+
DenyPayloadBuilder = Callable[[str], dict[str, object]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class NativeHook:
|
|
59
|
+
"""A nativized hook's evaluator paired with its full deny-payload builder.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
evaluate: The hook's evaluate function returning a deny-reason or None.
|
|
63
|
+
build_deny_payload: The hook's builder that turns a deny-reason into the
|
|
64
|
+
full deny payload the standalone hook writes (carrying systemMessage,
|
|
65
|
+
additionalContext, and suppressOutput).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
evaluate: NativeEvaluator
|
|
69
|
+
build_deny_payload: DenyPayloadBuilder
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class HostedHookResult:
|
|
74
|
+
"""Outcome of running one hosted hook inside the dispatcher process.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
exit_code: The exit code the hook raised via SystemExit, or 0 when the
|
|
78
|
+
hook returned without raising.
|
|
79
|
+
captured_stdout: The text the hook wrote to stdout during its run.
|
|
80
|
+
did_crash: True when the hook raised a non-SystemExit exception.
|
|
81
|
+
is_blocking: True when this hook's crash surfaces a blocking signal.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
exit_code: int
|
|
85
|
+
captured_stdout: str
|
|
86
|
+
did_crash: bool = field(default=False)
|
|
87
|
+
is_blocking: bool = field(default=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _log_hook_crash(hook_script_path: str, error: Exception) -> None:
|
|
91
|
+
"""Write a one-line crash summary to stderr.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
hook_script_path: The absolute path of the hook that crashed.
|
|
95
|
+
error: The exception the hook raised.
|
|
96
|
+
"""
|
|
97
|
+
formatted_traceback = traceback.format_exc().strip()
|
|
98
|
+
last_line = formatted_traceback.splitlines()[-1] if formatted_traceback else str(error)
|
|
99
|
+
error_type_name = type(error).__name__
|
|
100
|
+
sys.stderr.write(
|
|
101
|
+
f"[dispatcher] crash in {hook_script_path}: {error_type_name}: {error} | {last_line}\n"
|
|
102
|
+
)
|
|
103
|
+
sys.stderr.flush()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_hosted_hook(
|
|
107
|
+
hook_script_path: str,
|
|
108
|
+
payload_text: str,
|
|
109
|
+
is_blocking: bool,
|
|
110
|
+
) -> HostedHookResult:
|
|
111
|
+
"""Run one hosted hook in-process and return its outcome.
|
|
112
|
+
|
|
113
|
+
Sets stdin to a fresh stream over payload_text, sets argv to the hook's own
|
|
114
|
+
script path so a hook that branches on sys.argv (such as code_rules_enforcer's
|
|
115
|
+
--check pre-check mode) reads the same argv it would standalone rather than
|
|
116
|
+
the dispatcher's, captures stdout into a buffer, runs the hook via runpy
|
|
117
|
+
under __main__, catches SystemExit to read the exit code without ending the
|
|
118
|
+
dispatcher, and catches a non-SystemExit exception to log the crash and
|
|
119
|
+
classify it. Always restores stdin, stdout, and argv in the finally block.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
hook_script_path: Absolute path of the hook script to run.
|
|
123
|
+
payload_text: The raw payload text to replay as the hook's stdin.
|
|
124
|
+
is_blocking: Whether a crash from this hook surfaces a blocking signal.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A HostedHookResult carrying the exit code, captured stdout, crash flag,
|
|
128
|
+
and blocking classification.
|
|
129
|
+
"""
|
|
130
|
+
original_stdin = sys.stdin
|
|
131
|
+
original_stdout = sys.stdout
|
|
132
|
+
original_argv = sys.argv
|
|
133
|
+
captured_output = io.StringIO()
|
|
134
|
+
hook_exit_code = 0
|
|
135
|
+
hook_did_crash = False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
sys.stdin = io.StringIO(payload_text)
|
|
139
|
+
sys.stdout = captured_output
|
|
140
|
+
sys.argv = [hook_script_path]
|
|
141
|
+
runpy.run_path(hook_script_path, run_name="__main__")
|
|
142
|
+
except SystemExit as exit_signal:
|
|
143
|
+
raw_code = exit_signal.code
|
|
144
|
+
hook_exit_code = raw_code if isinstance(raw_code, int) else 0
|
|
145
|
+
except Exception as error:
|
|
146
|
+
_log_hook_crash(hook_script_path, error)
|
|
147
|
+
hook_did_crash = True
|
|
148
|
+
hook_exit_code = BLOCKING_CRASH_EXIT_CODE if is_blocking else 0
|
|
149
|
+
finally:
|
|
150
|
+
sys.stdin = original_stdin
|
|
151
|
+
sys.stdout = original_stdout
|
|
152
|
+
sys.argv = original_argv
|
|
153
|
+
|
|
154
|
+
return HostedHookResult(
|
|
155
|
+
exit_code=hook_exit_code,
|
|
156
|
+
captured_stdout=captured_output.getvalue(),
|
|
157
|
+
did_crash=hook_did_crash,
|
|
158
|
+
is_blocking=is_blocking,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_native_hook(
|
|
163
|
+
native_hook: NativeHook,
|
|
164
|
+
payload_by_key: dict[str, object],
|
|
165
|
+
is_blocking: bool,
|
|
166
|
+
) -> HostedHookResult:
|
|
167
|
+
"""Run one hosted hook's native evaluator in-process and return its outcome.
|
|
168
|
+
|
|
169
|
+
Calls the evaluator directly with the payload dict, builds the full deny JSON
|
|
170
|
+
via the hook's own deny-payload builder so the captured stdout matches the
|
|
171
|
+
standalone hook's deny shape (carrying systemMessage, additionalContext, and
|
|
172
|
+
suppressOutput), and catches a non-SystemExit crash to log and classify it.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
native_hook: The hook's evaluator paired with its deny-payload builder.
|
|
176
|
+
payload_by_key: The parsed payload dict to pass to the evaluator.
|
|
177
|
+
is_blocking: Whether a crash from this hook surfaces a blocking signal.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
A HostedHookResult carrying the captured deny JSON (empty when allowed),
|
|
181
|
+
the crash flag, and the blocking classification.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
deny_reason = native_hook.evaluate(payload_by_key)
|
|
185
|
+
except Exception as error:
|
|
186
|
+
_log_hook_crash(native_hook.evaluate.__module__, error)
|
|
187
|
+
return HostedHookResult(
|
|
188
|
+
exit_code=BLOCKING_CRASH_EXIT_CODE if is_blocking else 0,
|
|
189
|
+
captured_stdout="",
|
|
190
|
+
did_crash=True,
|
|
191
|
+
is_blocking=is_blocking,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
captured_stdout = (
|
|
195
|
+
json.dumps(native_hook.build_deny_payload(deny_reason)) if deny_reason is not None else ""
|
|
196
|
+
)
|
|
197
|
+
return HostedHookResult(
|
|
198
|
+
exit_code=0,
|
|
199
|
+
captured_stdout=captured_stdout,
|
|
200
|
+
did_crash=False,
|
|
201
|
+
is_blocking=is_blocking,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class ParsedHookOutput:
|
|
207
|
+
"""The fields parsed from one hook's stdout.
|
|
208
|
+
|
|
209
|
+
Attributes:
|
|
210
|
+
is_deny: True when the hook output carries a permissionDecision of deny.
|
|
211
|
+
is_allow: True when the hook output carries a permissionDecision of allow.
|
|
212
|
+
deny_reason: The permissionDecisionReason text when is_deny is True.
|
|
213
|
+
system_message: The hook's top-level systemMessage, or non-JSON stdout
|
|
214
|
+
text when the output is not a deny-shaped JSON object.
|
|
215
|
+
additional_context: The hook's hookSpecificOutput.additionalContext text.
|
|
216
|
+
suppress_output: True when the hook set a top-level suppressOutput flag.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
is_deny: bool
|
|
220
|
+
is_allow: bool
|
|
221
|
+
deny_reason: str
|
|
222
|
+
system_message: str
|
|
223
|
+
additional_context: str
|
|
224
|
+
suppress_output: bool
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _empty_parsed_hook_output(system_message: str) -> ParsedHookOutput:
|
|
228
|
+
"""Build a non-deciding ParsedHookOutput carrying only a system message.
|
|
229
|
+
|
|
230
|
+
Used for stdout that is empty or not deny-shaped JSON, where the only field
|
|
231
|
+
worth keeping is the raw stdout text surfaced as the system message.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
system_message: The raw stdout text to surface as the system message.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A ParsedHookOutput that neither denies nor allows.
|
|
238
|
+
"""
|
|
239
|
+
return ParsedHookOutput(
|
|
240
|
+
is_deny=False,
|
|
241
|
+
is_allow=False,
|
|
242
|
+
deny_reason="",
|
|
243
|
+
system_message=system_message,
|
|
244
|
+
additional_context="",
|
|
245
|
+
suppress_output=False,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_deny_from_hook_output(hook_output_text: str) -> ParsedHookOutput:
|
|
250
|
+
"""Parse one hook's stdout for its permission decision and user-facing fields.
|
|
251
|
+
|
|
252
|
+
Captures the deny signal and the explicit allow signal, plus every
|
|
253
|
+
supplementary field a hook emits on a deny — the top-level systemMessage and
|
|
254
|
+
suppressOutput, and the hookSpecificOutput.additionalContext — so the
|
|
255
|
+
dispatcher reproduces the standalone hook's full deny shape and re-emits an
|
|
256
|
+
explicit allow when a hook auto-approves the write.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
hook_output_text: The text the hook wrote to stdout.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A ParsedHookOutput carrying the deny signal, the allow signal, deny
|
|
263
|
+
reason, systemMessage, additionalContext, and suppressOutput flag. When
|
|
264
|
+
the output is not deny-shaped JSON, system_message carries the raw stdout
|
|
265
|
+
text.
|
|
266
|
+
"""
|
|
267
|
+
stripped_text = hook_output_text.strip()
|
|
268
|
+
if not stripped_text:
|
|
269
|
+
return _empty_parsed_hook_output("")
|
|
270
|
+
try:
|
|
271
|
+
parsed_output = json.loads(stripped_text)
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
return _empty_parsed_hook_output(stripped_text)
|
|
274
|
+
if not isinstance(parsed_output, dict):
|
|
275
|
+
return _empty_parsed_hook_output(stripped_text)
|
|
276
|
+
hook_specific = parsed_output.get("hookSpecificOutput", {})
|
|
277
|
+
if not isinstance(hook_specific, dict):
|
|
278
|
+
return _empty_parsed_hook_output(stripped_text)
|
|
279
|
+
permission_decision = hook_specific.get("permissionDecision")
|
|
280
|
+
is_deny = permission_decision == DENY_DECISION
|
|
281
|
+
is_allow = permission_decision == ALLOW_DECISION
|
|
282
|
+
deny_reason = hook_specific.get("permissionDecisionReason", "")
|
|
283
|
+
if not isinstance(deny_reason, str):
|
|
284
|
+
deny_reason = ""
|
|
285
|
+
raw_system_message = parsed_output.get("systemMessage", "")
|
|
286
|
+
system_message = raw_system_message if isinstance(raw_system_message, str) else ""
|
|
287
|
+
raw_additional_context = hook_specific.get("additionalContext", "")
|
|
288
|
+
additional_context = raw_additional_context if isinstance(raw_additional_context, str) else ""
|
|
289
|
+
suppress_output = parsed_output.get("suppressOutput") is True
|
|
290
|
+
return ParsedHookOutput(
|
|
291
|
+
is_deny=is_deny,
|
|
292
|
+
is_allow=is_allow,
|
|
293
|
+
deny_reason=deny_reason,
|
|
294
|
+
system_message=system_message,
|
|
295
|
+
additional_context=additional_context,
|
|
296
|
+
suppress_output=suppress_output,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class DispatcherDecision:
|
|
302
|
+
"""The aggregated decision across all hosted hook results.
|
|
303
|
+
|
|
304
|
+
Attributes:
|
|
305
|
+
should_deny: True when at least one hosted hook denied.
|
|
306
|
+
should_allow: True when at least one hosted hook emitted an explicit
|
|
307
|
+
allow decision and no hook denied, so the dispatcher re-emits an
|
|
308
|
+
explicit allow matching the standalone hook's auto-approval.
|
|
309
|
+
all_deny_reasons: All deny reasons from denying hooks, in run order.
|
|
310
|
+
all_system_messages: Every hook's top-level systemMessage, in run order,
|
|
311
|
+
joined into the deny payload's systemMessage.
|
|
312
|
+
all_additional_context: Every hook's hookSpecificOutput.additionalContext,
|
|
313
|
+
in run order, joined into the deny payload's additionalContext.
|
|
314
|
+
should_suppress_output: True when any hook set a suppressOutput flag, so
|
|
315
|
+
the deny payload suppresses output as the standalone hook would.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
should_deny: bool
|
|
319
|
+
should_allow: bool
|
|
320
|
+
all_deny_reasons: list[str]
|
|
321
|
+
all_system_messages: list[str]
|
|
322
|
+
all_additional_context: list[str]
|
|
323
|
+
should_suppress_output: bool
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def aggregate_hosted_hook_results(
|
|
327
|
+
all_results: list[HostedHookResult],
|
|
328
|
+
) -> DispatcherDecision:
|
|
329
|
+
"""Aggregate all hosted hook results into one dispatcher decision.
|
|
330
|
+
|
|
331
|
+
Parses each result's stdout for a deny decision and an explicit allow
|
|
332
|
+
decision. A clean BLOCKING_CRASH_EXIT_CODE from a blocking hook also signals
|
|
333
|
+
deny. Deny wins over allow: when any result denies, the aggregate denies
|
|
334
|
+
carrying every denying reason. When a deny carries no reason text,
|
|
335
|
+
EXIT_CODE_TWO_DENY_REASON supplies a fallback. When no result denies and at
|
|
336
|
+
least one result carried an explicit allow decision, the aggregate signals an
|
|
337
|
+
explicit allow so the dispatcher re-emits it, matching the standalone hook's
|
|
338
|
+
auto-approval. Collects every systemMessage and additionalContext message
|
|
339
|
+
from every hook, and the suppressOutput flag, whether or not it denied, so
|
|
340
|
+
the emitted deny reproduces each standalone hook's full deny shape.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
all_results: Outcomes from running each applicable hosted hook.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
A DispatcherDecision with the aggregated deny signal, the explicit allow
|
|
347
|
+
signal, all deny reasons, all systemMessage and additionalContext
|
|
348
|
+
messages, and the suppressOutput flag.
|
|
349
|
+
"""
|
|
350
|
+
all_deny_reasons: list[str] = []
|
|
351
|
+
all_system_messages: list[str] = []
|
|
352
|
+
all_additional_context: list[str] = []
|
|
353
|
+
should_suppress_output = False
|
|
354
|
+
saw_explicit_allow = False
|
|
355
|
+
|
|
356
|
+
for each_result in all_results:
|
|
357
|
+
parsed_output = _parse_deny_from_hook_output(each_result.captured_stdout)
|
|
358
|
+
if parsed_output.is_deny:
|
|
359
|
+
all_deny_reasons.append(
|
|
360
|
+
parsed_output.deny_reason if parsed_output.deny_reason else EXIT_CODE_TWO_DENY_REASON
|
|
361
|
+
)
|
|
362
|
+
elif each_result.did_crash and each_result.is_blocking:
|
|
363
|
+
all_deny_reasons.append(
|
|
364
|
+
"[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
365
|
+
)
|
|
366
|
+
elif each_result.exit_code == BLOCKING_CRASH_EXIT_CODE and each_result.is_blocking:
|
|
367
|
+
all_deny_reasons.append(EXIT_CODE_TWO_DENY_REASON)
|
|
368
|
+
if parsed_output.is_allow:
|
|
369
|
+
saw_explicit_allow = True
|
|
370
|
+
if parsed_output.system_message:
|
|
371
|
+
all_system_messages.append(parsed_output.system_message)
|
|
372
|
+
if parsed_output.additional_context:
|
|
373
|
+
all_additional_context.append(parsed_output.additional_context)
|
|
374
|
+
if parsed_output.suppress_output:
|
|
375
|
+
should_suppress_output = True
|
|
376
|
+
|
|
377
|
+
should_deny = bool(all_deny_reasons)
|
|
378
|
+
return DispatcherDecision(
|
|
379
|
+
should_deny=should_deny,
|
|
380
|
+
should_allow=saw_explicit_allow and not should_deny,
|
|
381
|
+
all_deny_reasons=all_deny_reasons,
|
|
382
|
+
all_system_messages=all_system_messages,
|
|
383
|
+
all_additional_context=all_additional_context,
|
|
384
|
+
should_suppress_output=should_suppress_output,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _emit_deny_decision(decision: DispatcherDecision) -> None:
|
|
389
|
+
"""Write one deny JSON object to stdout carrying all deny reasons and context.
|
|
390
|
+
|
|
391
|
+
Carries every hook's systemMessage and additionalContext and the
|
|
392
|
+
suppressOutput flag so the dispatched deny matches the standalone hooks'
|
|
393
|
+
full deny shape.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
decision: The aggregated dispatcher decision with deny reasons, context,
|
|
397
|
+
and the suppressOutput flag.
|
|
398
|
+
"""
|
|
399
|
+
combined_reason = " | ".join(decision.all_deny_reasons)
|
|
400
|
+
hook_specific: dict[str, object] = {
|
|
401
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
402
|
+
"permissionDecision": DENY_DECISION,
|
|
403
|
+
"permissionDecisionReason": combined_reason,
|
|
404
|
+
}
|
|
405
|
+
if decision.all_additional_context:
|
|
406
|
+
hook_specific["additionalContext"] = "\n".join(decision.all_additional_context)
|
|
407
|
+
deny_payload: dict[str, object] = {"hookSpecificOutput": hook_specific}
|
|
408
|
+
if decision.all_system_messages:
|
|
409
|
+
deny_payload["systemMessage"] = "\n".join(decision.all_system_messages)
|
|
410
|
+
if decision.should_suppress_output:
|
|
411
|
+
deny_payload["suppressOutput"] = True
|
|
412
|
+
sys.stdout.write(json.dumps(deny_payload) + "\n")
|
|
413
|
+
sys.stdout.flush()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _emit_allow_decision() -> None:
|
|
417
|
+
"""Write one explicit allow JSON object to stdout.
|
|
418
|
+
|
|
419
|
+
Matches the shape a standalone hosted hook emits when it auto-approves the
|
|
420
|
+
write, so a write a hosted hook allows explicitly is auto-approved under the
|
|
421
|
+
dispatcher rather than falling back to the default permission flow.
|
|
422
|
+
"""
|
|
423
|
+
allow_payload: dict[str, object] = {
|
|
424
|
+
"hookSpecificOutput": {
|
|
425
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
426
|
+
"permissionDecision": ALLOW_DECISION,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
sys.stdout.write(json.dumps(allow_payload) + "\n")
|
|
430
|
+
sys.stdout.flush()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _select_applicable_hooks(tool_name: str) -> list[HostedHookEntry]:
|
|
434
|
+
"""Return the ordered hosted hook entries applicable to the given tool name.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
tool_name: The tool name from the PreToolUse payload.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
The ordered list of HostedHookEntry objects whose applicable_tool_names
|
|
441
|
+
set includes tool_name.
|
|
442
|
+
"""
|
|
443
|
+
return [
|
|
444
|
+
each_entry
|
|
445
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
446
|
+
if tool_name in each_entry.applicable_tool_names
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
451
|
+
"""Resolve a hook relative path to an absolute path.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
relative_path: Hook path relative to the hooks/ directory.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
The absolute path of the hook script.
|
|
458
|
+
"""
|
|
459
|
+
return str(Path(__file__).resolve().parent.parent / relative_path)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _run_one_hosted_hook(
|
|
463
|
+
each_entry: HostedHookEntry,
|
|
464
|
+
payload_text: str,
|
|
465
|
+
payload_by_key: dict[str, object],
|
|
466
|
+
) -> HostedHookResult:
|
|
467
|
+
"""Run one hosted hook either natively or via runpy and return its outcome.
|
|
468
|
+
|
|
469
|
+
Calls the hook's native evaluator in-process when the entry names a native
|
|
470
|
+
module, otherwise runs the hook script via runpy under __main__.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
each_entry: The hosted hook entry to run.
|
|
474
|
+
payload_text: The raw JSON payload text to replay to a runpy hook.
|
|
475
|
+
payload_by_key: The parsed payload dict to pass to a native evaluator.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The HostedHookResult for this hook's run.
|
|
479
|
+
"""
|
|
480
|
+
if each_entry.native_module_name is not None:
|
|
481
|
+
native_hook_by_module_name: dict[str, NativeHook] = {
|
|
482
|
+
STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
|
|
483
|
+
evaluate=evaluate_state_description,
|
|
484
|
+
build_deny_payload=build_state_description_deny_payload,
|
|
485
|
+
),
|
|
486
|
+
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
|
|
487
|
+
evaluate=evaluate_plain_language,
|
|
488
|
+
build_deny_payload=build_plain_language_deny_payload,
|
|
489
|
+
),
|
|
490
|
+
}
|
|
491
|
+
native_hook = native_hook_by_module_name[each_entry.native_module_name]
|
|
492
|
+
return run_native_hook(native_hook, payload_by_key, each_entry.is_blocking)
|
|
493
|
+
script_path = _resolve_hook_script_path(each_entry.script_relative_path)
|
|
494
|
+
return run_hosted_hook(script_path, payload_text, each_entry.is_blocking)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def dispatch(
|
|
498
|
+
payload_text: str,
|
|
499
|
+
tool_name: str,
|
|
500
|
+
payload_by_key: dict[str, object],
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Run all applicable hosted hooks and emit one aggregated decision.
|
|
503
|
+
|
|
504
|
+
Selects the applicable hosted hooks for tool_name, runs each one in-process
|
|
505
|
+
(natively when the entry names a native module, otherwise via runpy),
|
|
506
|
+
aggregates the results, and emits a deny JSON object when any hook denied, an
|
|
507
|
+
explicit allow JSON object when a hook allowed explicitly and none denied, or
|
|
508
|
+
exits zero with no output when no hook decided.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
payload_text: The raw JSON payload text to replay to each runpy hook.
|
|
512
|
+
tool_name: The tool name from the PreToolUse payload.
|
|
513
|
+
payload_by_key: The parsed payload dict to pass to native evaluators.
|
|
514
|
+
"""
|
|
515
|
+
applicable_entries = _select_applicable_hooks(tool_name)
|
|
516
|
+
all_results: list[HostedHookResult] = []
|
|
517
|
+
for each_entry in applicable_entries:
|
|
518
|
+
hook_result = _run_one_hosted_hook(each_entry, payload_text, payload_by_key)
|
|
519
|
+
all_results.append(hook_result)
|
|
520
|
+
|
|
521
|
+
aggregated_decision = aggregate_hosted_hook_results(all_results)
|
|
522
|
+
if aggregated_decision.should_deny:
|
|
523
|
+
_emit_deny_decision(aggregated_decision)
|
|
524
|
+
return
|
|
525
|
+
if aggregated_decision.should_allow:
|
|
526
|
+
_emit_allow_decision()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def main() -> None:
|
|
530
|
+
"""Read stdin once and dispatch to all applicable hosted hooks."""
|
|
531
|
+
payload_dict = read_hook_input_dictionary_from_stdin()
|
|
532
|
+
if payload_dict is None:
|
|
533
|
+
sys.exit(0)
|
|
534
|
+
|
|
535
|
+
payload_text = json.dumps(payload_dict)
|
|
536
|
+
tool_name = payload_dict.get("tool_name", "")
|
|
537
|
+
if not isinstance(tool_name, str):
|
|
538
|
+
sys.exit(0)
|
|
539
|
+
|
|
540
|
+
dispatch(payload_text, tool_name, payload_dict)
|
|
541
|
+
sys.exit(0)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
if __name__ == "__main__":
|
|
545
|
+
main()
|