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,448 @@
1
+ # pyright: reportPrivateUsage=false
2
+
3
+ """Analyze shared doctor registration and platform contracts."""
4
+
5
+ import math
6
+ import platform
7
+ import sys
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ from sequential_hooks._containment import probe_current_containment
14
+ from sequential_hooks._doctor._command import is_windows_remote_executable
15
+ from sequential_hooks._doctor._types import (
16
+ ContractError,
17
+ Finding,
18
+ FindingSeverity,
19
+ InspectedCommand,
20
+ PlatformReport,
21
+ Registration,
22
+ )
23
+ from sequential_hooks._executable import ExecutableStatus, resolve_executable
24
+ from sequential_hooks.hosts import HostSpec
25
+
26
+ _ALL_MATCHERS = frozenset({'', '*', '.*'})
27
+ _MINIMUM_OUTER_TIMEOUT = 5.0
28
+ _SAFE_SHIM_BASENAME_LIMIT = 40
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class _ExecutableEnvironment:
33
+ """Describe the environment used to resolve inspected executables."""
34
+
35
+ _path_ext: str | None
36
+ _path_value: str | None
37
+ _windows: bool
38
+
39
+
40
+ def _expected_contract_category(
41
+ category: ContractError,
42
+ *,
43
+ requires_event: bool,
44
+ ) -> ContractError:
45
+ """Prioritize the expected event-requiring host over an embedded host's event rule."""
46
+ if requires_event and category is ContractError.EVENT_FORBIDDEN:
47
+ return ContractError.WRONG_HOST
48
+ return category
49
+
50
+
51
+ def _finding(
52
+ severity: FindingSeverity,
53
+ code: str,
54
+ message: str,
55
+ correction: str | None = None,
56
+ ) -> Finding:
57
+ """Build one bounded finding."""
58
+ return Finding(severity, code, message, correction)
59
+
60
+
61
+ def _contract_finding(
62
+ category: ContractError,
63
+ spec: HostSpec,
64
+ registration: Registration,
65
+ ) -> Finding:
66
+ """Build shared wrapper-contract text for complete and partial invocations."""
67
+ corrections = {
68
+ ContractError.EVENT_FORBIDDEN: (f'Use --agent {spec.name} and remove the --event option.'),
69
+ ContractError.EVENT_MISSING: f'Use --event {registration.event}.',
70
+ ContractError.HOST_MISSING: f'Use --agent {spec.name}.',
71
+ ContractError.WRONG_EVENT: f'Use --event {registration.event}.',
72
+ ContractError.WRONG_HOST: f'Use --agent {spec.name}.',
73
+ }
74
+ messages = {
75
+ ContractError.EVENT_FORBIDDEN: 'wrapper event is not accepted for this agent',
76
+ ContractError.EVENT_MISSING: 'wrapper event is missing',
77
+ ContractError.HOST_MISSING: 'wrapper agent is missing',
78
+ ContractError.WRONG_EVENT: 'wrapper event does not match the registered event',
79
+ ContractError.WRONG_HOST: 'wrapper agent does not match the configured agent',
80
+ }
81
+ return _finding(
82
+ FindingSeverity.ERROR,
83
+ category.value,
84
+ messages[category],
85
+ corrections[category],
86
+ )
87
+
88
+
89
+ def _invocation_contract(
90
+ spec: HostSpec,
91
+ registration: Registration,
92
+ invocation_host: str,
93
+ invocation_event: str | None,
94
+ ) -> tuple[Finding, ...]:
95
+ """Analyze one wrapper agent and event contract."""
96
+ findings: list[Finding] = []
97
+ if invocation_host != spec.name:
98
+ findings.append(_contract_finding(ContractError.WRONG_HOST, spec, registration))
99
+ if registration.event not in spec.events:
100
+ findings.append(
101
+ _finding(
102
+ FindingSeverity.ERROR,
103
+ 'native_event_unsupported',
104
+ 'wrapper is registered under a native event it cannot serve',
105
+ f'Register the sequential-hooks wrapper only under {", ".join(spec.events)}.',
106
+ )
107
+ )
108
+ return tuple(findings)
109
+ if spec.requires_event and invocation_event is None:
110
+ findings.append(_contract_finding(ContractError.EVENT_MISSING, spec, registration))
111
+ elif spec.requires_event and invocation_event != registration.event:
112
+ findings.append(_contract_finding(ContractError.WRONG_EVENT, spec, registration))
113
+ elif not spec.requires_event and invocation_event is not None:
114
+ findings.append(_contract_finding(ContractError.EVENT_FORBIDDEN, spec, registration))
115
+ return tuple(findings)
116
+
117
+
118
+ def _matcher_overlap(first: str | None, second: str | None) -> str:
119
+ """Classify a matcher pair without evaluating either expression."""
120
+ if first == second or first is None or second is None:
121
+ return 'definite'
122
+ if first in _ALL_MATCHERS or second in _ALL_MATCHERS:
123
+ return 'definite'
124
+ return 'possible'
125
+
126
+
127
+ def _registration_state(spec: HostSpec, registration: Registration) -> tuple[Finding, ...]:
128
+ """Analyze one registration's enabled and outer-timeout state."""
129
+ findings: list[Finding] = []
130
+ timeout = cast('object', registration.timeout)
131
+ if registration.enabled is False:
132
+ findings.append(
133
+ _finding(FindingSeverity.INFO, 'registration_disabled', 'registration is disabled')
134
+ )
135
+ elif registration.enabled is None:
136
+ findings.append(
137
+ _finding(
138
+ FindingSeverity.WARNING,
139
+ 'enabled_unknown',
140
+ 'registration enabled state cannot be inspected statically',
141
+ (
142
+ 'Reconcile disableAllHooks values in the discovered Claude settings sources.'
143
+ if spec.name == 'claude'
144
+ else 'Reconcile hook activation settings in the discovered sources.'
145
+ ),
146
+ )
147
+ )
148
+ if timeout is None:
149
+ findings.append(
150
+ _finding(
151
+ FindingSeverity.WARNING,
152
+ 'outer_timeout_missing',
153
+ 'outer handler timeout is missing',
154
+ f'Set an outer handler timeout of at least {_MINIMUM_OUTER_TIMEOUT:g} seconds.',
155
+ )
156
+ )
157
+ elif not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
158
+ findings.append(
159
+ _finding(
160
+ FindingSeverity.ERROR,
161
+ 'outer_timeout_malformed',
162
+ 'outer handler timeout is not a positive finite number',
163
+ f'Set an outer handler timeout of at least {_MINIMUM_OUTER_TIMEOUT:g} seconds.',
164
+ )
165
+ )
166
+ elif timeout < _MINIMUM_OUTER_TIMEOUT:
167
+ findings.append(
168
+ _finding(
169
+ FindingSeverity.WARNING,
170
+ 'outer_timeout_too_small',
171
+ 'outer handler timeout may expire before cleanup completes',
172
+ f'Set an outer handler timeout of at least {_MINIMUM_OUTER_TIMEOUT:g} seconds.',
173
+ )
174
+ )
175
+ return tuple(findings)
176
+
177
+
178
+ def _safe_shim_path(resolved: Path | None, *, wrapper: bool) -> str:
179
+ """Return a bounded shim identifier without directory disclosure."""
180
+ if resolved is None:
181
+ return '[redacted]/[shim]'
182
+ name = resolved.name
183
+ suffix = resolved.suffix.casefold()
184
+ if wrapper and name.casefold() in {
185
+ 'sequential-hooks.bat',
186
+ 'sequential-hooks.cmd',
187
+ 'sequential-hooks.ps1',
188
+ }:
189
+ basename = name[:_SAFE_SHIM_BASENAME_LIMIT]
190
+ else:
191
+ basename = f'[shim]{suffix}' if suffix in {'.bat', '.cmd', '.ps1'} else '[shim]'
192
+ return f'[redacted]/{basename}'
193
+
194
+
195
+ def _analyze_executable(
196
+ executable: str,
197
+ environment: _ExecutableEnvironment,
198
+ *,
199
+ wrapper: bool,
200
+ ) -> Finding | None:
201
+ """Return one executable resolution finding, if needed."""
202
+ if environment._windows and is_windows_remote_executable(executable):
203
+ return _finding(
204
+ FindingSeverity.WARNING,
205
+ 'command_uninspectable',
206
+ 'remote or device executable is not inspected statically',
207
+ 'Use a local executable path for static inspection; '
208
+ 'verify the remote or device command separately.',
209
+ )
210
+ resolution = resolve_executable(
211
+ executable,
212
+ path_value=environment._path_value,
213
+ windows=environment._windows,
214
+ pathext_value=environment._path_ext,
215
+ )
216
+ label = 'wrapper' if wrapper else 'child'
217
+ if resolution.status is ExecutableStatus.NOT_FOUND:
218
+ return _finding(
219
+ FindingSeverity.ERROR,
220
+ f'{label}_executable_missing',
221
+ f'{label} executable cannot be resolved',
222
+ f'Install or configure a runnable {label} executable.',
223
+ )
224
+ if resolution.status is ExecutableStatus.UNSUPPORTED_SHIM:
225
+ safe_path = _safe_shim_path(resolution.resolved, wrapper=wrapper)
226
+ return _finding(
227
+ FindingSeverity.ERROR,
228
+ f'{label}_executable_unsupported',
229
+ f'{label} executable resolves to an unsupported Windows shim at {safe_path}',
230
+ 'Replace the Windows batch shim with a real .exe or .com executable entry point.',
231
+ )
232
+ return None
233
+
234
+
235
+ def _analyze_invocation(
236
+ spec: HostSpec,
237
+ registration: Registration,
238
+ inspected: InspectedCommand,
239
+ environment: _ExecutableEnvironment,
240
+ ) -> tuple[Finding, ...]:
241
+ """Analyze one statically inspected registration."""
242
+ findings: list[Finding] = []
243
+ if inspected.dynamic:
244
+ findings.append(
245
+ _finding(
246
+ FindingSeverity.WARNING,
247
+ 'command_dynamic',
248
+ 'dynamic command cannot be judged healthy statically',
249
+ 'Use a loader-documented literal launch form.',
250
+ )
251
+ )
252
+ return tuple(findings)
253
+ if inspected.contract_error is not None:
254
+ category = _expected_contract_category(
255
+ inspected.contract_error,
256
+ requires_event=spec.requires_event,
257
+ )
258
+ findings.append(_contract_finding(category, spec, registration))
259
+ return tuple(findings)
260
+ if inspected.error is not None:
261
+ findings.append(
262
+ _finding(
263
+ FindingSeverity.WARNING,
264
+ 'command_uninspectable',
265
+ 'registration command cannot be inspected statically',
266
+ 'Use a loader-documented literal launch form '
267
+ 'and check the wrapper argument grammar.',
268
+ )
269
+ )
270
+ return tuple(findings)
271
+ if inspected.argv is None:
272
+ return tuple(findings)
273
+
274
+ wrapper = inspected.invocation is not None
275
+ executable_finding = _analyze_executable(
276
+ inspected.argv[0],
277
+ environment,
278
+ wrapper=wrapper,
279
+ )
280
+ if executable_finding is not None:
281
+ findings.append(executable_finding)
282
+ if inspected.invocation is None:
283
+ return tuple(findings)
284
+
285
+ invocation = inspected.invocation
286
+ findings.extend(
287
+ _invocation_contract(
288
+ spec,
289
+ registration,
290
+ invocation.host,
291
+ invocation.event,
292
+ )
293
+ )
294
+ for step in invocation.steps:
295
+ child_finding = _analyze_executable(
296
+ step.argv[0],
297
+ environment,
298
+ wrapper=False,
299
+ )
300
+ if child_finding is not None:
301
+ findings.append(child_finding)
302
+ if invocation.step_timeout is None:
303
+ findings.append(
304
+ _finding(
305
+ FindingSeverity.INFO,
306
+ 'outer_timeout_only',
307
+ 'no internal step timeout is configured; only the outer handler timeout applies',
308
+ 'Add --step-timeout if each child needs an independent bound.',
309
+ )
310
+ )
311
+ return tuple(findings)
312
+
313
+
314
+ def _same_child_argv(
315
+ first: tuple[str, ...],
316
+ second: tuple[str, ...],
317
+ *,
318
+ windows: bool,
319
+ ) -> bool:
320
+ """Compare child vectors using only executable platform folding."""
321
+ if not windows:
322
+ return first == second
323
+ return first[0].casefold() == second[0].casefold() and first[1:] == second[1:]
324
+
325
+
326
+ def analyze_registration(
327
+ spec: HostSpec,
328
+ inspected_registration: tuple[Registration, InspectedCommand],
329
+ *,
330
+ path_value: str | None,
331
+ path_ext: str | None,
332
+ windows: bool,
333
+ ) -> tuple[Finding, ...]:
334
+ """Return state and invocation findings for one inspected registration.
335
+
336
+ Args:
337
+ spec: Loaded host owning the event domain and event option rule.
338
+ inspected_registration: Registration and its existing command
339
+ inspection.
340
+ path_value: Search path used to resolve executables.
341
+ path_ext: Windows executable extensions used during resolution.
342
+ windows: Whether to use Windows launch and executable semantics.
343
+
344
+ Returns:
345
+ Registration state findings followed by invocation findings.
346
+ """
347
+ registration, inspected = inspected_registration
348
+ environment = _ExecutableEnvironment(
349
+ _path_ext=path_ext,
350
+ _path_value=path_value,
351
+ _windows=windows,
352
+ )
353
+ return (
354
+ *_registration_state(spec, registration),
355
+ *_analyze_invocation(
356
+ spec,
357
+ registration,
358
+ inspected,
359
+ environment,
360
+ ),
361
+ )
362
+
363
+
364
+ def analyze_registration_pairs(
365
+ inspected_registrations: Iterable[tuple[Registration, InspectedCommand]],
366
+ *,
367
+ windows: bool,
368
+ ) -> tuple[Finding, ...]:
369
+ """Return cross-registration findings from existing command inspections.
370
+
371
+ Args:
372
+ inspected_registrations: Registrations and inspections in loader order.
373
+ windows: Whether executable comparisons use Windows semantics.
374
+
375
+ Returns:
376
+ Findings for each earlier registration paired with each later one,
377
+ preserving input order.
378
+ """
379
+ inspected_registrations = tuple(inspected_registrations)
380
+ findings: list[Finding] = []
381
+ for first_index, (first, first_inspected) in enumerate(inspected_registrations):
382
+ for second_index in range(first_index + 1, len(inspected_registrations)):
383
+ second, second_inspected = inspected_registrations[second_index]
384
+ if first.event != second.event:
385
+ continue
386
+ if first_inspected.invocation is not None and second_inspected.invocation is not None:
387
+ if first.matcher == second.matcher:
388
+ findings.append(
389
+ _finding(
390
+ FindingSeverity.WARNING,
391
+ 'duplicate_wrapper',
392
+ 'event has repeated wrapper registrations',
393
+ 'Keep one wrapper registration for each event and matcher.',
394
+ )
395
+ )
396
+ overlap = _matcher_overlap(first.matcher, second.matcher)
397
+ findings.append(
398
+ _finding(
399
+ FindingSeverity.WARNING if overlap == 'definite' else FindingSeverity.INFO,
400
+ f'{overlap}_matcher_overlap',
401
+ f'{overlap} same-event matcher overlap',
402
+ 'Make same-event matcher scopes disjoint.',
403
+ )
404
+ )
405
+ wrapper, literal = (
406
+ (first_inspected, second_inspected)
407
+ if first_inspected.invocation is not None
408
+ else (second_inspected, first_inspected)
409
+ )
410
+ if (
411
+ wrapper.invocation is None
412
+ or literal.invocation is not None
413
+ or literal.argv is None
414
+ ):
415
+ continue
416
+ if any(
417
+ _same_child_argv(step.argv, literal.argv, windows=windows)
418
+ for step in wrapper.invocation.steps
419
+ ):
420
+ findings.append(
421
+ _finding(
422
+ FindingSeverity.WARNING,
423
+ 'duplicate_child_registration',
424
+ 'literal child duplicates a wrapper step',
425
+ 'Remove the separately registered literal child.',
426
+ )
427
+ )
428
+ return tuple(findings)
429
+
430
+
431
+ def inspect_platform() -> PlatformReport:
432
+ """Report interpreter and static containment availability.
433
+
434
+ Returns:
435
+ Current interpreter, containment classification, configuration result,
436
+ and bounded limitations.
437
+ """
438
+ interpreter = platform.python_implementation()
439
+ containment, configurable, limitations = probe_current_containment(
440
+ implementation=interpreter,
441
+ platform_name=sys.platform,
442
+ )
443
+ return PlatformReport(
444
+ interpreter,
445
+ containment.value,
446
+ configurable,
447
+ limitations,
448
+ )
@@ -0,0 +1,94 @@
1
+ """Define the doctor report types and re-export the published record types."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from sequential_hooks.hosts import (
7
+ ConfigSource,
8
+ ContractError,
9
+ Finding,
10
+ FindingSeverity,
11
+ InspectedCommand,
12
+ LaunchSemantics,
13
+ Registration,
14
+ SourceKind,
15
+ )
16
+
17
+ __all__ = [
18
+ 'ConfigSource',
19
+ 'ContractError',
20
+ 'DoctorReport',
21
+ 'Finding',
22
+ 'FindingSeverity',
23
+ 'InspectedCommand',
24
+ 'LaunchSemantics',
25
+ 'PlatformReport',
26
+ 'Registration',
27
+ 'SourceKind',
28
+ ]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PlatformReport:
33
+ """Describe an immutable static containment probe.
34
+
35
+ Args:
36
+ interpreter: Running Python implementation name.
37
+ containment: Selected containment-platform classification.
38
+ job_object_configurable: Whether Windows Job Object containment can be
39
+ configured, or `None` when inapplicable.
40
+ limitations: Bounded containment limitations in report order.
41
+
42
+ Attributes:
43
+ interpreter: Running Python implementation name.
44
+ containment: Selected containment-platform classification.
45
+ job_object_configurable: Whether Windows Job Object containment can be
46
+ configured, or `None` when inapplicable.
47
+ limitations: Bounded containment limitations in report order.
48
+ """
49
+
50
+ interpreter: str
51
+ containment: str
52
+ job_object_configurable: bool | None
53
+ limitations: tuple[str, ...]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class DoctorReport:
58
+ """Carry one immutable, ordered host inspection report.
59
+
60
+ Args:
61
+ host: Host configuration contract that was inspected.
62
+ executable: Resolved wrapper path when found, including an unsupported
63
+ shim.
64
+ version: Installed package version, `0.0.0` without installed metadata,
65
+ or `None` when unavailable.
66
+ platform: Static containment probe result.
67
+ sources: Configuration sources in discovery order.
68
+ registrations: Accepted registrations in loader order.
69
+ findings: Source problems first, then findings grouped by registration,
70
+ then cross-registration and aggregate findings.
71
+ limitations: Host inspection limitations in report order.
72
+
73
+ Attributes:
74
+ host: Host configuration contract that was inspected.
75
+ executable: Resolved wrapper path when found, including an unsupported
76
+ shim.
77
+ version: Installed package version, `0.0.0` without installed metadata,
78
+ or `None` when unavailable.
79
+ platform: Static containment probe result.
80
+ sources: Configuration sources in discovery order.
81
+ registrations: Accepted registrations in loader order.
82
+ findings: Source problems first, then findings grouped by registration,
83
+ then cross-registration and aggregate findings.
84
+ limitations: Host inspection limitations in report order.
85
+ """
86
+
87
+ host: str
88
+ executable: Path | None
89
+ version: str | None
90
+ platform: PlatformReport
91
+ sources: tuple[ConfigSource, ...]
92
+ registrations: tuple[Registration, ...]
93
+ findings: tuple[Finding, ...]
94
+ limitations: tuple[str, ...]