sequential-hooks 0.1.0__py3-none-any.whl
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.
- plugins/agy/_sequential_hooks/__init__.py +1 -0
- plugins/agy/_sequential_hooks/_adapter.py +566 -0
- plugins/agy/_sequential_hooks/_doctor.py +413 -0
- plugins/claude/_sequential_hooks/__init__.py +1 -0
- plugins/claude/_sequential_hooks/_adapter.py +560 -0
- plugins/claude/_sequential_hooks/_doctor.py +455 -0
- plugins/codex/_sequential_hooks/__init__.py +1 -0
- plugins/codex/_sequential_hooks/_adapter.py +466 -0
- plugins/codex/_sequential_hooks/_doctor.py +563 -0
- sequential_hooks/__init__.py +3 -0
- sequential_hooks/__main__.py +5 -0
- sequential_hooks/_arguments.py +222 -0
- sequential_hooks/_cleanup.py +88 -0
- sequential_hooks/_cli.py +440 -0
- sequential_hooks/_containment/__init__.py +410 -0
- sequential_hooks/_containment/_posix.py +236 -0
- sequential_hooks/_containment/_uncontained.py +239 -0
- sequential_hooks/_containment/_windows.py +853 -0
- sequential_hooks/_containment/_windows_api.py +570 -0
- sequential_hooks/_containment/_windows_launcher.py +189 -0
- sequential_hooks/_diagnostics.py +265 -0
- sequential_hooks/_doctor/__init__.py +342 -0
- sequential_hooks/_doctor/_command.py +469 -0
- sequential_hooks/_doctor/_common.py +448 -0
- sequential_hooks/_doctor/_types.py +94 -0
- sequential_hooks/_downstream.py +181 -0
- sequential_hooks/_executable.py +187 -0
- sequential_hooks/_executor.py +825 -0
- sequential_hooks/_registry.py +103 -0
- sequential_hooks/_runner.py +48 -0
- sequential_hooks/_types.py +125 -0
- sequential_hooks/hosts/__init__.py +130 -0
- sequential_hooks/hosts/_contract.py +434 -0
- sequential_hooks/hosts/_inspection.py +78 -0
- sequential_hooks/hosts/_json.py +150 -0
- sequential_hooks/hosts/_records.py +273 -0
- sequential_hooks/hosts/_skeleton.py +1326 -0
- sequential_hooks/py.typed +0 -0
- sequential_hooks-0.1.0.dist-info/METADATA +90 -0
- sequential_hooks-0.1.0.dist-info/RECORD +43 -0
- sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
- sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
- sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Validate and compose Claude Code hook events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from sequential_hooks.hosts import (
|
|
10
|
+
API_VERSION,
|
|
11
|
+
AdapterOutputError,
|
|
12
|
+
Composer,
|
|
13
|
+
Contribution,
|
|
14
|
+
HostInputError,
|
|
15
|
+
NativeResponse,
|
|
16
|
+
Outcome,
|
|
17
|
+
OutputFailureKind,
|
|
18
|
+
encode_response,
|
|
19
|
+
labeled,
|
|
20
|
+
optional_string,
|
|
21
|
+
reject_unsupported,
|
|
22
|
+
unsupported_fields,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
|
|
28
|
+
from plugins.claude._sequential_hooks._doctor import ClaudeDoctorLoader
|
|
29
|
+
from sequential_hooks.hosts import (
|
|
30
|
+
Diagnostics,
|
|
31
|
+
DoctorLoader,
|
|
32
|
+
FinalResponse,
|
|
33
|
+
JsonObject,
|
|
34
|
+
JsonValue,
|
|
35
|
+
Step,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_CLAUDE_FIELD_LIMIT_CHARACTERS = 10_000
|
|
39
|
+
_CLAUDE_PRE_RANK = {
|
|
40
|
+
'allow': 1,
|
|
41
|
+
'ask': 2,
|
|
42
|
+
'defer': 3,
|
|
43
|
+
'deny': 4,
|
|
44
|
+
}
|
|
45
|
+
_COMMON_FIELDS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
'continue',
|
|
48
|
+
'decision',
|
|
49
|
+
'hookSpecificOutput',
|
|
50
|
+
'reason',
|
|
51
|
+
'stopReason',
|
|
52
|
+
'suppressOutput',
|
|
53
|
+
'systemMessage',
|
|
54
|
+
'terminalSequence',
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
_EVENTS = ('PreToolUse', 'PostToolUse')
|
|
58
|
+
_POST_HOOK_FIELDS = frozenset(
|
|
59
|
+
{'additionalContext', 'hookEventName', 'updatedMCPToolOutput', 'updatedToolOutput'}
|
|
60
|
+
)
|
|
61
|
+
_PRE_HOOK_FIELDS = frozenset(
|
|
62
|
+
{
|
|
63
|
+
'additionalContext',
|
|
64
|
+
'hookEventName',
|
|
65
|
+
'permissionDecision',
|
|
66
|
+
'permissionDecisionReason',
|
|
67
|
+
'updatedInput',
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ClaudeAdapter(Composer):
|
|
73
|
+
"""Compose child results for one validated Claude Code event.
|
|
74
|
+
|
|
75
|
+
Child results are consumed in step order. Accepted mutations update the
|
|
76
|
+
event-owned field supplied to later children, and finalization emits the
|
|
77
|
+
composed native response.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
field_limit = _CLAUDE_FIELD_LIMIT_CHARACTERS
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
raw_input: bytes,
|
|
85
|
+
payload: JsonObject,
|
|
86
|
+
diagnostics: Diagnostics,
|
|
87
|
+
*,
|
|
88
|
+
allow_on_failure: bool,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Validate native input and initialize one Claude Code event adapter.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
raw_input: Original strict JSON bytes received from Claude Code.
|
|
94
|
+
payload: Decoded strict JSON object matching `raw_input`.
|
|
95
|
+
diagnostics: Developer-tier diagnostic sink.
|
|
96
|
+
allow_on_failure: Whether approved pre-tool failures may continue.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
HostInputError: If required Claude Code input is missing or
|
|
100
|
+
invalid, or the payload nests too deeply to compose.
|
|
101
|
+
"""
|
|
102
|
+
event = payload.get('hook_event_name')
|
|
103
|
+
if event not in _EVENTS:
|
|
104
|
+
raise HostInputError('Claude Code input has an unsupported hook event')
|
|
105
|
+
tool_name = payload.get('tool_name')
|
|
106
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
107
|
+
raise HostInputError('Claude Code input requires a non-empty tool_name')
|
|
108
|
+
if 'tool_input' not in payload:
|
|
109
|
+
raise HostInputError('Claude Code input requires tool_input')
|
|
110
|
+
pre_tool = event == 'PreToolUse'
|
|
111
|
+
if not pre_tool and 'tool_response' not in payload:
|
|
112
|
+
raise HostInputError('Claude Code PostToolUse input requires tool_response')
|
|
113
|
+
self.pre_tool = pre_tool
|
|
114
|
+
self.mutation_field = 'tool_input' if pre_tool else 'tool_response'
|
|
115
|
+
super().__init__(
|
|
116
|
+
event,
|
|
117
|
+
raw_input,
|
|
118
|
+
payload,
|
|
119
|
+
diagnostics,
|
|
120
|
+
allow_on_failure=allow_on_failure,
|
|
121
|
+
)
|
|
122
|
+
self._tool_name = tool_name
|
|
123
|
+
|
|
124
|
+
def _parse_post_hook(self, hook: JsonObject) -> Contribution:
|
|
125
|
+
"""Validate one current structured PostToolUse result."""
|
|
126
|
+
unsupported_fields(hook, _POST_HOOK_FIELDS, 'unsupported PostToolUse field')
|
|
127
|
+
if hook.get('hookEventName') != 'PostToolUse':
|
|
128
|
+
reject_unsupported('hookSpecificOutput event mismatch')
|
|
129
|
+
has_updated = 'updatedToolOutput' in hook
|
|
130
|
+
has_mcp = 'updatedMCPToolOutput' in hook
|
|
131
|
+
if has_updated and has_mcp:
|
|
132
|
+
reject_unsupported('PostToolUse mutation forms are ambiguous')
|
|
133
|
+
if has_mcp and not self._tool_name.startswith('mcp__'):
|
|
134
|
+
reject_unsupported('updatedMCPToolOutput requires an MCP tool')
|
|
135
|
+
mutation: JsonValue = None
|
|
136
|
+
if has_updated:
|
|
137
|
+
mutation = deepcopy(hook['updatedToolOutput'])
|
|
138
|
+
elif has_mcp:
|
|
139
|
+
mutation = deepcopy(hook['updatedMCPToolOutput'])
|
|
140
|
+
mutation_present = has_updated or has_mcp
|
|
141
|
+
if mutation_present and not _mutation_strings_fit(mutation):
|
|
142
|
+
raise AdapterOutputError(
|
|
143
|
+
OutputFailureKind.UNDELIVERABLE_MUTATION,
|
|
144
|
+
'PostToolUse mutation contains an over-limit string',
|
|
145
|
+
)
|
|
146
|
+
return Contribution(
|
|
147
|
+
additional_context=optional_string(hook, 'additionalContext'),
|
|
148
|
+
mutation=mutation,
|
|
149
|
+
mutation_present=mutation_present,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _parse_specific(self, value: JsonObject) -> Contribution:
|
|
153
|
+
"""Parse event-specific and deprecated decision fields."""
|
|
154
|
+
legacy_decision = value.get('decision')
|
|
155
|
+
if 'decision' in value and not isinstance(legacy_decision, str):
|
|
156
|
+
reject_unsupported('decision must be a string')
|
|
157
|
+
legacy_reason = optional_string(value, 'reason')
|
|
158
|
+
hook_value = value.get('hookSpecificOutput')
|
|
159
|
+
if 'hookSpecificOutput' in value and not isinstance(hook_value, dict):
|
|
160
|
+
reject_unsupported('hookSpecificOutput must be an object')
|
|
161
|
+
hook = cast('JsonObject', hook_value) if hook_value is not None else None
|
|
162
|
+
if self.pre_tool:
|
|
163
|
+
legacy = _parse_legacy_pre(legacy_decision, legacy_reason)
|
|
164
|
+
if hook is not None and legacy_decision is not None:
|
|
165
|
+
reject_unsupported('current and deprecated decision forms are ambiguous')
|
|
166
|
+
if hook is None:
|
|
167
|
+
return legacy
|
|
168
|
+
return _parse_current_pre(hook)
|
|
169
|
+
if legacy_decision is not None and legacy_decision != 'block':
|
|
170
|
+
reject_unsupported('PostToolUse decision must be block')
|
|
171
|
+
if legacy_reason is not None and legacy_decision != 'block':
|
|
172
|
+
reject_unsupported('PostToolUse reason requires decision block')
|
|
173
|
+
hook_contribution = Contribution() if hook is None else self._parse_post_hook(hook)
|
|
174
|
+
return Contribution(
|
|
175
|
+
additional_context=hook_contribution.additional_context,
|
|
176
|
+
decision=cast('str | None', legacy_decision),
|
|
177
|
+
decision_reason=legacy_reason,
|
|
178
|
+
mutation=hook_contribution.mutation,
|
|
179
|
+
mutation_present=hook_contribution.mutation_present,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _post_response(self, response: JsonObject) -> None:
|
|
183
|
+
"""Add PostToolUse decision, context, and mutation fields."""
|
|
184
|
+
state = self.state
|
|
185
|
+
if state.decision == 'block':
|
|
186
|
+
response['decision'] = 'block'
|
|
187
|
+
reasons = self.bounded_decision_reasons()
|
|
188
|
+
if reasons is not None:
|
|
189
|
+
response['reason'] = reasons
|
|
190
|
+
contexts = self.bounded_contexts()
|
|
191
|
+
if contexts is None and not state.updated_output_present:
|
|
192
|
+
return
|
|
193
|
+
hook: JsonObject = {'hookEventName': 'PostToolUse'}
|
|
194
|
+
if contexts is not None:
|
|
195
|
+
hook['additionalContext'] = contexts
|
|
196
|
+
if state.updated_output_present:
|
|
197
|
+
hook['updatedToolOutput'] = state.updated_output
|
|
198
|
+
response['hookSpecificOutput'] = hook
|
|
199
|
+
|
|
200
|
+
def _pre_response(self, response: JsonObject) -> None:
|
|
201
|
+
"""Add PreToolUse output for the winning decision."""
|
|
202
|
+
state = self.state
|
|
203
|
+
if state.decision is None:
|
|
204
|
+
return
|
|
205
|
+
hook: JsonObject = {'hookEventName': 'PreToolUse', 'permissionDecision': state.decision}
|
|
206
|
+
reasons = self.bounded_decision_reasons()
|
|
207
|
+
if reasons is not None:
|
|
208
|
+
hook['permissionDecisionReason'] = reasons
|
|
209
|
+
if state.decision in ('allow', 'ask'):
|
|
210
|
+
contexts = self.bounded_contexts()
|
|
211
|
+
if contexts is not None:
|
|
212
|
+
hook['additionalContext'] = contexts
|
|
213
|
+
if state.updated_input_present:
|
|
214
|
+
hook['updatedInput'] = state.updated_input
|
|
215
|
+
response['hookSpecificOutput'] = hook
|
|
216
|
+
|
|
217
|
+
def apply_decision(self, step: Step, value: Contribution) -> None:
|
|
218
|
+
"""Compose one native decision and its rank-owned reason.
|
|
219
|
+
|
|
220
|
+
PostToolUse accepts only `block`, which always wins. PreToolUse ranks
|
|
221
|
+
`allow`, `ask`, `defer`, `deny`; a higher rank replaces the reasons, an
|
|
222
|
+
equal rank appends its reason.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
step: Child that supplied `value`.
|
|
226
|
+
value: Validated contribution whose `decision` may be set.
|
|
227
|
+
"""
|
|
228
|
+
state = self.state
|
|
229
|
+
if value.decision is None:
|
|
230
|
+
return
|
|
231
|
+
if not self.pre_tool:
|
|
232
|
+
state.decision = value.decision
|
|
233
|
+
if value.decision_reason is not None:
|
|
234
|
+
state.decision_reasons.append(labeled(step, value.decision_reason))
|
|
235
|
+
return
|
|
236
|
+
current_rank = _CLAUDE_PRE_RANK[state.decision] if state.decision is not None else 0
|
|
237
|
+
new_rank = _CLAUDE_PRE_RANK[value.decision]
|
|
238
|
+
if new_rank > current_rank:
|
|
239
|
+
state.decision = value.decision
|
|
240
|
+
state.decision_reasons = []
|
|
241
|
+
if new_rank >= current_rank and state.decision == value.decision and value.decision_reason:
|
|
242
|
+
state.decision_reasons.append(labeled(step, value.decision_reason))
|
|
243
|
+
|
|
244
|
+
def build_response(self) -> JsonObject:
|
|
245
|
+
"""Build the current structured Claude Code response.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Universal fields, the event-specific `hookSpecificOutput`, and the
|
|
249
|
+
global stop, after the current shedding choices.
|
|
250
|
+
"""
|
|
251
|
+
state = self.state
|
|
252
|
+
response: JsonObject = {}
|
|
253
|
+
messages = self.bounded_messages()
|
|
254
|
+
if messages is not None:
|
|
255
|
+
response['systemMessage'] = messages
|
|
256
|
+
if state.suppress_output is not None:
|
|
257
|
+
response['suppressOutput'] = state.suppress_output
|
|
258
|
+
if state.terminal_sequence is not None:
|
|
259
|
+
response['terminalSequence'] = state.terminal_sequence
|
|
260
|
+
if self.pre_tool:
|
|
261
|
+
self._pre_response(response)
|
|
262
|
+
else:
|
|
263
|
+
self._post_response(response)
|
|
264
|
+
if state.continue_false:
|
|
265
|
+
response['continue'] = False
|
|
266
|
+
stop_reason = self.bounded_stop_reason()
|
|
267
|
+
if stop_reason is not None:
|
|
268
|
+
response['stopReason'] = stop_reason
|
|
269
|
+
return response
|
|
270
|
+
|
|
271
|
+
def classify_unparsable(self, text: str, stripped: bytes, error: str) -> Outcome:
|
|
272
|
+
"""Classify non-JSON stdout as native plain text unless it opens an object.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
text: Decoded stdout.
|
|
276
|
+
stripped: Stdout without leading ASCII whitespace.
|
|
277
|
+
error: Parser detail for the malformed classification.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Plain outcome, or a malformed failure for text starting with `{`.
|
|
281
|
+
"""
|
|
282
|
+
if not stripped.startswith(b'{'):
|
|
283
|
+
return Outcome('plain', plain=text)
|
|
284
|
+
return Outcome('failure', failure=OutputFailureKind.MALFORMED.value, message=error)
|
|
285
|
+
|
|
286
|
+
def failure_texts(self, category: str, message: str) -> tuple[str, str, str]:
|
|
287
|
+
"""Return Claude Code's full-detail diagnostic and report texts.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
category: Failure category with underscores replaced by spaces.
|
|
291
|
+
message: Failure detail, possibly empty.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Diagnostic text, report text, and the minimal report text.
|
|
295
|
+
"""
|
|
296
|
+
detail = message or f'{category} output'
|
|
297
|
+
return (
|
|
298
|
+
f'{category}: {detail}',
|
|
299
|
+
f'{category}: {detail}',
|
|
300
|
+
(
|
|
301
|
+
f'{category}: sequential-hooks cannot represent hook output; '
|
|
302
|
+
'see developer diagnostics'
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def parse_structured(self, value: JsonObject) -> Contribution:
|
|
307
|
+
"""Parse one whole Claude Code protocol object.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
value: Strict JSON object a child wrote to stdout.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Validated contribution.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
AdapterOutputError: If the object is outside the documented
|
|
317
|
+
contract.
|
|
318
|
+
"""
|
|
319
|
+
unsupported_fields(value, _COMMON_FIELDS, 'unsupported Claude Code output field')
|
|
320
|
+
universal = _parse_universal(value)
|
|
321
|
+
specific = self._parse_specific(value)
|
|
322
|
+
return Contribution(
|
|
323
|
+
additional_context=specific.additional_context,
|
|
324
|
+
continue_value=universal.continue_value,
|
|
325
|
+
decision=specific.decision,
|
|
326
|
+
decision_reason=specific.decision_reason,
|
|
327
|
+
mutation=specific.mutation,
|
|
328
|
+
mutation_present=specific.mutation_present,
|
|
329
|
+
stop_reason=universal.stop_reason,
|
|
330
|
+
suppress_output=universal.suppress_output,
|
|
331
|
+
system_message=universal.system_message,
|
|
332
|
+
terminal_sequence=universal.terminal_sequence,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def terminal_fallback(self, step: Step) -> bool:
|
|
336
|
+
"""Drop an accepted mutation to deliver a native block.
|
|
337
|
+
|
|
338
|
+
A committed terminal block outranks a retained mutation; the tool
|
|
339
|
+
already ran or will not run, so dropping the rewrite does not run an
|
|
340
|
+
unrewritten operation.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
step: Child whose blocking exit is being delivered.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Whether the response fits without the mutation.
|
|
347
|
+
"""
|
|
348
|
+
state = self.state
|
|
349
|
+
state.updated_input_present = False
|
|
350
|
+
state.updated_output_present = False
|
|
351
|
+
self.write_diagnostic(step, 'accepted mutation dropped to deliver the blocking response')
|
|
352
|
+
try:
|
|
353
|
+
self.fit_response()
|
|
354
|
+
except AdapterOutputError:
|
|
355
|
+
return False
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
def undeliverable_owner(self, owner: Contribution) -> bool:
|
|
359
|
+
"""Return whether an unfittable owner carries mandatory Claude Code output.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
owner: Contribution whose publication cannot fit.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
`True` for a decision, a global stop, or a mutation, unless the
|
|
366
|
+
owner's terminal sequence alone is what does not fit.
|
|
367
|
+
"""
|
|
368
|
+
mandatory = (
|
|
369
|
+
owner.continue_value is False or owner.decision is not None or owner.mutation_present
|
|
370
|
+
)
|
|
371
|
+
if (
|
|
372
|
+
mandatory
|
|
373
|
+
and self.state.terminal_sequence_owner is owner
|
|
374
|
+
and self.fits_without_terminal_sequence()
|
|
375
|
+
):
|
|
376
|
+
return False
|
|
377
|
+
return mandatory
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ClaudeHost:
|
|
381
|
+
"""Describe the Claude Code host for entry-point discovery.
|
|
382
|
+
|
|
383
|
+
Attributes:
|
|
384
|
+
api_version: Host contract version implemented here.
|
|
385
|
+
events: Native events the wrapper serves.
|
|
386
|
+
name: Entry-point name selected by `--agent`.
|
|
387
|
+
requires_event: `False`; the payload carries `hook_event_name`.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
api_version = API_VERSION
|
|
391
|
+
events = _EVENTS
|
|
392
|
+
name = 'claude'
|
|
393
|
+
requires_event = False
|
|
394
|
+
|
|
395
|
+
def adapter(
|
|
396
|
+
self,
|
|
397
|
+
event: str,
|
|
398
|
+
raw_input: bytes,
|
|
399
|
+
payload: JsonObject,
|
|
400
|
+
diagnostics: Diagnostics,
|
|
401
|
+
*,
|
|
402
|
+
allow_on_failure: bool,
|
|
403
|
+
) -> ClaudeAdapter:
|
|
404
|
+
"""Validate native input and construct the adapter for one invocation.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
event: Trusted event established from the payload.
|
|
408
|
+
raw_input: Original native strict JSON hook input.
|
|
409
|
+
payload: Parsed native strict JSON object.
|
|
410
|
+
diagnostics: Bounded developer diagnostic sink.
|
|
411
|
+
allow_on_failure: Whether approved pre-tool failures may continue.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Adapter ready to drive the chain.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
HostInputError: If the payload is not valid input for `event`.
|
|
418
|
+
"""
|
|
419
|
+
adapter = ClaudeAdapter(raw_input, payload, diagnostics, allow_on_failure=allow_on_failure)
|
|
420
|
+
if adapter.event != event:
|
|
421
|
+
raise HostInputError('Claude Code input event does not match the selected event')
|
|
422
|
+
return adapter
|
|
423
|
+
|
|
424
|
+
def configuration_response(
|
|
425
|
+
self,
|
|
426
|
+
event: str,
|
|
427
|
+
reason: str,
|
|
428
|
+
diagnostics: Diagnostics,
|
|
429
|
+
) -> FinalResponse:
|
|
430
|
+
"""Return the native deny or block for a configuration error.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
event: Trusted event, one of `events`.
|
|
434
|
+
reason: Wrapper diagnostic, published after response resolution.
|
|
435
|
+
diagnostics: Bounded developer diagnostic sink.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
PreToolUse deny or PostToolUse block without adapter construction.
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
ValueError: If `event` is not one of `events`.
|
|
442
|
+
"""
|
|
443
|
+
del reason
|
|
444
|
+
if event not in self.events:
|
|
445
|
+
raise ValueError(f'Claude Code has no configuration response for {event!r}')
|
|
446
|
+
message = '[step 1: configuration]\nspawn: sequential-hooks could not safely run this hook'
|
|
447
|
+
diagnostics.write_global(
|
|
448
|
+
'configuration: spawn: sequential-hooks could not safely run this hook'
|
|
449
|
+
)
|
|
450
|
+
value: JsonObject = {'systemMessage': message}
|
|
451
|
+
if event == 'PreToolUse':
|
|
452
|
+
value['hookSpecificOutput'] = {
|
|
453
|
+
'hookEventName': 'PreToolUse',
|
|
454
|
+
'permissionDecision': 'deny',
|
|
455
|
+
'permissionDecisionReason': message,
|
|
456
|
+
}
|
|
457
|
+
else:
|
|
458
|
+
value['decision'] = 'block'
|
|
459
|
+
value['reason'] = message
|
|
460
|
+
return NativeResponse(encode_response(value))
|
|
461
|
+
|
|
462
|
+
def doctor_loader(self) -> DoctorLoader:
|
|
463
|
+
"""Return the static Claude Code configuration loader.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Loader over the documented Claude Code settings files.
|
|
467
|
+
"""
|
|
468
|
+
module = import_module('plugins.claude._sequential_hooks._doctor')
|
|
469
|
+
loader_type: type[ClaudeDoctorLoader] = module.ClaudeDoctorLoader
|
|
470
|
+
return loader_type()
|
|
471
|
+
|
|
472
|
+
def payload_event(self, payload: Mapping[str, object]) -> str | None:
|
|
473
|
+
"""Return the event named by `hook_event_name`, if supported.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
payload: Parsed native strict JSON object.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
`PreToolUse` or `PostToolUse`, else `None`.
|
|
480
|
+
"""
|
|
481
|
+
value = payload.get('hook_event_name')
|
|
482
|
+
return value if isinstance(value, str) and value in _EVENTS else None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _mutation_strings_fit(value: JsonValue) -> bool:
|
|
486
|
+
"""Return whether every nested mutation string fits the field cap exactly."""
|
|
487
|
+
if isinstance(value, str):
|
|
488
|
+
return len(value) <= _CLAUDE_FIELD_LIMIT_CHARACTERS
|
|
489
|
+
if isinstance(value, list):
|
|
490
|
+
return all(_mutation_strings_fit(item) for item in value)
|
|
491
|
+
if isinstance(value, dict):
|
|
492
|
+
return all(_mutation_strings_fit(item) for item in value.values())
|
|
493
|
+
return True
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _parse_current_pre(hook: JsonObject) -> Contribution:
|
|
497
|
+
"""Validate one current structured PreToolUse result."""
|
|
498
|
+
unsupported_fields(hook, _PRE_HOOK_FIELDS, 'unsupported PreToolUse field')
|
|
499
|
+
if hook.get('hookEventName') != 'PreToolUse':
|
|
500
|
+
reject_unsupported('hookSpecificOutput event mismatch')
|
|
501
|
+
native_decision = hook.get('permissionDecision')
|
|
502
|
+
if not isinstance(native_decision, str) or native_decision not in _CLAUDE_PRE_RANK:
|
|
503
|
+
reject_unsupported('unsupported PreToolUse permissionDecision')
|
|
504
|
+
decision = native_decision
|
|
505
|
+
mutation: JsonValue = None
|
|
506
|
+
mutation_present = 'updatedInput' in hook
|
|
507
|
+
if mutation_present:
|
|
508
|
+
if decision not in ('allow', 'ask'):
|
|
509
|
+
reject_unsupported('updatedInput requires allow or ask')
|
|
510
|
+
mutation = deepcopy(hook['updatedInput'])
|
|
511
|
+
if not _mutation_strings_fit(mutation):
|
|
512
|
+
raise AdapterOutputError(
|
|
513
|
+
OutputFailureKind.UNDELIVERABLE_MUTATION,
|
|
514
|
+
'PreToolUse mutation contains an over-limit string',
|
|
515
|
+
)
|
|
516
|
+
return Contribution(
|
|
517
|
+
additional_context=optional_string(hook, 'additionalContext'),
|
|
518
|
+
decision=decision,
|
|
519
|
+
decision_reason=optional_string(hook, 'permissionDecisionReason'),
|
|
520
|
+
mutation=mutation,
|
|
521
|
+
mutation_present=mutation_present,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _parse_legacy_pre(legacy_decision: JsonValue, legacy_reason: str | None) -> Contribution:
|
|
526
|
+
"""Validate and normalize one deprecated top-level PreToolUse result."""
|
|
527
|
+
decision: str | None = None
|
|
528
|
+
if legacy_decision is not None:
|
|
529
|
+
if legacy_decision not in ('approve', 'block'):
|
|
530
|
+
reject_unsupported('deprecated decision must be approve or block')
|
|
531
|
+
decision = 'allow' if legacy_decision == 'approve' else 'deny'
|
|
532
|
+
elif legacy_reason is not None:
|
|
533
|
+
reject_unsupported('reason requires a deprecated decision')
|
|
534
|
+
return Contribution(decision=decision, decision_reason=legacy_reason)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _parse_universal(value: JsonObject) -> Contribution:
|
|
538
|
+
"""Validate and retain universal Claude Code output fields."""
|
|
539
|
+
continue_value = value.get('continue')
|
|
540
|
+
if 'continue' in value and not isinstance(continue_value, bool):
|
|
541
|
+
reject_unsupported('continue must be a Boolean')
|
|
542
|
+
stop_reason = optional_string(value, 'stopReason')
|
|
543
|
+
if stop_reason is not None and continue_value is not False:
|
|
544
|
+
reject_unsupported('stopReason requires continue false')
|
|
545
|
+
suppress_output = value.get('suppressOutput')
|
|
546
|
+
if 'suppressOutput' in value and not isinstance(suppress_output, bool):
|
|
547
|
+
reject_unsupported('suppressOutput must be a Boolean')
|
|
548
|
+
terminal_sequence = optional_string(value, 'terminalSequence')
|
|
549
|
+
if terminal_sequence is not None and len(terminal_sequence) > _CLAUDE_FIELD_LIMIT_CHARACTERS:
|
|
550
|
+
raise AdapterOutputError(
|
|
551
|
+
OutputFailureKind.OVERSIZED,
|
|
552
|
+
'terminalSequence exceeds the Claude Code field limit',
|
|
553
|
+
)
|
|
554
|
+
return Contribution(
|
|
555
|
+
continue_value=cast('bool | None', continue_value),
|
|
556
|
+
stop_reason=stop_reason,
|
|
557
|
+
suppress_output=cast('bool | None', suppress_output),
|
|
558
|
+
system_message=optional_string(value, 'systemMessage'),
|
|
559
|
+
terminal_sequence=terminal_sequence,
|
|
560
|
+
)
|