Skip to content

Commit 1afb6e6

Browse files
committed
feat(python): streaming-stdio popen — Sandbox.popen + Process (RFC #67)
Python ergonomic layer over the C ABI sandlock_popen / sandlock_handle_kill: Sandbox.popen(cmd, stdin/stdout/stderr=StdioMode) returns a live Process whose PIPED streams are unbuffered binary file objects the caller reads/writes while the child runs; INHERIT/NULL streams are None. Streams default to INHERIT (parity with subprocess.Popen) — pass StdioMode.PIPED for the streams you drive. Process is a context manager that kills+reaps the child and closes the streams on exit (kill before wait, so an undrained pipe cannot hang teardown). wait() closes a still-open piped stdin first so a reader child (cat) sees EOF, then caches its Result (idempotent, no double-free). kill() SIGKILLs the group idempotently. On a failed fd handoff, Process.__init__ closes any fds it opened and popen() frees the handle — nothing leaks on the error path. - wait(timeout): bounds the wait via sandlock_handle_wait_timeout; on timeout the child is killed and a non-success Result is returned (parity with Sandbox.run's timeout), so an undrained-pipe wait can never hang forever. - __del__ safety net: a Process dropped without wait()/context reaps the child and emits a ResourceWarning (best-effort, never raises) instead of silently leaking the handle and wedging the Sandbox. - stdio modes are normalized/validated with StdioMode(x): a bad mode raises a clear ValueError in Python instead of an opaque null handle across the FFI; the int discriminant is still accepted. - Result from wait() carries no stdout/stderr (streamed to the fds) — documented on popen() and wait(). StdioMode/Process are exported from the package. Tests mirror the FFI suite and add destructively-verified coverage for the load-bearing invariants: stdout streaming, stdin/stdout roundtrip through cat, wait()-delivers-EOF to an unclosed piped stdin (watchdog), wait(timeout) kills-and-returns (watchdog), both stdout and stderr piped at once, stderr piped, Null yields no stream, kill lifecycle, context-manager reap + stream close + sandbox reuse, __del__ reaps an abandoned process, idempotent cached wait, int-discriminant accepted, unknown mode rejected, second-process guard. Refs #67
1 parent 766af10 commit 1afb6e6

4 files changed

Lines changed: 528 additions & 2 deletions

File tree

python/src/sandlock/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
)
1515
from .inputs import inputs
1616
from .handler import Handler, NotifAction, HandlerCtx, ExceptionPolicy
17-
from .sandbox import Sandbox, BranchAction, parse_ports, Change, DryRunResult
17+
from .sandbox import (
18+
Sandbox, BranchAction, parse_ports, Change, DryRunResult, StdioMode, Process,
19+
)
1820
from ._profile import load_profile, list_profiles
1921
from .exceptions import (
2022
SandlockError,
@@ -49,6 +51,8 @@
4951
"parse_ports",
5052
"Change",
5153
"DryRunResult",
54+
"StdioMode",
55+
"Process",
5256
"Protection",
5357
# Handler ABI
5458
"Handler",

python/src/sandlock/_sdk.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,27 @@ def confine(policy: "PolicyDataclass") -> None:
317317
_lib.sandlock_handle_port_mappings.restype = ctypes.c_char_p
318318
_lib.sandlock_handle_port_mappings.argtypes = [_c_handle_p]
319319

320+
# Streaming-stdio popen (RFC #67): create+start a live handle with per-stream
321+
# StdioMode; each piped stream's owned fd is returned through its out pointer.
322+
_lib.sandlock_popen.restype = _c_handle_p
323+
_lib.sandlock_popen.argtypes = [
324+
_c_policy_p,
325+
ctypes.c_char_p,
326+
ctypes.POINTER(ctypes.c_char_p),
327+
ctypes.c_uint,
328+
ctypes.c_uint32, # stdin_mode
329+
ctypes.c_uint32, # stdout_mode
330+
ctypes.c_uint32, # stderr_mode
331+
ctypes.POINTER(ctypes.c_int), # out_stdin_fd
332+
ctypes.POINTER(ctypes.c_int), # out_stdout_fd
333+
ctypes.POINTER(ctypes.c_int), # out_stderr_fd
334+
]
335+
336+
# SIGKILL the handle's process group without freeing it (so wait can still
337+
# collect the exit status). 0 on success, -1 on error.
338+
_lib.sandlock_handle_kill.restype = ctypes.c_int
339+
_lib.sandlock_handle_kill.argtypes = [_c_handle_p]
340+
320341
# Result
321342
_lib.sandlock_result_exit_code.restype = ctypes.c_int
322343
_lib.sandlock_result_exit_code.argtypes = [_c_result_p]

python/src/sandlock/sandbox.py

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import inspect
1212
import re
1313
from dataclasses import dataclass, field
14-
from enum import Enum
14+
from enum import Enum, IntEnum
1515
from typing import TYPE_CHECKING, Callable, Mapping, Sequence
1616

1717
if TYPE_CHECKING:
@@ -92,6 +92,20 @@ class BranchAction(Enum):
9292
KEEP = "keep" # Leave branch as-is (caller decides)
9393

9494

95+
class StdioMode(IntEnum):
96+
"""Per-stream stdio wiring for :meth:`Sandbox.popen`.
97+
98+
The values are the stable ABI discriminants shared with the C/Rust core.
99+
"""
100+
101+
INHERIT = 0
102+
"""Share the supervisor's own fd (child writes to the same terminal/file)."""
103+
PIPED = 1
104+
"""Connect to a pipe; the caller owns the returned end via :class:`Process`."""
105+
NULL = 2
106+
"""Connect the stream to ``/dev/null``."""
107+
108+
95109
@dataclass(frozen=True)
96110
class Change:
97111
"""A single filesystem change detected by dry-run."""
@@ -836,6 +850,85 @@ def wait(self):
836850
stderr=stderr,
837851
)
838852

853+
def popen(
854+
self,
855+
cmd: Sequence[str],
856+
stdin: StdioMode = StdioMode.INHERIT,
857+
stdout: StdioMode = StdioMode.INHERIT,
858+
stderr: StdioMode = StdioMode.INHERIT,
859+
) -> "Process":
860+
"""Spawn a confined process with per-stream stdio and return a live
861+
:class:`Process` whose piped streams the caller reads/writes while it runs.
862+
863+
The streaming counterpart of :meth:`run`: instead of buffering output
864+
until the child exits, each stream set to :attr:`StdioMode.PIPED` is
865+
handed back as a file object on the returned ``Process`` (``.stdin`` /
866+
``.stdout`` / ``.stderr``); ``INHERIT``/``NULL`` streams are ``None``.
867+
Use it to drive a request/response protocol over stdio while the process
868+
is alive (an MCP or LSP server, a REPL, JSON-RPC).
869+
870+
All streams default to :attr:`StdioMode.INHERIT` (parity with
871+
:class:`subprocess.Popen`); pass :attr:`StdioMode.PIPED` for exactly the
872+
streams you want to drive.
873+
874+
Deadlock warning (as with :class:`subprocess.Popen`): if you write to a
875+
piped ``stdin`` you own it -- close it before :meth:`Process.wait` or a
876+
child that reads to EOF (e.g. ``cat``) never exits and the wait blocks
877+
forever; likewise drain a piped ``stdout``/``stderr`` before waiting or a
878+
child that fills the pipe buffer blocks on write. ``Process`` is a
879+
context manager that closes the streams and reaps the child on exit.
880+
881+
The ``Result`` returned by :meth:`Process.wait` carries *no* captured
882+
``stdout``/``stderr`` (they were streamed to you as fds) — read the output
883+
off ``proc.stdout``/``proc.stderr``, not off the result.
884+
885+
Raises:
886+
RuntimeError: If a process is already running, or the spawn failed.
887+
ValueError: If a stdio argument is not a valid :class:`StdioMode`.
888+
"""
889+
import ctypes
890+
from ._sdk import _lib, _make_argv
891+
892+
if self._handle is not None:
893+
raise RuntimeError("sandbox is already running")
894+
895+
# Normalize/validate up front: StdioMode(x) accepts a StdioMode or its int
896+
# discriminant and raises ValueError on anything else, so a bad mode fails
897+
# clearly in Python instead of as an opaque null handle across the FFI.
898+
stdin, stdout, stderr = StdioMode(stdin), StdioMode(stdout), StdioMode(stderr)
899+
900+
native = self._ensure_native()
901+
argv, argc = _make_argv(list(cmd))
902+
resolved_name = self._resolve_name()
903+
904+
fd_in = ctypes.c_int(-1)
905+
fd_out = ctypes.c_int(-1)
906+
fd_err = ctypes.c_int(-1)
907+
self._handle = _lib.sandlock_popen(
908+
native.ptr,
909+
_encode(resolved_name),
910+
argv,
911+
argc,
912+
int(stdin),
913+
int(stdout),
914+
int(stderr),
915+
ctypes.byref(fd_in),
916+
ctypes.byref(fd_out),
917+
ctypes.byref(fd_err),
918+
)
919+
if not self._handle:
920+
raise RuntimeError("sandlock_popen failed")
921+
922+
try:
923+
return Process(self, fd_in.value, fd_out.value, fd_err.value)
924+
except BaseException:
925+
# Wrapping the fds failed after the child was spawned. Process.__init__
926+
# already closed any fds it opened; free the handle (which reaps the
927+
# child) and clear it so the Sandbox is left clean.
928+
_lib.sandlock_handle_free(self._handle)
929+
self._handle = None
930+
raise
931+
839932
def dry_run(self, cmd: Sequence[str], timeout: float | None = None) -> "DryRunResult":
840933
"""Dry-run: run a command, collect filesystem changes, then discard.
841934
@@ -1149,3 +1242,172 @@ def _resolve_syscall(key) -> int:
11491242
f"syscall key must be a name (str) or number (int), "
11501243
f"got {type(key).__name__}"
11511244
)
1245+
1246+
1247+
class Process:
1248+
"""A live confined process with caller-owned stdio, returned by
1249+
:meth:`Sandbox.popen`.
1250+
1251+
``.stdin`` / ``.stdout`` / ``.stderr`` are unbuffered binary file objects
1252+
for streams opened with :attr:`StdioMode.PIPED`, else ``None``. The process
1253+
keeps running until :meth:`wait` (or :meth:`kill`) is called, or the
1254+
``Process`` context manager exits. Prefer the context manager, which closes
1255+
the streams and reaps the child even on error::
1256+
1257+
with sandbox.popen(["cat"], stdin=StdioMode.PIPED,
1258+
stdout=StdioMode.PIPED) as proc:
1259+
proc.stdin.write(b"hi\\n")
1260+
proc.stdin.close() # EOF so cat exits
1261+
data = proc.stdout.read()
1262+
result = proc.wait()
1263+
"""
1264+
1265+
def __init__(self, sandbox: "Sandbox", stdin_fd: int, stdout_fd: int, stderr_fd: int):
1266+
import os
1267+
1268+
self._sandbox = sandbox
1269+
self._result = None
1270+
self.stdin = self.stdout = self.stderr = None
1271+
# os.fdopen takes ownership of each fd: closing the stream closes the fd,
1272+
# so a Process that is closed/exited never leaks the pipe ends. Wrap the
1273+
# three fds so that if a later fdopen raises, the streams already opened
1274+
# are closed and the not-yet-wrapped raw fds are closed too — no fd is
1275+
# leaked on the error path.
1276+
specs = ((stdin_fd, "wb"), (stdout_fd, "rb"), (stderr_fd, "rb"))
1277+
opened: list = []
1278+
try:
1279+
for fd, mode in specs:
1280+
opened.append(os.fdopen(fd, mode, buffering=0) if fd >= 0 else None)
1281+
except BaseException:
1282+
for stream in opened:
1283+
if stream is not None:
1284+
try:
1285+
stream.close()
1286+
except OSError:
1287+
pass
1288+
for fd, _mode in specs[len(opened):]:
1289+
if fd >= 0:
1290+
try:
1291+
os.close(fd)
1292+
except OSError:
1293+
pass
1294+
raise
1295+
self.stdin, self.stdout, self.stderr = opened
1296+
1297+
@property
1298+
def pid(self) -> int | None:
1299+
"""The child PID while running, else ``None``."""
1300+
return self._sandbox.pid
1301+
1302+
def kill(self) -> None:
1303+
"""SIGKILL the child's entire process group. Idempotent: a process that
1304+
already exited is not an error. The handle stays valid -- call
1305+
:meth:`wait` afterwards to collect the (non-success) exit status."""
1306+
from ._sdk import _lib
1307+
1308+
handle = self._sandbox._handle
1309+
if handle is None:
1310+
return
1311+
_lib.sandlock_handle_kill(handle)
1312+
1313+
def wait(self, timeout: float | None = None) -> "Result":
1314+
"""Wait for the child to exit and return its :class:`Result`.
1315+
1316+
A still-open piped ``stdin`` is closed first so a child that reads to EOF
1317+
can exit; piped ``stdout``/``stderr`` stay open for the caller to finish
1318+
reading. Frees the underlying handle; a second call returns the cached
1319+
result.
1320+
1321+
The returned ``Result`` carries no ``stdout``/``stderr`` (those were
1322+
streamed to you as fds — read them off ``.stdout``/``.stderr``).
1323+
1324+
Args:
1325+
timeout: Maximum seconds to wait. On timeout the child is killed and
1326+
a non-success ``Result`` is returned (same semantics as
1327+
:meth:`Sandbox.run`'s ``timeout``) — the wait never hangs. ``None``
1328+
(default) waits indefinitely. Note: without a ``timeout``, a piped
1329+
``stdout``/``stderr`` you have not drained can block the child on a
1330+
full pipe and hang the wait forever; pass a ``timeout`` or drain first.
1331+
"""
1332+
from ._sdk import _lib, Result
1333+
1334+
handle = self._sandbox._handle
1335+
if handle is None:
1336+
if self._result is not None:
1337+
return self._result
1338+
raise RuntimeError("process is not running")
1339+
1340+
# Deliver EOF to a still-open piped stdin so a reader child can exit and
1341+
# the wait below does not block forever.
1342+
if self.stdin is not None and not self.stdin.closed:
1343+
try:
1344+
self.stdin.close()
1345+
except OSError:
1346+
pass
1347+
1348+
try:
1349+
timeout_ms = int(timeout * 1000) if timeout else 0
1350+
result_p = _lib.sandlock_handle_wait_timeout(handle, timeout_ms)
1351+
finally:
1352+
_lib.sandlock_handle_free(handle)
1353+
self._sandbox._handle = None
1354+
1355+
if not result_p:
1356+
self._result = Result(
1357+
success=False, exit_code=-1, error="sandlock_handle_wait failed"
1358+
)
1359+
return self._result
1360+
1361+
exit_code = _lib.sandlock_result_exit_code(result_p)
1362+
success = _lib.sandlock_result_success(result_p)
1363+
# stdout/stderr were handed to the caller as fds, so the RunResult holds
1364+
# none — read them off the streams, not the Result.
1365+
_lib.sandlock_result_free(result_p)
1366+
self._result = Result(success=bool(success), exit_code=exit_code)
1367+
return self._result
1368+
1369+
def __enter__(self) -> "Process":
1370+
return self
1371+
1372+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
1373+
# Terminate and reap a still-running child so no confined process is left
1374+
# behind, then close every stream we own.
1375+
if self._sandbox._handle is not None:
1376+
try:
1377+
self.kill()
1378+
except Exception:
1379+
pass
1380+
try:
1381+
self.wait()
1382+
except Exception:
1383+
pass
1384+
for stream in (self.stdin, self.stdout, self.stderr):
1385+
if stream is not None and not stream.closed:
1386+
try:
1387+
stream.close()
1388+
except OSError:
1389+
pass
1390+
1391+
def __del__(self):
1392+
# Safety net for a Process dropped without wait()/context: a live handle
1393+
# would otherwise leak the confined child + its runtime and wedge the
1394+
# Sandbox in "already running". Reap it, warning like subprocess.Popen so
1395+
# the missing wait()/`with` is visible. __del__ must never raise, and may
1396+
# run during interpreter shutdown when imports/globals are gone — so this
1397+
# is strictly best-effort inside a broad guard.
1398+
try:
1399+
if getattr(self, "_sandbox", None) is None or self._sandbox._handle is None:
1400+
return
1401+
import warnings
1402+
1403+
warnings.warn(
1404+
"Process was not waited on or used as a context manager; "
1405+
"reaping the confined child. Use `with sandbox.popen(...)` or "
1406+
"call wait().",
1407+
ResourceWarning,
1408+
stacklevel=2,
1409+
)
1410+
self.kill()
1411+
self.wait()
1412+
except Exception:
1413+
pass

0 commit comments

Comments
 (0)