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,239 @@
|
|
|
1
|
+
"""Run explicitly allowed children without process-tree containment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import TYPE_CHECKING, BinaryIO, cast
|
|
10
|
+
|
|
11
|
+
from sequential_hooks._containment import ContainmentError, start_process
|
|
12
|
+
from sequential_hooks._types import ContainmentState
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from sequential_hooks._containment import ManagedStep, StderrReader
|
|
16
|
+
from sequential_hooks._containment._windows_api import WindowsApi
|
|
17
|
+
from sequential_hooks._types import Step
|
|
18
|
+
|
|
19
|
+
_ABORT_GRACE_SECONDS = 1.0
|
|
20
|
+
_CREATE_NEW_PROCESS_GROUP = cast(
|
|
21
|
+
'int',
|
|
22
|
+
getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0x00000200),
|
|
23
|
+
)
|
|
24
|
+
_FORCE_KILL_WAIT_SECONDS = 1.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UncontainedBackend:
|
|
28
|
+
"""Create only explicitly authorized uncontained processes."""
|
|
29
|
+
|
|
30
|
+
def start(self, step: Step, *, allow_uncontained: bool) -> ManagedStep:
|
|
31
|
+
"""Create one explicitly authorized uncontained process.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
step: Literal argv to start.
|
|
35
|
+
allow_uncontained: Whether the user explicitly allowed fallback.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Fresh uncontained immediate process.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ContainmentError: If fallback was not explicitly allowed.
|
|
42
|
+
"""
|
|
43
|
+
if not allow_uncontained:
|
|
44
|
+
raise ContainmentError('uncontained execution requires an explicit override')
|
|
45
|
+
return _UncontainedManagedStep(step)
|
|
46
|
+
|
|
47
|
+
def recover_release_failure(
|
|
48
|
+
self,
|
|
49
|
+
step: Step,
|
|
50
|
+
failed: ManagedStep,
|
|
51
|
+
*,
|
|
52
|
+
allow_uncontained: bool,
|
|
53
|
+
) -> ManagedStep:
|
|
54
|
+
"""Return a fresh explicitly uncontained process.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
step: Step whose prepared process could not be released.
|
|
58
|
+
failed: Failed managed process already disposed by the executor.
|
|
59
|
+
allow_uncontained: Whether the user explicitly allowed fallback.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Fresh uncontained immediate process.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ContainmentError: If fallback was not explicitly allowed.
|
|
66
|
+
"""
|
|
67
|
+
del failed
|
|
68
|
+
return self.start(step, allow_uncontained=allow_uncontained)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _UncontainedManagedStep:
|
|
72
|
+
"""Own only one explicitly uncontained immediate process."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
step: Step,
|
|
77
|
+
*,
|
|
78
|
+
platform_name: str | None = None,
|
|
79
|
+
windows_api: WindowsApi | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Start one explicitly uncontained child and validate its pipes."""
|
|
82
|
+
selected_platform = sys.platform if platform_name is None else platform_name
|
|
83
|
+
self._graceful_signal_forwarded = False
|
|
84
|
+
self._windows_api = None
|
|
85
|
+
if selected_platform == 'win32':
|
|
86
|
+
if windows_api is None:
|
|
87
|
+
from sequential_hooks._containment._windows_api import ( # noqa: PLC0415
|
|
88
|
+
NativeWindowsApi,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
windows_api = NativeWindowsApi()
|
|
92
|
+
self._windows_api = windows_api
|
|
93
|
+
creationflags = _CREATE_NEW_PROCESS_GROUP if self._windows_api is not None else 0
|
|
94
|
+
self._process = start_process(
|
|
95
|
+
step,
|
|
96
|
+
contained=False,
|
|
97
|
+
creationflags=creationflags,
|
|
98
|
+
)
|
|
99
|
+
if (
|
|
100
|
+
self._process.stdin is None
|
|
101
|
+
or self._process.stdout is None
|
|
102
|
+
or self._process.stderr is None
|
|
103
|
+
):
|
|
104
|
+
self.terminate_tree(_ABORT_GRACE_SECONDS)
|
|
105
|
+
raise ContainmentError('uncontained child pipes were not created')
|
|
106
|
+
|
|
107
|
+
def _terminate_windows(self, api: WindowsApi, grace_seconds: float) -> None:
|
|
108
|
+
"""Reap the owned native process within the grace and force deadlines."""
|
|
109
|
+
grace_deadline = time.monotonic() + max(0.0, grace_seconds)
|
|
110
|
+
exited = self._process.poll() is not None
|
|
111
|
+
termination_requested = False
|
|
112
|
+
try:
|
|
113
|
+
if not exited and not self._graceful_signal_forwarded:
|
|
114
|
+
with contextlib.suppress(ProcessLookupError):
|
|
115
|
+
self._process.terminate()
|
|
116
|
+
termination_requested = True
|
|
117
|
+
if api.wait_process(self._process, max(0.0, grace_deadline - time.monotonic())):
|
|
118
|
+
self._process.wait(timeout=0.0)
|
|
119
|
+
return
|
|
120
|
+
force_deadline = time.monotonic() + _FORCE_KILL_WAIT_SECONDS
|
|
121
|
+
if not exited and not termination_requested:
|
|
122
|
+
self._process.kill()
|
|
123
|
+
if not api.wait_process(self._process, max(0.0, force_deadline - time.monotonic())):
|
|
124
|
+
raise ContainmentError(
|
|
125
|
+
'uncontained immediate process could not be reaped after kill'
|
|
126
|
+
)
|
|
127
|
+
self._process.wait(timeout=max(0.0, force_deadline - time.monotonic()))
|
|
128
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
129
|
+
raise ContainmentError(
|
|
130
|
+
'uncontained immediate process could not be reaped after kill'
|
|
131
|
+
) from error
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def containment(self) -> ContainmentState:
|
|
135
|
+
"""Return the reduced containment state.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Explicitly uncontained state.
|
|
139
|
+
"""
|
|
140
|
+
return ContainmentState.UNCONTAINED
|
|
141
|
+
|
|
142
|
+
def forward_signal(self, signum: int) -> None:
|
|
143
|
+
"""Forward one graceful signal to the immediate process.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
signum: Actual signal received by the wrapper.
|
|
147
|
+
"""
|
|
148
|
+
if self._graceful_signal_forwarded or self._process.poll() is not None:
|
|
149
|
+
return
|
|
150
|
+
if self._windows_api is not None:
|
|
151
|
+
del signum
|
|
152
|
+
try:
|
|
153
|
+
delivered = self._windows_api.generate_ctrl_break(self._process.pid)
|
|
154
|
+
except OSError:
|
|
155
|
+
return
|
|
156
|
+
if not delivered:
|
|
157
|
+
return
|
|
158
|
+
else:
|
|
159
|
+
try:
|
|
160
|
+
self._process.send_signal(signum)
|
|
161
|
+
except ProcessLookupError:
|
|
162
|
+
return
|
|
163
|
+
self._graceful_signal_forwarded = True
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def process(self) -> subprocess.Popen[bytes]:
|
|
167
|
+
"""Return the immediate process completion handle.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Immediate uncontained process.
|
|
171
|
+
"""
|
|
172
|
+
return self._process
|
|
173
|
+
|
|
174
|
+
def release(self) -> None:
|
|
175
|
+
"""Do nothing because the uncontained child starts during construction."""
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def stderr(self) -> StderrReader:
|
|
179
|
+
"""Return the parent stderr pipe.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Binary stderr reader.
|
|
183
|
+
"""
|
|
184
|
+
return cast('StderrReader', self._process.stderr)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def stdin(self) -> BinaryIO:
|
|
188
|
+
"""Return the parent stdin pipe.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Binary stdin writer.
|
|
192
|
+
"""
|
|
193
|
+
return cast('BinaryIO', self._process.stdin)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def stdout(self) -> BinaryIO:
|
|
197
|
+
"""Return the parent stdout pipe.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Binary stdout reader.
|
|
201
|
+
"""
|
|
202
|
+
return cast('BinaryIO', self._process.stdout)
|
|
203
|
+
|
|
204
|
+
def terminate_tree(self, grace_seconds: float) -> None:
|
|
205
|
+
"""Terminate only the immediate process after a bounded grace.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
grace_seconds: Maximum graceful wait before force termination.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
ContainmentError: If the immediate process cannot be reaped after
|
|
212
|
+
forced termination.
|
|
213
|
+
"""
|
|
214
|
+
if self._windows_api is not None:
|
|
215
|
+
self._terminate_windows(self._windows_api, grace_seconds)
|
|
216
|
+
return
|
|
217
|
+
if self._process.poll() is not None:
|
|
218
|
+
return
|
|
219
|
+
if not self._graceful_signal_forwarded:
|
|
220
|
+
with contextlib.suppress(ProcessLookupError):
|
|
221
|
+
self._process.terminate()
|
|
222
|
+
try:
|
|
223
|
+
self._process.wait(timeout=max(0.0, grace_seconds))
|
|
224
|
+
except subprocess.TimeoutExpired:
|
|
225
|
+
self._process.kill()
|
|
226
|
+
try:
|
|
227
|
+
self._process.wait(timeout=_FORCE_KILL_WAIT_SECONDS)
|
|
228
|
+
except subprocess.TimeoutExpired as error:
|
|
229
|
+
raise ContainmentError(
|
|
230
|
+
'uncontained immediate process could not be reaped after kill'
|
|
231
|
+
) from error
|
|
232
|
+
|
|
233
|
+
def abort_before_release(self) -> None:
|
|
234
|
+
"""Dispose of the immediate process before release."""
|
|
235
|
+
self.terminate_tree(_ABORT_GRACE_SECONDS)
|
|
236
|
+
|
|
237
|
+
def finish(self) -> None:
|
|
238
|
+
"""Terminate only the remaining immediate process."""
|
|
239
|
+
self.terminate_tree(_ABORT_GRACE_SECONDS)
|