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,563 @@
|
|
|
1
|
+
"""Inspect Codex hook configuration sources."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Collection, Iterable, Iterator
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from sequential_hooks.hosts import (
|
|
11
|
+
ConfigSource,
|
|
12
|
+
Finding,
|
|
13
|
+
FindingSeverity,
|
|
14
|
+
InspectedCommand,
|
|
15
|
+
JsonObjectExpectedError,
|
|
16
|
+
JsonProtocolError,
|
|
17
|
+
LaunchSemantics,
|
|
18
|
+
Registration,
|
|
19
|
+
SourceKind,
|
|
20
|
+
deduplicate_source_paths,
|
|
21
|
+
load_object_text,
|
|
22
|
+
parse_timeout,
|
|
23
|
+
project_layers,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_LoadedSource = tuple[ConfigSource, dict[str, object] | None, Finding | None]
|
|
27
|
+
|
|
28
|
+
_LIMITATIONS = (
|
|
29
|
+
'active profiles cannot be established from the inspected configuration files',
|
|
30
|
+
'session overrides cannot be established from the inspected configuration files',
|
|
31
|
+
'plugin hooks cannot be inspected without scanning enabled plugin installations',
|
|
32
|
+
'persisted hook trust state cannot be established from hook definitions',
|
|
33
|
+
'cloud or MDM managed hooks cannot be inspected as ordinary local configuration',
|
|
34
|
+
'managed requirements.toml hooks and constraints cannot be established here',
|
|
35
|
+
'allow_managed_hooks_only can suppress user, project, session, and plugin hooks',
|
|
36
|
+
'local source absence does not prove the absence of an effective registration',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CodexDoctorLoader:
|
|
41
|
+
"""Inspect the Codex hooks and config files through the published `DoctorLoader` contract."""
|
|
42
|
+
|
|
43
|
+
def analyze_registration(
|
|
44
|
+
self,
|
|
45
|
+
registration: Registration,
|
|
46
|
+
inspected: InspectedCommand,
|
|
47
|
+
*,
|
|
48
|
+
windows: bool,
|
|
49
|
+
) -> tuple[Finding, ...]:
|
|
50
|
+
"""Return host-specific findings for one inspected registration.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
registration: Accepted registration.
|
|
54
|
+
inspected: Static inspection of the registration's command.
|
|
55
|
+
windows: Whether Windows launch semantics apply.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Findings in report order.
|
|
59
|
+
"""
|
|
60
|
+
del registration, inspected, windows
|
|
61
|
+
return ()
|
|
62
|
+
|
|
63
|
+
def discover_sources(
|
|
64
|
+
self,
|
|
65
|
+
home: Path,
|
|
66
|
+
cwd: Path,
|
|
67
|
+
system_root: Path,
|
|
68
|
+
) -> tuple[ConfigSource, ...]:
|
|
69
|
+
"""Return every configuration source in deterministic inspection order.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
home: Home directory used to locate user configuration.
|
|
73
|
+
cwd: Working directory used to locate project configuration.
|
|
74
|
+
system_root: Filesystem root used to locate system configuration.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Candidate sources, existing or not, in inspection order.
|
|
78
|
+
"""
|
|
79
|
+
return _discover_sources(home, cwd, system_root)
|
|
80
|
+
|
|
81
|
+
def inspection_limitations(self) -> tuple[str, ...]:
|
|
82
|
+
"""Return configuration sources static inspection cannot establish.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Stable limitation descriptions in report order.
|
|
86
|
+
"""
|
|
87
|
+
return _inspection_limitations()
|
|
88
|
+
|
|
89
|
+
def load_registrations(
|
|
90
|
+
self,
|
|
91
|
+
sources: tuple[ConfigSource, ...],
|
|
92
|
+
) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
|
|
93
|
+
"""Extract registrations and structural findings in source order.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
sources: Sources returned by `discover_sources`.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Accepted registrations and source-isolated findings.
|
|
100
|
+
"""
|
|
101
|
+
return _load_registrations(sources)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _discover_sources(
|
|
105
|
+
home: Path,
|
|
106
|
+
cwd: Path,
|
|
107
|
+
system_root: Path,
|
|
108
|
+
) -> tuple[ConfigSource, ...]:
|
|
109
|
+
"""Return Codex sources in deterministic inspection order."""
|
|
110
|
+
paths: list[tuple[Path, SourceKind]] = [
|
|
111
|
+
(home / '.codex/hooks.json', SourceKind.USER),
|
|
112
|
+
(home / '.codex/config.toml', SourceKind.USER),
|
|
113
|
+
(system_root / 'etc/codex/hooks.json', SourceKind.SYSTEM),
|
|
114
|
+
(system_root / 'etc/codex/config.toml', SourceKind.SYSTEM),
|
|
115
|
+
]
|
|
116
|
+
for layer in project_layers(cwd):
|
|
117
|
+
paths.extend(
|
|
118
|
+
(
|
|
119
|
+
(layer / '.codex/hooks.json', SourceKind.PROJECT),
|
|
120
|
+
(layer / '.codex/config.toml', SourceKind.PROJECT),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return tuple(
|
|
124
|
+
ConfigSource(path, kind, path.exists()) for path, kind in deduplicate_source_paths(paths)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _finding(
|
|
129
|
+
source: ConfigSource,
|
|
130
|
+
code: str,
|
|
131
|
+
message: str,
|
|
132
|
+
correction: str | None = None,
|
|
133
|
+
) -> Finding:
|
|
134
|
+
"""Build a source-specific structural finding."""
|
|
135
|
+
return Finding(
|
|
136
|
+
FindingSeverity.WARNING,
|
|
137
|
+
code,
|
|
138
|
+
f'Codex configuration at {source.path} {message}',
|
|
139
|
+
correction,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _activation_finding(source: ConfigSource) -> Finding:
|
|
144
|
+
"""Describe source activation without taking ownership from Codex."""
|
|
145
|
+
if source.kind is SourceKind.SYSTEM:
|
|
146
|
+
return Finding(
|
|
147
|
+
FindingSeverity.INFO,
|
|
148
|
+
'codex_system_policy_source',
|
|
149
|
+
f'Codex configuration at {source.path} is a managed policy source; '
|
|
150
|
+
'Codex determines activation',
|
|
151
|
+
)
|
|
152
|
+
if source.kind is SourceKind.PROJECT:
|
|
153
|
+
return _finding(
|
|
154
|
+
source,
|
|
155
|
+
'codex_project_trust_unknown',
|
|
156
|
+
'has hook definitions whose project trust state cannot be established statically',
|
|
157
|
+
'Review the hook with /hooks inside Codex.',
|
|
158
|
+
)
|
|
159
|
+
return _finding(
|
|
160
|
+
source,
|
|
161
|
+
'codex_hook_trust_unknown',
|
|
162
|
+
'has a non-managed hook definition whose trust state cannot be established statically',
|
|
163
|
+
'Review the hook with /hooks inside Codex.',
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _feature_state(
|
|
168
|
+
source: ConfigSource,
|
|
169
|
+
loaded: dict[str, object],
|
|
170
|
+
) -> tuple[bool | None, list[Finding]]:
|
|
171
|
+
"""Return this TOML source's hook feature state and compatibility findings."""
|
|
172
|
+
features = loaded.get('features')
|
|
173
|
+
if features is None:
|
|
174
|
+
return True, []
|
|
175
|
+
findings: list[Finding] = []
|
|
176
|
+
if not isinstance(features, dict):
|
|
177
|
+
findings.append(_finding(source, 'codex_features_malformed', 'has malformed features'))
|
|
178
|
+
return None, findings
|
|
179
|
+
feature_data = cast('dict[str, object]', features)
|
|
180
|
+
if 'codex_hooks' in feature_data:
|
|
181
|
+
findings.append(
|
|
182
|
+
_finding(
|
|
183
|
+
source,
|
|
184
|
+
'codex_feature_alias_deprecated',
|
|
185
|
+
'uses the deprecated features.codex_hooks compatibility alias',
|
|
186
|
+
'Use features.hooks instead.',
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
canonical = feature_data.get('hooks')
|
|
190
|
+
alias = feature_data.get('codex_hooks')
|
|
191
|
+
if 'hooks' in feature_data:
|
|
192
|
+
if isinstance(canonical, bool):
|
|
193
|
+
enabled: bool | None = canonical
|
|
194
|
+
else:
|
|
195
|
+
enabled = None
|
|
196
|
+
findings.append(
|
|
197
|
+
_finding(
|
|
198
|
+
source,
|
|
199
|
+
'codex_feature_hooks_malformed',
|
|
200
|
+
'has a malformed features.hooks value',
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
elif 'codex_hooks' in feature_data:
|
|
204
|
+
if isinstance(alias, bool):
|
|
205
|
+
enabled = alias
|
|
206
|
+
else:
|
|
207
|
+
enabled = None
|
|
208
|
+
findings.append(
|
|
209
|
+
_finding(
|
|
210
|
+
source,
|
|
211
|
+
'codex_feature_alias_malformed',
|
|
212
|
+
'has a malformed features.codex_hooks value',
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
enabled = True
|
|
217
|
+
return enabled, findings
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _handler_fields_finding(
|
|
221
|
+
source: ConfigSource,
|
|
222
|
+
handler_data: dict[str, object],
|
|
223
|
+
) -> Finding | None:
|
|
224
|
+
"""Return the first malformed portable command field."""
|
|
225
|
+
if not isinstance(handler_data.get('command'), str) or not handler_data.get('command'):
|
|
226
|
+
return _finding(
|
|
227
|
+
source,
|
|
228
|
+
'codex_command_malformed',
|
|
229
|
+
'contains a command hook without a non-empty string command',
|
|
230
|
+
)
|
|
231
|
+
if 'args' in handler_data and (
|
|
232
|
+
not isinstance(raw_args := handler_data['args'], list)
|
|
233
|
+
or not all(isinstance(argument, str) for argument in cast('list[object]', raw_args))
|
|
234
|
+
):
|
|
235
|
+
return _finding(
|
|
236
|
+
source,
|
|
237
|
+
'codex_args_malformed',
|
|
238
|
+
'contains a command hook with malformed args',
|
|
239
|
+
)
|
|
240
|
+
if (
|
|
241
|
+
'timeout' in handler_data
|
|
242
|
+
and parse_timeout(
|
|
243
|
+
handler_data['timeout'],
|
|
244
|
+
positive=False,
|
|
245
|
+
)
|
|
246
|
+
is None
|
|
247
|
+
):
|
|
248
|
+
return _finding(
|
|
249
|
+
source,
|
|
250
|
+
'codex_timeout_malformed',
|
|
251
|
+
'contains a command hook with a malformed timeout',
|
|
252
|
+
)
|
|
253
|
+
if 'async' in handler_data and not isinstance(handler_data['async'], bool):
|
|
254
|
+
return _finding(
|
|
255
|
+
source,
|
|
256
|
+
'codex_async_malformed',
|
|
257
|
+
'contains a command hook with a malformed async value',
|
|
258
|
+
)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _inspection_limitations() -> tuple[str, ...]:
|
|
263
|
+
"""Return Codex sources static inspection cannot establish."""
|
|
264
|
+
return _LIMITATIONS
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _is_posix_only_command(command: str) -> bool:
|
|
268
|
+
"""Return whether a command explicitly launches a POSIX shell."""
|
|
269
|
+
stripped = command.lstrip()
|
|
270
|
+
return stripped.startswith(('sh ', 'bash ', '/bin/sh ', '/bin/bash '))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _iter_event_items(
|
|
274
|
+
hooks: dict[str, object],
|
|
275
|
+
*,
|
|
276
|
+
toml: bool,
|
|
277
|
+
) -> Iterator[tuple[str, object]]:
|
|
278
|
+
"""Yield hook events without product-owned TOML state metadata."""
|
|
279
|
+
for event, groups in hooks.items():
|
|
280
|
+
if not toml or event != 'state':
|
|
281
|
+
yield event, groups
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _layer_feature_states(
|
|
285
|
+
loaded_sources: Collection[_LoadedSource],
|
|
286
|
+
) -> tuple[dict[Path, bool | None], dict[Path, tuple[Finding, ...]]]:
|
|
287
|
+
"""Collect the feature state established by each layer's TOML source."""
|
|
288
|
+
states: dict[Path, bool | None] = {}
|
|
289
|
+
findings: dict[Path, tuple[Finding, ...]] = {}
|
|
290
|
+
for source, loaded, source_finding in loaded_sources:
|
|
291
|
+
if source.path.suffix != '.toml' or not source.exists:
|
|
292
|
+
continue
|
|
293
|
+
if source_finding is not None or loaded is None:
|
|
294
|
+
states[source.path.parent] = None
|
|
295
|
+
continue
|
|
296
|
+
state, feature_findings = _feature_state(source, loaded)
|
|
297
|
+
states[source.path.parent] = state
|
|
298
|
+
findings[source.path] = tuple(feature_findings)
|
|
299
|
+
return states, findings
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _load_json_source(
|
|
303
|
+
source: ConfigSource,
|
|
304
|
+
text: str,
|
|
305
|
+
) -> tuple[dict[str, object] | None, Finding | None]:
|
|
306
|
+
"""Load one strict JSON hook source."""
|
|
307
|
+
try:
|
|
308
|
+
loaded = load_object_text(text)
|
|
309
|
+
except JsonObjectExpectedError:
|
|
310
|
+
return None, _finding(source, 'codex_settings_malformed', 'must contain a JSON object')
|
|
311
|
+
except JsonProtocolError:
|
|
312
|
+
return None, _finding(source, 'codex_json_malformed', 'contains malformed JSON')
|
|
313
|
+
return cast('dict[str, object]', loaded), None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _load_toml_source(
|
|
317
|
+
source: ConfigSource,
|
|
318
|
+
text: str,
|
|
319
|
+
) -> tuple[dict[str, object] | None, Finding | None]:
|
|
320
|
+
"""Load one TOML configuration source."""
|
|
321
|
+
try:
|
|
322
|
+
return tomllib.loads(text), None
|
|
323
|
+
except (tomllib.TOMLDecodeError, ValueError):
|
|
324
|
+
return None, _finding(source, 'codex_toml_malformed', 'contains malformed TOML')
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _load_source(source: ConfigSource) -> tuple[dict[str, object] | None, Finding | None]:
|
|
328
|
+
"""Load one source without exposing content in diagnostics."""
|
|
329
|
+
try:
|
|
330
|
+
text = source.path.read_text(encoding='utf-8')
|
|
331
|
+
except UnicodeDecodeError:
|
|
332
|
+
return None, _finding(
|
|
333
|
+
source,
|
|
334
|
+
'codex_source_encoding_malformed',
|
|
335
|
+
'contains malformed UTF-8',
|
|
336
|
+
)
|
|
337
|
+
except OSError:
|
|
338
|
+
return None, _finding(source, 'codex_source_unreadable', 'cannot be read')
|
|
339
|
+
if source.path.suffix == '.toml':
|
|
340
|
+
return _load_toml_source(source, text)
|
|
341
|
+
return _load_json_source(source, text)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _load_sources(sources: Iterable[ConfigSource]) -> tuple[_LoadedSource, ...]:
|
|
345
|
+
"""Load existing sources independently in inspection order."""
|
|
346
|
+
loaded_sources: list[_LoadedSource] = []
|
|
347
|
+
for source in sources:
|
|
348
|
+
if source.exists:
|
|
349
|
+
loaded, finding = _load_source(source)
|
|
350
|
+
else:
|
|
351
|
+
loaded, finding = None, None
|
|
352
|
+
loaded_sources.append((source, loaded, finding))
|
|
353
|
+
return tuple(loaded_sources)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _windows_command_finding(
|
|
357
|
+
source: ConfigSource,
|
|
358
|
+
handler_data: dict[str, object],
|
|
359
|
+
) -> Finding | None:
|
|
360
|
+
"""Return a malformed or ambiguous Windows override finding."""
|
|
361
|
+
toml = source.path.suffix == '.toml'
|
|
362
|
+
windows_keys = ('commandWindows', 'command_windows') if toml else ('commandWindows',)
|
|
363
|
+
if toml and all(key in handler_data for key in windows_keys):
|
|
364
|
+
return _finding(
|
|
365
|
+
source,
|
|
366
|
+
'codex_windows_command_ambiguous',
|
|
367
|
+
'contains both TOML spellings of the Windows command override',
|
|
368
|
+
)
|
|
369
|
+
if any(
|
|
370
|
+
key in handler_data and (not isinstance(handler_data[key], str) or not handler_data[key])
|
|
371
|
+
for key in windows_keys
|
|
372
|
+
):
|
|
373
|
+
return _finding(
|
|
374
|
+
source,
|
|
375
|
+
'codex_windows_command_malformed',
|
|
376
|
+
'contains a malformed Windows command override',
|
|
377
|
+
)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _handler_validation_finding(
|
|
382
|
+
source: ConfigSource,
|
|
383
|
+
handler: object,
|
|
384
|
+
) -> Finding | None:
|
|
385
|
+
"""Return the first structural problem in one handler."""
|
|
386
|
+
if not isinstance(handler, dict):
|
|
387
|
+
return _finding(source, 'codex_hook_malformed', 'contains a malformed hook handler')
|
|
388
|
+
handler_data = cast('dict[str, object]', handler)
|
|
389
|
+
if handler_data.get('type') != 'command':
|
|
390
|
+
return _finding(
|
|
391
|
+
source,
|
|
392
|
+
'codex_hook_type_unsupported',
|
|
393
|
+
'contains a hook whose type is not the supported command form',
|
|
394
|
+
)
|
|
395
|
+
fields_finding = _handler_fields_finding(source, handler_data)
|
|
396
|
+
if fields_finding is not None:
|
|
397
|
+
return fields_finding
|
|
398
|
+
windows_finding = _windows_command_finding(source, handler_data)
|
|
399
|
+
if windows_finding is not None:
|
|
400
|
+
return windows_finding
|
|
401
|
+
if handler_data.get('async') is True:
|
|
402
|
+
return _finding(
|
|
403
|
+
source,
|
|
404
|
+
'codex_async_unsupported',
|
|
405
|
+
'contains an asynchronous command hook that Codex does not run',
|
|
406
|
+
)
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _windows_findings(
|
|
411
|
+
source: ConfigSource,
|
|
412
|
+
registrations: Collection[Registration],
|
|
413
|
+
) -> list[Finding]:
|
|
414
|
+
"""Diagnose explicit POSIX shell dependencies on native Windows."""
|
|
415
|
+
if sys.platform != 'win32':
|
|
416
|
+
return []
|
|
417
|
+
return [
|
|
418
|
+
_finding(
|
|
419
|
+
source,
|
|
420
|
+
'codex_windows_posix_command',
|
|
421
|
+
'contains a command that explicitly depends on POSIX shell tokenization on Windows',
|
|
422
|
+
'Add commandWindows and review the hook with /hooks inside Codex.',
|
|
423
|
+
)
|
|
424
|
+
for registration in registrations
|
|
425
|
+
if registration.command_windows is None and _is_posix_only_command(registration.command)
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _registration(
|
|
430
|
+
source: ConfigSource,
|
|
431
|
+
event: str,
|
|
432
|
+
matcher: str | None,
|
|
433
|
+
handler: object,
|
|
434
|
+
*,
|
|
435
|
+
enabled: bool | None,
|
|
436
|
+
) -> tuple[Registration | None, Finding | None]:
|
|
437
|
+
"""Parse one supported synchronous command hook."""
|
|
438
|
+
validation_finding = _handler_validation_finding(source, handler)
|
|
439
|
+
if validation_finding is not None:
|
|
440
|
+
return None, validation_finding
|
|
441
|
+
handler_data = cast('dict[str, object]', handler)
|
|
442
|
+
command = cast('str', handler_data['command'])
|
|
443
|
+
raw_args = cast('list[str] | None', handler_data.get('args'))
|
|
444
|
+
command_windows = handler_data.get('commandWindows')
|
|
445
|
+
if source.path.suffix == '.toml' and command_windows is None:
|
|
446
|
+
command_windows = handler_data.get('command_windows')
|
|
447
|
+
parsed_windows = cast('str | None', command_windows)
|
|
448
|
+
registration = Registration(
|
|
449
|
+
source=source,
|
|
450
|
+
event=event,
|
|
451
|
+
matcher=matcher,
|
|
452
|
+
command=command,
|
|
453
|
+
args=tuple(raw_args) if isinstance(raw_args, list) else None,
|
|
454
|
+
launch_semantics=LaunchSemantics.POSIX_SHELL,
|
|
455
|
+
command_windows=parsed_windows,
|
|
456
|
+
windows_launch_semantics=LaunchSemantics.WINDOWS_SHELL,
|
|
457
|
+
timeout=parse_timeout(handler_data.get('timeout'), positive=False),
|
|
458
|
+
enabled=enabled,
|
|
459
|
+
type=cast('str', handler_data['type']),
|
|
460
|
+
)
|
|
461
|
+
return replace(
|
|
462
|
+
registration,
|
|
463
|
+
configuration_findings=tuple(_windows_findings(source, (registration,))),
|
|
464
|
+
), None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _event_registrations(
|
|
468
|
+
source: ConfigSource,
|
|
469
|
+
event: object,
|
|
470
|
+
groups: object,
|
|
471
|
+
*,
|
|
472
|
+
enabled: bool | None,
|
|
473
|
+
) -> tuple[list[Registration], list[Finding]]:
|
|
474
|
+
"""Parse one event layer."""
|
|
475
|
+
if not isinstance(event, str) or not isinstance(groups, list):
|
|
476
|
+
return [], [_finding(source, 'codex_event_malformed', 'contains a malformed event')]
|
|
477
|
+
registrations: list[Registration] = []
|
|
478
|
+
findings: list[Finding] = []
|
|
479
|
+
for group in cast('list[object]', groups):
|
|
480
|
+
if not isinstance(group, dict):
|
|
481
|
+
findings.append(
|
|
482
|
+
_finding(
|
|
483
|
+
source,
|
|
484
|
+
'codex_matcher_group_malformed',
|
|
485
|
+
'contains a malformed matcher group',
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
continue
|
|
489
|
+
group_data = cast('dict[str, object]', group)
|
|
490
|
+
matcher = group_data.get('matcher')
|
|
491
|
+
if 'matcher' in group_data and not isinstance(matcher, str):
|
|
492
|
+
findings.append(
|
|
493
|
+
_finding(source, 'codex_matcher_malformed', 'contains a malformed matcher')
|
|
494
|
+
)
|
|
495
|
+
continue
|
|
496
|
+
handlers = group_data.get('hooks')
|
|
497
|
+
if not isinstance(handlers, list):
|
|
498
|
+
findings.append(
|
|
499
|
+
_finding(
|
|
500
|
+
source,
|
|
501
|
+
'codex_handler_list_malformed',
|
|
502
|
+
'contains a malformed hook handler list',
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
continue
|
|
506
|
+
for handler in cast('list[object]', handlers):
|
|
507
|
+
registration, finding = _registration(
|
|
508
|
+
source,
|
|
509
|
+
event,
|
|
510
|
+
cast('str | None', matcher),
|
|
511
|
+
handler,
|
|
512
|
+
enabled=enabled,
|
|
513
|
+
)
|
|
514
|
+
if registration is not None:
|
|
515
|
+
registrations.append(registration)
|
|
516
|
+
if finding is not None:
|
|
517
|
+
findings.append(finding)
|
|
518
|
+
return registrations, findings
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _load_registrations(
|
|
522
|
+
sources: Iterable[ConfigSource],
|
|
523
|
+
) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
|
|
524
|
+
"""Extract registrations and findings in source order."""
|
|
525
|
+
registrations: list[Registration] = []
|
|
526
|
+
findings: list[Finding] = []
|
|
527
|
+
loaded_sources = _load_sources(sources)
|
|
528
|
+
feature_states, feature_findings = _layer_feature_states(loaded_sources)
|
|
529
|
+
for source, loaded, finding in loaded_sources:
|
|
530
|
+
if not source.exists:
|
|
531
|
+
continue
|
|
532
|
+
if finding is not None:
|
|
533
|
+
findings.append(finding)
|
|
534
|
+
continue
|
|
535
|
+
if loaded is None:
|
|
536
|
+
continue
|
|
537
|
+
toml = source.path.suffix == '.toml'
|
|
538
|
+
enabled = feature_states.get(source.path.parent, True)
|
|
539
|
+
if toml:
|
|
540
|
+
findings.extend(feature_findings.get(source.path, ()))
|
|
541
|
+
hooks = loaded.get('hooks')
|
|
542
|
+
if hooks is None:
|
|
543
|
+
continue
|
|
544
|
+
if not isinstance(hooks, dict):
|
|
545
|
+
findings.append(
|
|
546
|
+
_finding(source, 'codex_hooks_malformed', 'contains a malformed hooks object')
|
|
547
|
+
)
|
|
548
|
+
continue
|
|
549
|
+
source_registrations: list[Registration] = []
|
|
550
|
+
hook_events = cast('dict[str, object]', hooks)
|
|
551
|
+
for event, groups in _iter_event_items(hook_events, toml=toml):
|
|
552
|
+
event_registrations, event_findings = _event_registrations(
|
|
553
|
+
source,
|
|
554
|
+
event,
|
|
555
|
+
groups,
|
|
556
|
+
enabled=enabled,
|
|
557
|
+
)
|
|
558
|
+
source_registrations.extend(event_registrations)
|
|
559
|
+
findings.extend(event_findings)
|
|
560
|
+
registrations.extend(source_registrations)
|
|
561
|
+
if source_registrations:
|
|
562
|
+
findings.append(_activation_finding(source))
|
|
563
|
+
return tuple(registrations), tuple(findings)
|