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.
Files changed (43) hide show
  1. plugins/agy/_sequential_hooks/__init__.py +1 -0
  2. plugins/agy/_sequential_hooks/_adapter.py +566 -0
  3. plugins/agy/_sequential_hooks/_doctor.py +413 -0
  4. plugins/claude/_sequential_hooks/__init__.py +1 -0
  5. plugins/claude/_sequential_hooks/_adapter.py +560 -0
  6. plugins/claude/_sequential_hooks/_doctor.py +455 -0
  7. plugins/codex/_sequential_hooks/__init__.py +1 -0
  8. plugins/codex/_sequential_hooks/_adapter.py +466 -0
  9. plugins/codex/_sequential_hooks/_doctor.py +563 -0
  10. sequential_hooks/__init__.py +3 -0
  11. sequential_hooks/__main__.py +5 -0
  12. sequential_hooks/_arguments.py +222 -0
  13. sequential_hooks/_cleanup.py +88 -0
  14. sequential_hooks/_cli.py +440 -0
  15. sequential_hooks/_containment/__init__.py +410 -0
  16. sequential_hooks/_containment/_posix.py +236 -0
  17. sequential_hooks/_containment/_uncontained.py +239 -0
  18. sequential_hooks/_containment/_windows.py +853 -0
  19. sequential_hooks/_containment/_windows_api.py +570 -0
  20. sequential_hooks/_containment/_windows_launcher.py +189 -0
  21. sequential_hooks/_diagnostics.py +265 -0
  22. sequential_hooks/_doctor/__init__.py +342 -0
  23. sequential_hooks/_doctor/_command.py +469 -0
  24. sequential_hooks/_doctor/_common.py +448 -0
  25. sequential_hooks/_doctor/_types.py +94 -0
  26. sequential_hooks/_downstream.py +181 -0
  27. sequential_hooks/_executable.py +187 -0
  28. sequential_hooks/_executor.py +825 -0
  29. sequential_hooks/_registry.py +103 -0
  30. sequential_hooks/_runner.py +48 -0
  31. sequential_hooks/_types.py +125 -0
  32. sequential_hooks/hosts/__init__.py +130 -0
  33. sequential_hooks/hosts/_contract.py +434 -0
  34. sequential_hooks/hosts/_inspection.py +78 -0
  35. sequential_hooks/hosts/_json.py +150 -0
  36. sequential_hooks/hosts/_records.py +273 -0
  37. sequential_hooks/hosts/_skeleton.py +1326 -0
  38. sequential_hooks/py.typed +0 -0
  39. sequential_hooks-0.1.0.dist-info/METADATA +90 -0
  40. sequential_hooks-0.1.0.dist-info/RECORD +43 -0
  41. sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
  42. sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
  43. 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)
@@ -0,0 +1,3 @@
1
+ """Deterministic, host-aware composition for coding-agent hooks."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,5 @@
1
+ """Run the sequential-hooks command-line entry point."""
2
+
3
+ from sequential_hooks._cli import main
4
+
5
+ raise SystemExit(main())