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,466 @@
|
|
|
1
|
+
"""Validate and compose Codex 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
|
+
Composer,
|
|
12
|
+
Contribution,
|
|
13
|
+
HostInputError,
|
|
14
|
+
NativeResponse,
|
|
15
|
+
Outcome,
|
|
16
|
+
OutputFailureKind,
|
|
17
|
+
bounded_diagnostic,
|
|
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.codex._sequential_hooks._doctor import CodexDoctorLoader
|
|
29
|
+
from sequential_hooks.hosts import (
|
|
30
|
+
Diagnostics,
|
|
31
|
+
DoctorLoader,
|
|
32
|
+
FinalResponse,
|
|
33
|
+
JsonObject,
|
|
34
|
+
JsonValue,
|
|
35
|
+
Step,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_COMMAND_TOOLS = ('Bash', 'apply_patch')
|
|
39
|
+
_EVENTS = ('PreToolUse', 'PostToolUse')
|
|
40
|
+
_POST_FIELDS = frozenset(
|
|
41
|
+
{'continue', 'decision', 'hookSpecificOutput', 'reason', 'stopReason', 'systemMessage'}
|
|
42
|
+
)
|
|
43
|
+
_POST_HOOK_FIELDS = frozenset({'additionalContext', 'hookEventName'})
|
|
44
|
+
_PRE_FIELDS = frozenset({'decision', 'hookSpecificOutput', 'reason', 'systemMessage'})
|
|
45
|
+
_PRE_HOOK_FIELDS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
'additionalContext',
|
|
48
|
+
'hookEventName',
|
|
49
|
+
'permissionDecision',
|
|
50
|
+
'permissionDecisionReason',
|
|
51
|
+
'updatedInput',
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CodexAdapter(Composer):
|
|
57
|
+
"""Compose child results for one validated Codex event.
|
|
58
|
+
|
|
59
|
+
Child results are consumed in step order. Accepted mutations replace the
|
|
60
|
+
`tool_input` supplied to later children, and finalization emits the
|
|
61
|
+
composed native response.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
raw_input: bytes,
|
|
67
|
+
payload: JsonObject,
|
|
68
|
+
diagnostics: Diagnostics,
|
|
69
|
+
*,
|
|
70
|
+
allow_on_failure: bool,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Validate native input and initialize one Codex event adapter.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
raw_input: Original strict JSON bytes received from Codex.
|
|
76
|
+
payload: Decoded strict JSON object matching `raw_input`.
|
|
77
|
+
diagnostics: Developer-tier diagnostic sink.
|
|
78
|
+
allow_on_failure: Whether approved pre-tool failures may continue.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
HostInputError: If required Codex input is missing or invalid, or
|
|
82
|
+
the payload nests too deeply to compose.
|
|
83
|
+
"""
|
|
84
|
+
event = payload.get('hook_event_name')
|
|
85
|
+
if event not in _EVENTS:
|
|
86
|
+
raise HostInputError('Codex input has an unsupported hook event')
|
|
87
|
+
tool_name = payload.get('tool_name')
|
|
88
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
89
|
+
raise HostInputError('Codex input requires a non-empty tool_name')
|
|
90
|
+
if 'tool_input' not in payload:
|
|
91
|
+
raise HostInputError('Codex input requires tool_input')
|
|
92
|
+
pre_tool = event == 'PreToolUse'
|
|
93
|
+
if not pre_tool and 'tool_response' not in payload:
|
|
94
|
+
raise HostInputError('Codex PostToolUse input requires tool_response')
|
|
95
|
+
self.pre_tool = pre_tool
|
|
96
|
+
self.mutation_field = 'tool_input' if pre_tool else None
|
|
97
|
+
super().__init__(
|
|
98
|
+
event,
|
|
99
|
+
raw_input,
|
|
100
|
+
payload,
|
|
101
|
+
diagnostics,
|
|
102
|
+
allow_on_failure=allow_on_failure,
|
|
103
|
+
)
|
|
104
|
+
self._tool_name = tool_name
|
|
105
|
+
|
|
106
|
+
def _parse_pre_mutation(self, hook: JsonObject, decision: object) -> JsonValue:
|
|
107
|
+
"""Validate one whole Codex replacement arguments object."""
|
|
108
|
+
mutation_present = 'updatedInput' in hook
|
|
109
|
+
if decision == 'allow' and not mutation_present:
|
|
110
|
+
reject_unsupported('permissionDecision allow requires updatedInput')
|
|
111
|
+
if mutation_present and decision != 'allow':
|
|
112
|
+
reject_unsupported('updatedInput requires permissionDecision allow')
|
|
113
|
+
if not mutation_present:
|
|
114
|
+
return None
|
|
115
|
+
mutation_value = hook['updatedInput']
|
|
116
|
+
if not isinstance(mutation_value, dict):
|
|
117
|
+
reject_unsupported('updatedInput must be an arguments object')
|
|
118
|
+
mutation = deepcopy(cast('JsonObject', mutation_value))
|
|
119
|
+
if self._tool_name in _COMMAND_TOOLS and not isinstance(mutation.get('command'), str):
|
|
120
|
+
reject_unsupported(f'{self._tool_name} updatedInput requires string command')
|
|
121
|
+
return mutation
|
|
122
|
+
|
|
123
|
+
def _parse_pre_hook(self, hook: JsonObject) -> Contribution:
|
|
124
|
+
"""Validate current Codex PreToolUse hook fields."""
|
|
125
|
+
unsupported_fields(hook, _PRE_HOOK_FIELDS, 'unsupported Codex PreToolUse hook field')
|
|
126
|
+
if hook.get('hookEventName') != 'PreToolUse':
|
|
127
|
+
reject_unsupported('hookSpecificOutput event mismatch')
|
|
128
|
+
decision = hook.get('permissionDecision')
|
|
129
|
+
if 'permissionDecision' in hook and decision not in ('allow', 'deny'):
|
|
130
|
+
reject_unsupported('unsupported Codex PreToolUse permissionDecision')
|
|
131
|
+
decision_reason = optional_string(hook, 'permissionDecisionReason')
|
|
132
|
+
if decision_reason is not None and decision is None:
|
|
133
|
+
reject_unsupported('permissionDecisionReason requires a decision')
|
|
134
|
+
return Contribution(
|
|
135
|
+
additional_context=optional_string(hook, 'additionalContext'),
|
|
136
|
+
decision=cast('str | None', decision),
|
|
137
|
+
decision_reason=decision_reason,
|
|
138
|
+
mutation=self._parse_pre_mutation(hook, decision),
|
|
139
|
+
mutation_present='updatedInput' in hook,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _parse_pre(self, value: JsonObject) -> Contribution:
|
|
143
|
+
"""Validate one structured PreToolUse result."""
|
|
144
|
+
unsupported_fields(value, _PRE_FIELDS, 'unsupported Codex PreToolUse field')
|
|
145
|
+
system_message = optional_string(value, 'systemMessage')
|
|
146
|
+
legacy = _parse_pre_legacy(value)
|
|
147
|
+
if 'hookSpecificOutput' not in value:
|
|
148
|
+
return Contribution(
|
|
149
|
+
decision=legacy.decision,
|
|
150
|
+
decision_reason=legacy.decision_reason,
|
|
151
|
+
system_message=system_message,
|
|
152
|
+
)
|
|
153
|
+
hook_value = value['hookSpecificOutput']
|
|
154
|
+
if not isinstance(hook_value, dict):
|
|
155
|
+
reject_unsupported('hookSpecificOutput must be an object')
|
|
156
|
+
current = self._parse_pre_hook(cast('JsonObject', hook_value))
|
|
157
|
+
if legacy.decision is not None and current.decision is not None:
|
|
158
|
+
reject_unsupported('current and deprecated decision forms are ambiguous')
|
|
159
|
+
return Contribution(
|
|
160
|
+
additional_context=current.additional_context,
|
|
161
|
+
decision=current.decision if legacy.decision is None else legacy.decision,
|
|
162
|
+
decision_reason=(
|
|
163
|
+
current.decision_reason if legacy.decision is None else legacy.decision_reason
|
|
164
|
+
),
|
|
165
|
+
mutation=current.mutation,
|
|
166
|
+
mutation_present=current.mutation_present,
|
|
167
|
+
system_message=system_message,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _post_response(self, response: JsonObject) -> None:
|
|
171
|
+
"""Add PostToolUse block, context, and global stop fields."""
|
|
172
|
+
state = self.state
|
|
173
|
+
if state.decision == 'block':
|
|
174
|
+
response['decision'] = 'block'
|
|
175
|
+
reasons = self.bounded_decision_reasons()
|
|
176
|
+
if reasons is not None:
|
|
177
|
+
response['reason'] = reasons
|
|
178
|
+
contexts = self.bounded_contexts()
|
|
179
|
+
if contexts is not None:
|
|
180
|
+
response['hookSpecificOutput'] = {
|
|
181
|
+
'hookEventName': 'PostToolUse',
|
|
182
|
+
'additionalContext': contexts,
|
|
183
|
+
}
|
|
184
|
+
if state.continue_false:
|
|
185
|
+
response['continue'] = False
|
|
186
|
+
stop_reason = self.bounded_stop_reason()
|
|
187
|
+
if stop_reason is not None:
|
|
188
|
+
response['stopReason'] = stop_reason
|
|
189
|
+
|
|
190
|
+
def _pre_response(self, response: JsonObject) -> None:
|
|
191
|
+
"""Add PreToolUse output for the winning result."""
|
|
192
|
+
state = self.state
|
|
193
|
+
contexts = self.bounded_contexts()
|
|
194
|
+
if state.decision is None and contexts is None:
|
|
195
|
+
return
|
|
196
|
+
hook: JsonObject = {'hookEventName': 'PreToolUse'}
|
|
197
|
+
if state.decision is not None:
|
|
198
|
+
hook['permissionDecision'] = state.decision
|
|
199
|
+
reasons = self.bounded_decision_reasons()
|
|
200
|
+
if reasons is not None:
|
|
201
|
+
hook['permissionDecisionReason'] = reasons
|
|
202
|
+
if contexts is not None and state.decision != 'deny':
|
|
203
|
+
hook['additionalContext'] = contexts
|
|
204
|
+
if state.decision == 'allow' and state.updated_input_present:
|
|
205
|
+
hook['updatedInput'] = state.updated_input
|
|
206
|
+
response['hookSpecificOutput'] = hook
|
|
207
|
+
|
|
208
|
+
def apply_decision(self, step: Step, value: Contribution) -> None:
|
|
209
|
+
"""Compose decisions: deny outranks non-decisions, and the wrapper never creates ask.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
step: Child that supplied `value`.
|
|
213
|
+
value: Validated contribution whose `decision` may be set.
|
|
214
|
+
"""
|
|
215
|
+
state = self.state
|
|
216
|
+
if value.decision is None:
|
|
217
|
+
return
|
|
218
|
+
reason = (
|
|
219
|
+
[labeled(step, value.decision_reason)] if value.decision_reason is not None else []
|
|
220
|
+
)
|
|
221
|
+
if not self.pre_tool:
|
|
222
|
+
state.decision = 'block'
|
|
223
|
+
state.decision_reasons = reason
|
|
224
|
+
return
|
|
225
|
+
if value.decision == 'deny':
|
|
226
|
+
state.decision = 'deny'
|
|
227
|
+
state.decision_reasons = reason
|
|
228
|
+
return
|
|
229
|
+
if state.decision is None:
|
|
230
|
+
state.decision = 'allow'
|
|
231
|
+
state.decision_reasons = []
|
|
232
|
+
if state.decision == 'allow':
|
|
233
|
+
state.decision_reasons.extend(reason)
|
|
234
|
+
|
|
235
|
+
def build_response(self) -> JsonObject:
|
|
236
|
+
"""Build the current canonical Codex response.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
User messages and the event-specific fields after the current
|
|
240
|
+
shedding choices.
|
|
241
|
+
"""
|
|
242
|
+
response: JsonObject = {}
|
|
243
|
+
messages = self.bounded_messages()
|
|
244
|
+
if messages is not None:
|
|
245
|
+
response['systemMessage'] = messages
|
|
246
|
+
if self.pre_tool:
|
|
247
|
+
self._pre_response(response)
|
|
248
|
+
else:
|
|
249
|
+
self._post_response(response)
|
|
250
|
+
return response
|
|
251
|
+
|
|
252
|
+
def classify_unparsable(self, text: str, stripped: bytes, error: str) -> Outcome:
|
|
253
|
+
"""Classify non-JSON stdout as developer text unless it opens JSON.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
text: Decoded stdout.
|
|
257
|
+
stripped: Stdout without leading ASCII whitespace.
|
|
258
|
+
error: Parser detail for the malformed classification.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Developer outcome, or a malformed failure for text starting with
|
|
262
|
+
`{` or `[`.
|
|
263
|
+
"""
|
|
264
|
+
if stripped[:1] in (b'{', b'['):
|
|
265
|
+
return Outcome('failure', failure=OutputFailureKind.MALFORMED.value, message=error)
|
|
266
|
+
return Outcome('developer', developer_text=text)
|
|
267
|
+
|
|
268
|
+
def failure_texts(self, category: str, message: str) -> tuple[str, str, str]:
|
|
269
|
+
"""Return Codex's repr-quoted diagnostic and pointer report texts.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
category: Failure category with underscores replaced by spaces.
|
|
273
|
+
message: Failure detail, possibly empty.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Bounded diagnostic text, report text, and minimal report text.
|
|
277
|
+
"""
|
|
278
|
+
detail = message or f'{category} child result'
|
|
279
|
+
return (
|
|
280
|
+
bounded_diagnostic(f'{category}: ', detail),
|
|
281
|
+
f'{category}: child result rejected; see developer diagnostics',
|
|
282
|
+
f'{category}: see developer diagnostics',
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def ordinary_exit_texts(self, returncode: int) -> tuple[str, str]:
|
|
286
|
+
"""Return the diagnostic and pointer report for a non-blocking exit.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
returncode: Child status.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Diagnostic text and report text.
|
|
293
|
+
"""
|
|
294
|
+
detail = f'exited with status {returncode}'
|
|
295
|
+
return detail, f'{detail}; see developer diagnostics'
|
|
296
|
+
|
|
297
|
+
def parse_structured(self, value: JsonObject) -> Contribution:
|
|
298
|
+
"""Parse one complete Codex protocol object.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
value: Strict JSON object a child wrote to stdout.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Validated contribution.
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
AdapterOutputError: If the object is outside the documented
|
|
308
|
+
contract.
|
|
309
|
+
"""
|
|
310
|
+
return self._parse_pre(value) if self.pre_tool else _parse_post(value)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class CodexHost:
|
|
314
|
+
"""Describe the Codex host for entry-point discovery.
|
|
315
|
+
|
|
316
|
+
Attributes:
|
|
317
|
+
api_version: Host contract version implemented here.
|
|
318
|
+
events: Native events the wrapper serves.
|
|
319
|
+
name: Entry-point name selected by `--agent`.
|
|
320
|
+
requires_event: `False`; the payload carries `hook_event_name`.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
api_version = API_VERSION
|
|
324
|
+
events = _EVENTS
|
|
325
|
+
name = 'codex'
|
|
326
|
+
requires_event = False
|
|
327
|
+
|
|
328
|
+
def adapter(
|
|
329
|
+
self,
|
|
330
|
+
event: str,
|
|
331
|
+
raw_input: bytes,
|
|
332
|
+
payload: JsonObject,
|
|
333
|
+
diagnostics: Diagnostics,
|
|
334
|
+
*,
|
|
335
|
+
allow_on_failure: bool,
|
|
336
|
+
) -> CodexAdapter:
|
|
337
|
+
"""Validate native input and construct the adapter for one invocation.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
event: Trusted event established from the payload.
|
|
341
|
+
raw_input: Original native strict JSON hook input.
|
|
342
|
+
payload: Parsed native strict JSON object.
|
|
343
|
+
diagnostics: Bounded developer diagnostic sink.
|
|
344
|
+
allow_on_failure: Whether approved pre-tool failures may continue.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Adapter ready to drive the chain.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
HostInputError: If the payload is not valid input for `event`.
|
|
351
|
+
"""
|
|
352
|
+
adapter = CodexAdapter(raw_input, payload, diagnostics, allow_on_failure=allow_on_failure)
|
|
353
|
+
if adapter.event != event:
|
|
354
|
+
raise HostInputError('Codex input event does not match the selected event')
|
|
355
|
+
return adapter
|
|
356
|
+
|
|
357
|
+
def configuration_response(
|
|
358
|
+
self,
|
|
359
|
+
event: str,
|
|
360
|
+
reason: str,
|
|
361
|
+
diagnostics: Diagnostics,
|
|
362
|
+
) -> FinalResponse:
|
|
363
|
+
"""Return the native deny or block for a configuration error.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
event: Trusted event, one of `events`.
|
|
367
|
+
reason: Wrapper diagnostic, published after response resolution.
|
|
368
|
+
diagnostics: Bounded developer diagnostic sink.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
PreToolUse deny or PostToolUse block without adapter construction.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
ValueError: If `event` is not one of `events`.
|
|
375
|
+
"""
|
|
376
|
+
del reason
|
|
377
|
+
if event not in self.events:
|
|
378
|
+
raise ValueError(f'Codex has no configuration response for {event!r}')
|
|
379
|
+
message = (
|
|
380
|
+
'[step 1: configuration]\nspawn: child result rejected; see developer diagnostics'
|
|
381
|
+
)
|
|
382
|
+
diagnostics.write_global(
|
|
383
|
+
"configuration: spawn: 'sequential-hooks could not safely run this hook'"
|
|
384
|
+
)
|
|
385
|
+
value: JsonObject = {'systemMessage': message}
|
|
386
|
+
if event == 'PreToolUse':
|
|
387
|
+
value['hookSpecificOutput'] = {
|
|
388
|
+
'hookEventName': 'PreToolUse',
|
|
389
|
+
'permissionDecision': 'deny',
|
|
390
|
+
'permissionDecisionReason': message,
|
|
391
|
+
}
|
|
392
|
+
else:
|
|
393
|
+
value['decision'] = 'block'
|
|
394
|
+
value['reason'] = message
|
|
395
|
+
return NativeResponse(encode_response(value))
|
|
396
|
+
|
|
397
|
+
def doctor_loader(self) -> DoctorLoader:
|
|
398
|
+
"""Return the static Codex configuration loader.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Loader over the documented Codex hooks and config files.
|
|
402
|
+
"""
|
|
403
|
+
module = import_module('plugins.codex._sequential_hooks._doctor')
|
|
404
|
+
loader_type: type[CodexDoctorLoader] = module.CodexDoctorLoader
|
|
405
|
+
return loader_type()
|
|
406
|
+
|
|
407
|
+
def payload_event(self, payload: Mapping[str, object]) -> str | None:
|
|
408
|
+
"""Return the event named by `hook_event_name`, if supported.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
payload: Parsed native strict JSON object.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
`PreToolUse` or `PostToolUse`, else `None`.
|
|
415
|
+
"""
|
|
416
|
+
value = payload.get('hook_event_name')
|
|
417
|
+
return value if isinstance(value, str) and value in _EVENTS else None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _parse_post(value: JsonObject) -> Contribution:
|
|
421
|
+
"""Validate one structured PostToolUse result."""
|
|
422
|
+
unsupported_fields(value, _POST_FIELDS, 'unsupported Codex PostToolUse field')
|
|
423
|
+
decision = value.get('decision')
|
|
424
|
+
if 'decision' in value and decision != 'block':
|
|
425
|
+
reject_unsupported('Codex PostToolUse decision must be block')
|
|
426
|
+
reason = optional_string(value, 'reason')
|
|
427
|
+
if reason is not None and decision != 'block':
|
|
428
|
+
reject_unsupported('Codex PostToolUse reason requires decision block')
|
|
429
|
+
continue_value = value.get('continue')
|
|
430
|
+
if 'continue' in value and not isinstance(continue_value, bool):
|
|
431
|
+
reject_unsupported('continue must be a Boolean')
|
|
432
|
+
stop_reason = optional_string(value, 'stopReason')
|
|
433
|
+
if stop_reason is not None and continue_value is not False:
|
|
434
|
+
reject_unsupported('stopReason requires continue false')
|
|
435
|
+
additional_context: str | None = None
|
|
436
|
+
if 'hookSpecificOutput' in value:
|
|
437
|
+
hook_value = value['hookSpecificOutput']
|
|
438
|
+
if not isinstance(hook_value, dict):
|
|
439
|
+
reject_unsupported('hookSpecificOutput must be an object')
|
|
440
|
+
hook = cast('JsonObject', hook_value)
|
|
441
|
+
unsupported_fields(hook, _POST_HOOK_FIELDS, 'unsupported Codex PostToolUse hook field')
|
|
442
|
+
if hook.get('hookEventName') != 'PostToolUse':
|
|
443
|
+
reject_unsupported('hookSpecificOutput event mismatch')
|
|
444
|
+
additional_context = optional_string(hook, 'additionalContext')
|
|
445
|
+
return Contribution(
|
|
446
|
+
additional_context=additional_context,
|
|
447
|
+
continue_value=cast('bool | None', continue_value),
|
|
448
|
+
decision=cast('str | None', decision),
|
|
449
|
+
decision_reason=reason,
|
|
450
|
+
stop_reason=stop_reason,
|
|
451
|
+
system_message=optional_string(value, 'systemMessage'),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _parse_pre_legacy(value: JsonObject) -> Contribution:
|
|
456
|
+
"""Validate deprecated Codex PreToolUse decision fields."""
|
|
457
|
+
legacy_decision = value.get('decision')
|
|
458
|
+
if 'decision' in value and legacy_decision != 'block':
|
|
459
|
+
reject_unsupported('deprecated Codex decision must be block')
|
|
460
|
+
legacy_reason = optional_string(value, 'reason')
|
|
461
|
+
if legacy_reason is not None and legacy_decision != 'block':
|
|
462
|
+
reject_unsupported('reason requires deprecated decision block')
|
|
463
|
+
return Contribution(
|
|
464
|
+
decision='deny' if legacy_decision == 'block' else None,
|
|
465
|
+
decision_reason=legacy_reason,
|
|
466
|
+
)
|