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,469 @@
1
+ # pyright: reportPrivateUsage=false
2
+
3
+ """Inspect registered hook commands without executing them."""
4
+
5
+ import math
6
+ import re
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from itertools import pairwise
10
+ from pathlib import Path, PureWindowsPath
11
+
12
+ from sequential_hooks._arguments import InvocationError, parse_hook_argv
13
+ from sequential_hooks._doctor._types import (
14
+ ContractError,
15
+ InspectedCommand,
16
+ LaunchSemantics,
17
+ Registration,
18
+ )
19
+ from sequential_hooks._registry import host_requirements
20
+
21
+ _ENVIRONMENT_PREFIX = re.compile(r'^\s*[A-Za-z_][A-Za-z0-9_]*=')
22
+ _NEGATION_PREFIX = re.compile(r'^\s*!(?:\s|$)')
23
+ _SECRET_SEGMENTS = frozenset({'apikey', 'credential', 'password', 'secret', 'token'})
24
+ _SUMMARY_LIMIT = 240
25
+ _WRAPPER_NAMES = frozenset({'sequential-hooks', 'sequential-hooks.exe'})
26
+
27
+
28
+ @dataclass
29
+ class _CommandLexState:
30
+ """Retain lexical state while reading one raw command token."""
31
+
32
+ _equals: int | None
33
+ _escaped: bool
34
+ _logical: list[str]
35
+ _outer_quote_end: int | None
36
+ _quote: str | None
37
+ _value_start: int | None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class _CommandToken:
42
+ """Describe one raw command token and its lexical content."""
43
+
44
+ _start: int
45
+ _end: int
46
+ _content_end: int
47
+ _text: str
48
+ _value_start: int | None
49
+
50
+
51
+ @dataclass
52
+ class _ContractState:
53
+ """Retain a safely parsed leading wrapper-option prefix."""
54
+
55
+ _allow_seen: bool = False
56
+ _event_seen: bool = False
57
+ _event: str | None = None
58
+ _host_seen: bool = False
59
+ _host: str | None = None
60
+ _timeout_seen: bool = False
61
+
62
+
63
+ def _append_token_character(state: _CommandLexState, character: str, index: int) -> None:
64
+ """Append one logical token character and retain its raw equals span."""
65
+ state._logical.append(character)
66
+ if character == '=' and state._equals is None:
67
+ state._equals = index
68
+ state._value_start = index + 1
69
+
70
+
71
+ def _consume_contract_option(
72
+ state: _ContractState,
73
+ option: str,
74
+ value: str,
75
+ ) -> bool:
76
+ """Consume one value-bearing option when it is well formed."""
77
+ if not value:
78
+ return False
79
+ valid = True
80
+ if option in {'-e', '--event'}:
81
+ if state._event_seen:
82
+ valid = False
83
+ else:
84
+ state._event_seen = True
85
+ state._event = value
86
+ elif option in {'-a', '--agent'}:
87
+ if state._host_seen:
88
+ valid = False
89
+ else:
90
+ state._host_seen = True
91
+ state._host = value
92
+ elif state._timeout_seen:
93
+ valid = False
94
+ else:
95
+ state._timeout_seen = True
96
+ try:
97
+ timeout = float(value)
98
+ except ValueError:
99
+ valid = False
100
+ else:
101
+ valid = math.isfinite(timeout) and timeout > 0
102
+ return valid
103
+
104
+
105
+ def _consume_token_character(
106
+ state: _CommandLexState,
107
+ character: str,
108
+ index: int,
109
+ *,
110
+ leading_quote: str | None,
111
+ ) -> bool:
112
+ """Consume one source character or identify an unquoted token boundary."""
113
+ if state._escaped:
114
+ if character != '\n':
115
+ _append_token_character(state, character, index)
116
+ state._escaped = False
117
+ elif character == '\\' and state._quote != "'":
118
+ state._escaped = True
119
+ elif character in {"'", '"'} and state._quote in {None, character}:
120
+ if state._quote is None:
121
+ state._quote = character
122
+ else:
123
+ if state._value_start == index:
124
+ state._value_start = index + 1
125
+ if leading_quote == character and state._outer_quote_end is None:
126
+ state._outer_quote_end = index
127
+ state._quote = None
128
+ elif state._quote is None and character.isspace():
129
+ return False
130
+ else:
131
+ _append_token_character(state, character, index)
132
+ return True
133
+
134
+
135
+ def _effective_command(registration: Registration, *, windows: bool) -> str:
136
+ """Return the command active on the selected platform."""
137
+ if windows and registration.command_windows is not None:
138
+ return registration.command_windows
139
+ return registration.command
140
+
141
+
142
+ def _is_unquoted_dynamic(character: str, *, word_start: bool) -> bool:
143
+ """Return whether one unquoted character requires shell evaluation."""
144
+ return character in {'(', ')', '|', '>', '<', ';', '&', '*', '?', '['} or (
145
+ character == '~' and word_start
146
+ )
147
+
148
+
149
+ def _leading_contract(
150
+ argv: tuple[str, ...],
151
+ ) -> tuple[str | None, bool, str | None] | None:
152
+ """Parse a complete leading wrapper-option prefix conservatively."""
153
+ index = 1
154
+ state = _ContractState()
155
+ valid = True
156
+ while valid and index < len(argv) and argv[index].startswith('-'):
157
+ option = argv[index]
158
+ if option == '--allow-on-failure':
159
+ valid = not state._allow_seen
160
+ state._allow_seen = True
161
+ index += 1
162
+ continue
163
+ has_value = index + 1 < len(argv) and not argv[index + 1].startswith('-')
164
+ if option not in {'-a', '-e', '--event', '--agent', '--step-timeout'} or not has_value:
165
+ valid = False
166
+ continue
167
+ valid = _consume_contract_option(state, option, argv[index + 1])
168
+ index += 2
169
+ if not valid or index >= len(argv):
170
+ return None
171
+ return state._host, state._event_seen, state._event
172
+
173
+
174
+ def _next_command_token(command: str, start: int) -> tuple[_CommandToken | None, int]:
175
+ """Read one quote- and escape-aware token without normalizing source text."""
176
+ index = start
177
+ while index < len(command) and command[index].isspace():
178
+ index += 1
179
+ if index == len(command):
180
+ return None, index
181
+
182
+ token_start = index
183
+ leading_quote = command[index] if command[index] in {"'", '"'} else None
184
+ state = _CommandLexState(
185
+ _equals=None,
186
+ _escaped=False,
187
+ _logical=[],
188
+ _outer_quote_end=None,
189
+ _quote=None,
190
+ _value_start=None,
191
+ )
192
+ while index < len(command) and _consume_token_character(
193
+ state,
194
+ command[index],
195
+ index,
196
+ leading_quote=leading_quote,
197
+ ):
198
+ index += 1
199
+ if state._escaped:
200
+ state._logical.append('\\')
201
+
202
+ content_end = index - (1 if state._outer_quote_end == index - 1 else 0)
203
+ value_start = None if state._value_start is None else min(state._value_start, content_end)
204
+ return (
205
+ _CommandToken(
206
+ _start=token_start,
207
+ _end=index,
208
+ _content_end=content_end,
209
+ _text=''.join(state._logical),
210
+ _value_start=value_start,
211
+ ),
212
+ index,
213
+ )
214
+
215
+
216
+ def _normalized_secret_name(name: str) -> str:
217
+ """Fold separator spellings before secret-marker comparison."""
218
+ return name.casefold().replace('_', '-').replace('.', '-')
219
+
220
+
221
+ def _is_secret_assignment_name(name: str) -> bool:
222
+ """Return whether one non-option assignment name carries a secret."""
223
+ if name.startswith('--') or any(character.isspace() for character in name):
224
+ return False
225
+ folded = _normalized_secret_name(name)
226
+ return any(
227
+ marker in folded
228
+ for marker in ('api-key', 'apikey', 'credential', 'password', 'secret', 'token')
229
+ )
230
+
231
+
232
+ def _is_secret_option(name: str) -> bool:
233
+ """Return whether one complete long-option name carries a secret."""
234
+ if not name.startswith('--'):
235
+ return False
236
+ segments = _normalized_secret_name(name[2:]).split('-')
237
+ if not segments or segments[0] == 'no':
238
+ return False
239
+ stems = [
240
+ segment[:-1] if len(segment) > 1 and segment.endswith('s') else segment
241
+ for segment in segments
242
+ ]
243
+ if any(stem in _SECRET_SEGMENTS for stem in stems):
244
+ return True
245
+ return any(first == 'api' and second == 'key' for first, second in pairwise(stems))
246
+
247
+
248
+ def _redact_command_text(command: str) -> str:
249
+ """Redact secret value spans found by one bounded lexical pass."""
250
+ spans: list[tuple[int, int]] = []
251
+ pending_secret_option = False
252
+ position = 0
253
+ while True:
254
+ token, position = _next_command_token(command, position)
255
+ if token is None:
256
+ break
257
+ if pending_secret_option:
258
+ pending_secret_option = False
259
+ if not token._text.startswith('-'):
260
+ spans.append((token._start, token._end))
261
+ continue
262
+
263
+ if token._value_start is None:
264
+ pending_secret_option = _is_secret_option(token._text)
265
+ continue
266
+
267
+ name = token._text.partition('=')[0]
268
+ if _is_secret_option(name) or _is_secret_assignment_name(name):
269
+ spans.append((token._value_start, token._content_end))
270
+
271
+ parts: list[str] = []
272
+ cursor = 0
273
+ for start, end in spans:
274
+ parts.extend((command[cursor:start], '[REDACTED]'))
275
+ cursor = end
276
+ parts.append(command[cursor:])
277
+ return ''.join(parts)
278
+
279
+
280
+ def _static_posix_command(command: str) -> str | None:
281
+ """Return the statically inspectable prefix, or none for dynamic syntax."""
282
+ if _ENVIRONMENT_PREFIX.match(command) or _NEGATION_PREFIX.match(command):
283
+ return None
284
+ comment_start: int | None = None
285
+ quote: str | None = None
286
+ escaped = False
287
+ word_start = True
288
+ for index, character in enumerate(command):
289
+ if character == '\n':
290
+ return None
291
+ if escaped or comment_start is not None:
292
+ escaped = False
293
+ word_start = False
294
+ continue
295
+ if character == '\\' and quote != "'":
296
+ escaped = True
297
+ continue
298
+ if character in {"'", '"'} and quote in {None, character}:
299
+ quote = character if quote is None else None
300
+ word_start = False
301
+ continue
302
+ if character == '#' and quote is None and word_start:
303
+ comment_start = index
304
+ continue
305
+ if (character in {'$', '`'} and quote != "'") or (
306
+ quote is None and _is_unquoted_dynamic(character, word_start=word_start)
307
+ ):
308
+ return None
309
+ word_start = quote is None and character.isspace()
310
+ return command[:comment_start]
311
+
312
+
313
+ def _wrapper_contract_error(argv: tuple[str, ...]) -> ContractError | None:
314
+ """Classify only safely recognizable wrapper contract failures."""
315
+ contract = _leading_contract(argv)
316
+ if contract is None:
317
+ return None
318
+ host, event_seen, event = contract
319
+ hosts = host_requirements()
320
+ category: ContractError | None = None
321
+ if host is None:
322
+ category = ContractError.HOST_MISSING
323
+ elif host not in hosts:
324
+ category = ContractError.WRONG_HOST
325
+ elif hosts[host].requires_event:
326
+ if not event_seen:
327
+ category = ContractError.EVENT_MISSING
328
+ elif event not in hosts[host].events:
329
+ category = ContractError.WRONG_EVENT
330
+ elif event_seen:
331
+ category = ContractError.EVENT_FORBIDDEN
332
+ return category
333
+
334
+
335
+ def _wrapper_name(executable: str, *, windows: bool) -> str:
336
+ """Return the platform-correct executable basename."""
337
+ if windows:
338
+ return PureWindowsPath(executable).name.casefold()
339
+ return Path(executable).name
340
+
341
+
342
+ def _inspect_argv(argv: tuple[str, ...], *, windows: bool) -> InspectedCommand:
343
+ """Inspect one exact argument vector."""
344
+ if not argv or not argv[0]:
345
+ return InspectedCommand(
346
+ argv=argv or None,
347
+ invocation=None,
348
+ dynamic=False,
349
+ error='registration has no executable',
350
+ )
351
+ if _wrapper_name(argv[0], windows=windows) not in _WRAPPER_NAMES:
352
+ return InspectedCommand(argv=argv, invocation=None, dynamic=False, error=None)
353
+ try:
354
+ invocation = parse_hook_argv(argv[1:])
355
+ except InvocationError as error:
356
+ return InspectedCommand(
357
+ argv=argv,
358
+ invocation=None,
359
+ dynamic=False,
360
+ error=str(error),
361
+ contract_error=_wrapper_contract_error(argv),
362
+ )
363
+ return InspectedCommand(argv=argv, invocation=invocation, dynamic=False, error=None)
364
+
365
+
366
+ def _inspect_direct(
367
+ registration: Registration,
368
+ command: str,
369
+ *,
370
+ windows: bool,
371
+ ) -> InspectedCommand:
372
+ """Inspect one loader-verified direct argument vector."""
373
+ if registration.args is None:
374
+ return InspectedCommand(
375
+ argv=None,
376
+ invocation=None,
377
+ dynamic=False,
378
+ error='direct argv registration is missing args',
379
+ )
380
+ return _inspect_argv((command, *registration.args), windows=windows)
381
+
382
+
383
+ def _inspect_posix_shell(command: str) -> InspectedCommand:
384
+ """Inspect loader-verified static POSIX shell text."""
385
+ static_command = _static_posix_command(command)
386
+ if static_command is None:
387
+ return InspectedCommand(
388
+ argv=None,
389
+ invocation=None,
390
+ dynamic=True,
391
+ error='command contains dynamic shell syntax',
392
+ )
393
+ try:
394
+ argv = tuple(shlex.split(static_command, posix=True))
395
+ except ValueError:
396
+ return InspectedCommand(
397
+ argv=None,
398
+ invocation=None,
399
+ dynamic=False,
400
+ error='command has malformed POSIX quoting',
401
+ )
402
+ return _inspect_argv(argv, windows=False)
403
+
404
+
405
+ def inspect_command(
406
+ registration: Registration,
407
+ *,
408
+ windows: bool,
409
+ ) -> InspectedCommand:
410
+ """Inspect one literal registration without evaluating it.
411
+
412
+ Args:
413
+ registration: Host registration to inspect.
414
+ windows: Whether to apply the registration's Windows launch semantics.
415
+
416
+ Returns:
417
+ Static command inspection or a bounded reason it cannot be inspected.
418
+ """
419
+ if windows:
420
+ if registration.windows_launch_semantics is None:
421
+ return InspectedCommand(
422
+ argv=None,
423
+ invocation=None,
424
+ dynamic=False,
425
+ error='registration has no documented Windows launch form',
426
+ )
427
+ semantics = registration.windows_launch_semantics
428
+ command = _effective_command(registration, windows=True)
429
+ else:
430
+ semantics = registration.launch_semantics
431
+ command = _effective_command(registration, windows=False)
432
+
433
+ if semantics is LaunchSemantics.DIRECT_ARGV:
434
+ return _inspect_direct(registration, command, windows=windows)
435
+ if semantics is LaunchSemantics.POSIX_SHELL:
436
+ return _inspect_posix_shell(command)
437
+ return InspectedCommand(
438
+ argv=None,
439
+ invocation=None,
440
+ dynamic=False,
441
+ error=f'{semantics.value} launch semantics cannot be inspected statically',
442
+ )
443
+
444
+
445
+ def is_windows_remote_executable(executable: str) -> bool:
446
+ """Identify explicit Windows UNC or device tokens without filesystem access."""
447
+ return executable.replace('\\', '/').startswith('//')
448
+
449
+
450
+ def redact_command(registration: Registration, *, windows: bool = False) -> str:
451
+ """Return a safe registration summary for display.
452
+
453
+ Args:
454
+ registration: Host registration whose effective command is displayed.
455
+ windows: Whether to select the registration's Windows command.
456
+
457
+ Returns:
458
+ Redacted and length-bounded command summary.
459
+ """
460
+ command = _effective_command(registration, windows=windows)
461
+ if windows and is_windows_remote_executable(command):
462
+ return '[REDACTED] remote or device command'
463
+ if _ENVIRONMENT_PREFIX.match(command):
464
+ return '[REDACTED] environment command'
465
+
466
+ redacted = _redact_command_text(command)
467
+ if len(redacted) > _SUMMARY_LIMIT:
468
+ return f'{redacted[: _SUMMARY_LIMIT - 3]}...'
469
+ return redacted