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,455 @@
1
+ """Inspect Claude Code hook configuration sources."""
2
+
3
+ import stat
4
+ import sys
5
+ from collections.abc import Collection, Iterable, Mapping
6
+ from dataclasses import dataclass, 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
+ _DisableSetting = tuple[bool | None, Finding | None, bool]
27
+
28
+ _LIMITATIONS = (
29
+ 'server-managed Claude Code settings cannot be inspected as a local file',
30
+ 'macOS com.anthropic.claudecode managed preferences or plist delivery cannot be inspected',
31
+ (
32
+ 'Windows HKLM and HKCU SOFTWARE\\Policies\\ClaudeCode Settings registry delivery '
33
+ 'cannot be inspected'
34
+ ),
35
+ 'plugin-provided Claude Code hooks cannot be inspected without scanning plugin caches',
36
+ )
37
+
38
+
39
+ class ClaudeDoctorLoader:
40
+ """Inspect the Claude Code settings files through the published `DoctorLoader` contract."""
41
+
42
+ def analyze_registration(
43
+ self,
44
+ registration: Registration,
45
+ inspected: InspectedCommand,
46
+ *,
47
+ windows: bool,
48
+ ) -> tuple[Finding, ...]:
49
+ """Return host-specific findings for one inspected registration.
50
+
51
+ Args:
52
+ registration: Accepted registration.
53
+ inspected: Static inspection of the registration's command.
54
+ windows: Whether Windows launch semantics apply.
55
+
56
+ Returns:
57
+ Findings in report order.
58
+ """
59
+ del registration, inspected, windows
60
+ return ()
61
+
62
+ def discover_sources(
63
+ self,
64
+ home: Path,
65
+ cwd: Path,
66
+ system_root: Path,
67
+ ) -> tuple[ConfigSource, ...]:
68
+ """Return every configuration source in deterministic inspection order.
69
+
70
+ Args:
71
+ home: Home directory used to locate user configuration.
72
+ cwd: Working directory used to locate project configuration.
73
+ system_root: Filesystem root used to locate system configuration.
74
+
75
+ Returns:
76
+ Candidate sources, existing or not, in inspection order.
77
+ """
78
+ return _discover_sources(home, cwd, system_root)
79
+
80
+ def inspection_limitations(self) -> tuple[str, ...]:
81
+ """Return configuration sources static inspection cannot establish.
82
+
83
+ Returns:
84
+ Stable limitation descriptions in report order.
85
+ """
86
+ return _inspection_limitations()
87
+
88
+ def load_registrations(
89
+ self,
90
+ sources: tuple[ConfigSource, ...],
91
+ ) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
92
+ """Extract registrations and structural findings in source order.
93
+
94
+ Args:
95
+ sources: Sources returned by `discover_sources`.
96
+
97
+ Returns:
98
+ Accepted registrations and source-isolated findings.
99
+ """
100
+ return _load_registrations(sources)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class _Activation:
105
+ """Describe activation established from inspected settings files."""
106
+
107
+ _managed: bool | None
108
+ _non_managed: bool | None
109
+
110
+
111
+ def _activation(
112
+ sources: Collection[ConfigSource],
113
+ settings: Mapping[Path, _DisableSetting],
114
+ ) -> _Activation:
115
+ """Resolve inspected-file activation without inventing same-scope order."""
116
+ by_scope = {
117
+ scope: [
118
+ value
119
+ for source in sources
120
+ if source.kind is scope
121
+ for value, _finding_value, rejected in (settings[source.path],)
122
+ if value is not None and not rejected
123
+ ]
124
+ for scope in (
125
+ SourceKind.MANAGED,
126
+ SourceKind.LOCAL,
127
+ SourceKind.PROJECT,
128
+ SourceKind.USER,
129
+ )
130
+ }
131
+ winning_scope = next((scope for scope, values in by_scope.items() if values), None)
132
+ if winning_scope is None:
133
+ return _Activation(_managed=True, _non_managed=True)
134
+ values = by_scope[winning_scope]
135
+ if winning_scope is SourceKind.MANAGED:
136
+ disabled = values[-1]
137
+ elif len(set(values)) == 1:
138
+ disabled = values[0]
139
+ else:
140
+ return _Activation(_managed=True, _non_managed=None)
141
+ if not disabled:
142
+ return _Activation(_managed=True, _non_managed=True)
143
+ if winning_scope is SourceKind.MANAGED:
144
+ return _Activation(_managed=False, _non_managed=False)
145
+ return _Activation(_managed=True, _non_managed=False)
146
+
147
+
148
+ def _enabled(source: ConfigSource, activation: _Activation) -> bool | None:
149
+ """Return the inspected activation state for one registration source."""
150
+ if source.kind is SourceKind.MANAGED:
151
+ return activation._managed # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
152
+ return activation._non_managed # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
153
+
154
+
155
+ def _finding(source: ConfigSource, code: str, message: str) -> Finding:
156
+ """Build a source-specific structural finding."""
157
+ return Finding(
158
+ FindingSeverity.WARNING,
159
+ code,
160
+ f'Claude configuration at {source.path} {message}',
161
+ )
162
+
163
+
164
+ def _disable_setting(
165
+ source: ConfigSource,
166
+ loaded: dict[str, object] | None,
167
+ ) -> _DisableSetting:
168
+ """Return the visible setting, finding, and whole-source rejection state."""
169
+ if loaded is None or 'disableAllHooks' not in loaded:
170
+ return None, None, False
171
+ value = loaded['disableAllHooks']
172
+ if isinstance(value, bool):
173
+ return value, None, False
174
+ finding = _finding(
175
+ source,
176
+ 'claude_disable_all_hooks_malformed',
177
+ 'contains a malformed disableAllHooks setting',
178
+ )
179
+ return None, finding, source.kind is not SourceKind.MANAGED
180
+
181
+
182
+ def _hook_validation_finding(
183
+ source: ConfigSource,
184
+ hook: object,
185
+ ) -> Finding | None:
186
+ """Return the first structural problem in one hook."""
187
+ finding = None
188
+ if not isinstance(hook, dict):
189
+ finding = _finding(
190
+ source,
191
+ 'claude_hook_malformed',
192
+ 'contains a malformed hook',
193
+ )
194
+ else:
195
+ hook_data = cast('dict[str, object]', hook)
196
+ if hook_data.get('type') != 'command':
197
+ finding = _finding(
198
+ source,
199
+ 'claude_hook_type_unsupported',
200
+ 'contains a hook whose type is not the documented command form',
201
+ )
202
+ elif not isinstance(hook_data.get('command'), str) or not hook_data.get('command'):
203
+ finding = _finding(
204
+ source,
205
+ 'claude_command_malformed',
206
+ 'contains a command hook without a string command',
207
+ )
208
+ elif 'args' in hook_data and (
209
+ not isinstance(raw_args := hook_data['args'], list)
210
+ or not all(isinstance(argument, str) for argument in cast('list[object]', raw_args))
211
+ ):
212
+ finding = _finding(
213
+ source,
214
+ 'claude_args_malformed',
215
+ 'contains a command hook with malformed args',
216
+ )
217
+ elif (
218
+ 'timeout' in hook_data
219
+ and parse_timeout(
220
+ hook_data['timeout'],
221
+ positive=False,
222
+ )
223
+ is None
224
+ ):
225
+ finding = _finding(
226
+ source,
227
+ 'claude_timeout_malformed',
228
+ 'contains a command hook with a malformed timeout',
229
+ )
230
+ return finding
231
+
232
+
233
+ def _inspection_limitations() -> tuple[str, ...]:
234
+ """Return Claude sources static inspection cannot establish."""
235
+ return _LIMITATIONS
236
+
237
+
238
+ def _load_source(source: ConfigSource) -> tuple[dict[str, object] | None, Finding | None]:
239
+ """Load one source without exposing content in diagnostics."""
240
+ try:
241
+ text = source.path.read_text(encoding='utf-8')
242
+ except UnicodeDecodeError:
243
+ return None, _finding(
244
+ source,
245
+ 'claude_source_encoding_malformed',
246
+ 'contains malformed UTF-8',
247
+ )
248
+ except OSError:
249
+ return None, _finding(source, 'claude_source_unreadable', 'cannot be read')
250
+ try:
251
+ loaded = load_object_text(text)
252
+ except JsonObjectExpectedError:
253
+ return None, _finding(source, 'claude_settings_malformed', 'must contain a JSON object')
254
+ except JsonProtocolError:
255
+ return None, _finding(source, 'claude_json_malformed', 'contains malformed JSON')
256
+ return cast('dict[str, object]', loaded), None
257
+
258
+
259
+ def _managed_directory(system_root: Path) -> Path:
260
+ """Return the documented managed-settings directory for this platform."""
261
+ if sys.platform == 'darwin':
262
+ return system_root / 'Library/Application Support/ClaudeCode'
263
+ if sys.platform == 'win32':
264
+ return system_root / 'Program Files/ClaudeCode'
265
+ return system_root / 'etc/claude-code'
266
+
267
+
268
+ def _managed_drop_ins(directory: Path) -> tuple[ConfigSource, ...]:
269
+ """Return documented managed drop-ins or isolate enumeration failure."""
270
+ try:
271
+ if not stat.S_ISDIR(directory.stat().st_mode):
272
+ return ()
273
+ paths = tuple(directory.iterdir())
274
+ except FileNotFoundError:
275
+ return ()
276
+ except OSError:
277
+ return (ConfigSource(directory, SourceKind.MANAGED, exists=True),)
278
+ return tuple(
279
+ ConfigSource(path, SourceKind.MANAGED, exists=True)
280
+ for path in sorted(paths, key=lambda item: item.name)
281
+ if path.is_file() and not path.name.startswith('.') and path.suffix == '.json'
282
+ )
283
+
284
+
285
+ def _discover_sources(
286
+ home: Path,
287
+ cwd: Path,
288
+ system_root: Path,
289
+ ) -> tuple[ConfigSource, ...]:
290
+ """Return Claude sources in deterministic inspection order."""
291
+ paths: list[tuple[Path, SourceKind]] = [(home / '.claude/settings.json', SourceKind.USER)]
292
+ for layer in project_layers(cwd):
293
+ paths.extend(
294
+ (
295
+ (layer / '.claude/settings.json', SourceKind.PROJECT),
296
+ (layer / '.claude/settings.local.json', SourceKind.LOCAL),
297
+ )
298
+ )
299
+ managed_directory = _managed_directory(system_root)
300
+ paths.append((managed_directory / 'managed-settings.json', SourceKind.MANAGED))
301
+ drop_in_directory = managed_directory / 'managed-settings.d'
302
+ sources = tuple(
303
+ ConfigSource(path, kind, path.exists()) for path, kind in deduplicate_source_paths(paths)
304
+ )
305
+ return (*sources, *_managed_drop_ins(drop_in_directory))
306
+
307
+
308
+ def _registration(
309
+ source: ConfigSource,
310
+ event: str,
311
+ matcher: str | None,
312
+ hook: object,
313
+ *,
314
+ enabled: bool | None,
315
+ ) -> tuple[Registration | None, Finding | None]:
316
+ """Parse one command hook."""
317
+ validation_finding = _hook_validation_finding(source, hook)
318
+ if validation_finding is not None:
319
+ return None, validation_finding
320
+ hook_data = cast('dict[str, object]', hook)
321
+ command = cast('str', hook_data.get('command'))
322
+ raw_args = cast('list[str] | None', hook_data.get('args'))
323
+ raw_timeout = hook_data.get('timeout')
324
+
325
+ args = tuple(raw_args) if isinstance(raw_args, list) else None
326
+ direct = args is not None
327
+ registration = Registration(
328
+ source=source,
329
+ event=event,
330
+ matcher=matcher,
331
+ command=command,
332
+ args=args,
333
+ launch_semantics=(LaunchSemantics.DIRECT_ARGV if direct else LaunchSemantics.POSIX_SHELL),
334
+ command_windows=None,
335
+ windows_launch_semantics=(
336
+ LaunchSemantics.DIRECT_ARGV if direct else LaunchSemantics.HOST_TOKENIZER
337
+ ),
338
+ timeout=parse_timeout(raw_timeout, positive=False),
339
+ enabled=enabled,
340
+ type=cast('str', hook_data['type']),
341
+ )
342
+ if direct:
343
+ return registration, None
344
+ if sys.platform != 'win32':
345
+ finding = Finding(
346
+ FindingSeverity.INFO,
347
+ 'claude_exec_form_recommended',
348
+ 'Claude Code command strings use a POSIX shell',
349
+ 'Use the exec form with explicit args.',
350
+ )
351
+ else:
352
+ finding = Finding(
353
+ FindingSeverity.INFO,
354
+ 'claude_windows_shell_conditional',
355
+ 'Claude Code command strings use Git Bash on Windows when statically '
356
+ 'established as available, otherwise PowerShell; shell availability was not probed',
357
+ 'Use the exec form with explicit args, especially on Windows.',
358
+ )
359
+
360
+ return replace(registration, configuration_findings=(finding,)), None
361
+
362
+
363
+ def _event_registrations(
364
+ source: ConfigSource,
365
+ event: object,
366
+ groups: object,
367
+ *,
368
+ enabled: bool | None,
369
+ ) -> tuple[list[Registration], list[Finding]]:
370
+ """Parse one event layer."""
371
+ if not isinstance(event, str) or not isinstance(groups, list):
372
+ return [], [_finding(source, 'claude_event_malformed', 'contains a malformed event')]
373
+ registrations: list[Registration] = []
374
+ findings: list[Finding] = []
375
+ for group in cast('list[object]', groups):
376
+ if not isinstance(group, dict):
377
+ findings.append(
378
+ _finding(
379
+ source, 'claude_matcher_group_malformed', 'contains a malformed matcher group'
380
+ )
381
+ )
382
+ continue
383
+ group_data = cast('dict[str, object]', group)
384
+ matcher = group_data.get('matcher')
385
+ if 'matcher' in group_data and not isinstance(matcher, str):
386
+ findings.append(
387
+ _finding(source, 'claude_matcher_malformed', 'contains a malformed matcher')
388
+ )
389
+ continue
390
+ parsed_matcher = cast('str | None', matcher)
391
+ hooks = group_data.get('hooks')
392
+ if not isinstance(hooks, list):
393
+ findings.append(
394
+ _finding(source, 'claude_hooks_malformed', 'contains a malformed hooks list')
395
+ )
396
+ continue
397
+ for hook in cast('list[object]', hooks):
398
+ registration, finding = _registration(
399
+ source,
400
+ event,
401
+ parsed_matcher,
402
+ hook,
403
+ enabled=enabled,
404
+ )
405
+ if registration is not None:
406
+ registrations.append(registration)
407
+ if finding is not None:
408
+ findings.append(finding)
409
+ return registrations, findings
410
+
411
+
412
+ def _load_registrations(
413
+ sources: Iterable[ConfigSource],
414
+ ) -> tuple[tuple[Registration, ...], tuple[Finding, ...]]:
415
+ """Extract registrations and findings in source order."""
416
+ source_list = tuple(sources)
417
+ loaded_sources = tuple(
418
+ (source, *_load_source(source)) if source.exists else (source, None, None)
419
+ for source in source_list
420
+ )
421
+ settings = {
422
+ source.path: _disable_setting(source, loaded)
423
+ for source, loaded, _finding_value in loaded_sources
424
+ }
425
+ activation = _activation(source_list, settings)
426
+ registrations: list[Registration] = []
427
+ findings: list[Finding] = []
428
+ for source, loaded, load_finding in loaded_sources:
429
+ if load_finding is not None:
430
+ findings.append(load_finding)
431
+ continue
432
+ _value, setting_finding, rejected = settings[source.path]
433
+ if setting_finding is not None:
434
+ findings.append(setting_finding)
435
+ if loaded is None or rejected:
436
+ continue
437
+ hooks = loaded.get('hooks')
438
+ if hooks is None:
439
+ continue
440
+ if not isinstance(hooks, dict):
441
+ findings.append(
442
+ _finding(source, 'claude_hooks_malformed', 'contains a malformed hooks object')
443
+ )
444
+ continue
445
+ hook_events = cast('dict[str, object]', hooks)
446
+ for event, groups in hook_events.items():
447
+ event_registrations, event_findings = _event_registrations(
448
+ source,
449
+ event,
450
+ groups,
451
+ enabled=_enabled(source, activation),
452
+ )
453
+ registrations.extend(event_registrations)
454
+ findings.extend(event_findings)
455
+ return tuple(registrations), tuple(findings)
@@ -0,0 +1 @@
1
+ """Provide the private Codex adapter implementation."""