diff --git a/docs/execution-model.md b/docs/execution-model.md index 6a96003..38b7bd2 100644 --- a/docs/execution-model.md +++ b/docs/execution-model.md @@ -7,6 +7,21 @@ frozen to seven operation names. ## Minimal cell ABI v1 The public API names the isolation backend explicitly: `backend="subinterpreter"` is the execution-cell mode, `backend="process"` is the process-boundary mode, and `backend="microvm"` is the microVM-boundary mode. These modes change the containment boundary, not the seven cell operations below. +### Backend implementation status + +`subinterpreter` and `process` are implemented; `microvm` is reserved and fails +closed until a launcher is available. + +The `process` backend runs guest code in a separate OS process, so in-process +Python escapes (for example recovering an unrestricted `__import__` by walking +`object.__subclasses__()`) can no longer reach the supervisor's address space — +they are confined to the guest process. Because the supervisor must never +deserialize attacker-controlled bytes, values crossing the boundary (the +argument to `post`, and `call` results) must be JSON-serializable; non-JSON +payloads surface as an error to the caller rather than crossing the boundary. +Kernel-level confinement of the guest process (no-new-privs, seccomp, rlimits, +Landlock, cgroups) is layered on top of this boundary. + ## Allowed operations 1. **`exec(source)`** diff --git a/pyisolate/runtime/child.py b/pyisolate/runtime/child.py new file mode 100644 index 0000000..c488f7e --- /dev/null +++ b/pyisolate/runtime/child.py @@ -0,0 +1,239 @@ +"""Guest runtime for the ``backend="process"`` isolation mode. + +This module is the entry point executed in a *fresh* interpreter for every +process-backed sandbox (``python -m pyisolate.runtime.child ``). Because it +runs in its own OS process, guest code cannot reach the supervisor's address +space at all -- the in-process object-graph escapes that defeat the +sub-interpreter backend (walking ``().__class__.__base__.__subclasses__()`` to +recover an unrestricted ``__import__``) can still run here, but they only ever +touch *this* process, which later hardening layers (seccomp, rlimits, Landlock, +cgroups) confine at the kernel level. + +The parent speaks a tiny length-framed JSON protocol over an inherited +``AF_UNIX`` socket. JSON is deliberate: the parent must never ``pickle.loads`` +bytes produced by untrusted guest code, so values crossing the boundary are +restricted to JSON-serializable data. + +Parent -> child frames:: + + {"op": "bootstrap", "name": ..., "allowed_imports": [...] | null, + "fs": [...] | null, "tcp": [...] | null} + {"op": "exec", "source": "..."} + {"op": "call", "target": "mod.fn", "args": [...], "kwargs": {...}} + {"op": "stop"} + +Child -> parent frames:: + + {"ev": "ready"} + {"ev": "post", "message": } + {"ev": "log", "level": ..., "message": ..., "fields": {...}} + {"ev": "metric", "name": ..., "value": ..., "tags": {...}} + {"ev": "done"} + {"ev": "error", "exc_type": "PolicyError", "message": "..."} +""" + +from __future__ import annotations + +import json +import socket +import struct +import sys +from pathlib import Path +from typing import Any + +from .. import errors +from .thread import _SAFE_BUILTINS, _blocked_open, _make_importer, _thread_local + +_LEN = struct.Struct("!I") + + +def _send_frame(sock: socket.socket, obj: dict[str, Any]) -> None: + # json.dumps raises TypeError for non-serializable payloads; that propagates + # into guest code (e.g. out of ``post``) instead of silently crossing the + # boundary, which is the contract for the process backend. + data = json.dumps(obj).encode("utf-8") + sock.sendall(_LEN.pack(len(data)) + data) + + +def _recv_exact(sock: socket.socket, size: int) -> bytes | None: + chunks: list[bytes] = [] + remaining = size + while remaining: + chunk = sock.recv(remaining) + if not chunk: + return None + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _recv_frame(sock: socket.socket) -> dict[str, Any] | None: + header = _recv_exact(sock, _LEN.size) + if header is None: + return None + (size,) = _LEN.unpack(header) + body = _recv_exact(sock, size) + if body is None: + return None + return json.loads(body.decode("utf-8")) + + +class _GuestChannel: + """Guest-side implementations of the ``post``/``log``/``metric``/``request`` + cell operations that frame back to the supervisor process.""" + + def __init__(self, sock: socket.socket) -> None: + self._sock = sock + + def post(self, message: Any) -> None: + _send_frame(self._sock, {"ev": "post", "message": message}) + + def log(self, level: str, message: str, **fields: Any) -> None: + _send_frame( + self._sock, + {"ev": "log", "level": level, "message": message, "fields": fields}, + ) + + def metric(self, name: str, value: Any, tags: dict[str, str] | None = None) -> None: + _send_frame( + self._sock, + {"ev": "metric", "name": name, "value": value, "tags": tags or {}}, + ) + + def request(self, capability: str, action: str, payload: Any = None) -> None: + # Broker mediation for the process backend is not yet wired; deny by + # default rather than silently succeeding. + raise errors.PolicyError( + f"broker request {capability!r}/{action!r} is not available " + "for the process backend" + ) + + +def _install_guest_context( + *, + allowed_imports: list[str] | None, + fs: list[str] | None, + tcp: list[str] | None, +) -> None: + """Populate the thread-local state the reused import/FS/network guards read. + + ``_thread_local.sandbox`` is ``None`` here (there is no ``SandboxThread`` in + the child); the guards in :mod:`pyisolate.runtime.thread` already treat that + as "no in-process quota counters" and still enforce the policy allow-lists. + """ + + _thread_local.sandbox = None + _thread_local.authority = None + _thread_local.runtime_policy = None + _thread_local.fs_capability = None + _thread_local.net_capability = None + _thread_local.subprocess_capability = None + _thread_local.clock_capability = None + _thread_local.random_capability = None + _thread_local.fs = ( + [Path(p).resolve(strict=False) for p in fs] if fs is not None else None + ) + if tcp is not None: + _thread_local.tcp = set(tcp) + elif hasattr(_thread_local, "tcp"): + del _thread_local.tcp + + +def _build_guest_globals( + channel: _GuestChannel, allowed_imports: list[str] | None +) -> dict[str, Any]: + builtins_dict = _SAFE_BUILTINS.copy() + builtins_dict["open"] = _blocked_open + builtins_dict["__import__"] = _make_importer(allowed_imports or []) + return { + "post": channel.post, + "log": channel.log, + "metric": channel.metric, + "request": channel.request, + "__builtins__": builtins_dict, + } + + +def _run_exec(source: str, guest_globals: dict[str, Any]) -> None: + exec(source, guest_globals, guest_globals) # noqa: S102 - sandboxed guest code + + +def _run_call( + target: str, args: list[Any], kwargs: dict[str, Any], guest_globals: dict[str, Any] +) -> Any: + importer = guest_globals["__builtins__"]["__import__"] + try: + module_name, func_name = target.rsplit(".", 1) + except ValueError as exc: + raise errors.SandboxError( + f"call target {target!r} must include a module path (e.g. 'module.func')" + ) from exc + mod = importer(module_name, fromlist=["_"]) + func = object.__getattribute__(mod, func_name) + return func(*args, **(kwargs or {})) + + +def _serve(sock: socket.socket) -> None: + bootstrap = _recv_frame(sock) + if bootstrap is None or bootstrap.get("op") != "bootstrap": + return + allowed_imports = bootstrap.get("allowed_imports") + _install_guest_context( + allowed_imports=allowed_imports, + fs=bootstrap.get("fs"), + tcp=bootstrap.get("tcp"), + ) + channel = _GuestChannel(sock) + guest_globals = _build_guest_globals(channel, allowed_imports) + _send_frame(sock, {"ev": "ready"}) + + while True: + frame = _recv_frame(sock) + if frame is None: + return + op = frame.get("op") + if op == "stop": + return + try: + if op == "exec": + _run_exec(frame.get("source", ""), guest_globals) + elif op == "call": + result = _run_call( + frame.get("target", ""), + frame.get("args", []), + frame.get("kwargs", {}), + guest_globals, + ) + channel.post(result) + else: + raise errors.SandboxError(f"unknown cell operation: {op!r}") + except BaseException as exc: # noqa: BLE001 - surface every failure to host + _send_frame( + sock, + { + "ev": "error", + "exc_type": type(exc).__name__, + "message": str(exc), + }, + ) + else: + _send_frame(sock, {"ev": "done"}) + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + return 2 + fd = int(argv[1]) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, fileno=fd) + try: + _serve(sock) + finally: + try: + sock.close() + except OSError: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/pyisolate/runtime/process_backend.py b/pyisolate/runtime/process_backend.py new file mode 100644 index 0000000..ffed8aa --- /dev/null +++ b/pyisolate/runtime/process_backend.py @@ -0,0 +1,327 @@ +"""Supervisor-side handle for the ``backend="process"`` isolation mode. + +``ProcessSandbox`` launches guest code in a separate OS process +(:mod:`pyisolate.runtime.child`) and speaks the same length-framed JSON +protocol over an inherited ``AF_UNIX`` socketpair. It duck-types the subset of +the :class:`~pyisolate.runtime.thread.SandboxThread` surface that +:class:`pyisolate.supervisor.Sandbox` delegates to (``exec``, ``call``, +``recv``, ``stop``, ``kill``, ``cancel``, ``reap``, ``is_alive``, ``name``), +so the existing handle wrapper works unchanged. + +Unlike the sub-interpreter backend, the boundary here is a real process +boundary: guest code runs in a distinct address space and cannot read or +corrupt supervisor memory. Kernel-level confinement of that process +(no-new-privs, seccomp, rlimits, Landlock, cgroups) is layered on in follow-up +work; this module establishes the process boundary and transport. +""" + +from __future__ import annotations + +import json +import queue +import socket +import struct +import subprocess +import sys +import threading +from typing import Any, Optional + +from .. import errors +from .thread import Stats + +_LEN = struct.Struct("!I") + +# Guest results and errors cross the boundary as JSON. Never unpickle data +# produced by untrusted guest code in the supervisor process. +_CHILD_MODULE = "pyisolate.runtime.child" + + +def _extract_fs_tcp(policy: Any) -> tuple[Optional[list[str]], Optional[list[str]]]: + """Best-effort extraction of filesystem/TCP allow-lists from a policy. + + Handles both the legacy :class:`~pyisolate.policy.Policy` shape (``.fs`` / + ``.tcp`` string collections) and the compiled + :class:`~pyisolate.policy.model.RuntimePolicy` shape (``allow_fs`` / + ``allow_tcp`` rule objects). Anything richer is left to the kernel + enforcement layers. + """ + + if policy is None: + return None, None + + fs: Optional[list[str]] = None + tcp: Optional[list[str]] = None + + allow_fs = getattr(policy, "allow_fs", None) + allow_tcp = getattr(policy, "allow_tcp", None) + if allow_fs is not None or allow_tcp is not None: + if allow_fs: + fs = [rule.path for rule in allow_fs] + if allow_tcp: + tcp = [rule.destination for rule in allow_tcp] + return fs, tcp + + p_fs = getattr(policy, "fs", None) + p_tcp = getattr(policy, "tcp", None) + if p_fs: + fs = [str(item) for item in p_fs] + if p_tcp: + tcp = [str(item) for item in p_tcp] + return fs, tcp + + +class ProcessSandbox: + """Runs guest code in a confined child process behind a JSON channel.""" + + def __init__( + self, + name: str, + *, + policy: Any = None, + allowed_imports: Optional[list[str]] = None, + backend: str = "process", + ) -> None: + self.name = name + self._backend = backend + self._outbox: "queue.Queue[Any]" = queue.Queue() + self._closed = False + self._lock = threading.Lock() + # Tenant-quota bookkeeping mirrors SandboxThread so the supervisor's + # shared reservation helpers can account for process sandboxes too. + self._tenant: Optional[str] = None + self._tenant_quota: Optional[int] = None + self._tenant_quota_reserved = False + # Handle-surface attributes the Sandbox wrapper reads. Features not yet + # implemented for this backend (see the methods below) are surfaced as + # explicit NotImplementedError rather than AttributeError. + self._cgroup_path = None + self.quota_enforcement = None + self.termination_reason: Optional[str] = None + self._quarantine_reason: Optional[str] = None + self._ops = 0 + self._errors = 0 + + fs, tcp = _extract_fs_tcp(policy) + + parent_sock, child_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + try: + self._proc = subprocess.Popen( + [sys.executable, "-m", _CHILD_MODULE, str(child_sock.fileno())], + pass_fds=(child_sock.fileno(),), + close_fds=True, + ) + except Exception: + parent_sock.close() + child_sock.close() + raise + # The child holds its own copy of the socketpair end; drop ours so an + # unexpected child exit surfaces as EOF on the parent side. + child_sock.close() + self._sock = parent_sock + + self._send( + { + "op": "bootstrap", + "name": name, + "allowed_imports": allowed_imports, + "fs": fs, + "tcp": tcp, + } + ) + + self._reader = threading.Thread( + target=self._read_loop, name=f"pyisolate-proc-{name}", daemon=True + ) + self._reader.start() + + # -- transport --------------------------------------------------------- + + def _send(self, obj: dict[str, Any]) -> None: + data = json.dumps(obj).encode("utf-8") + with self._lock: + if self._closed: + raise errors.SandboxError("sandbox process channel is closed") + self._sock.sendall(_LEN.pack(len(data)) + data) + + def _recv_exact(self, size: int) -> bytes | None: + chunks: list[bytes] = [] + remaining = size + while remaining: + try: + chunk = self._sock.recv(remaining) + except OSError: + return None + if not chunk: + return None + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + def _read_loop(self) -> None: + while True: + header = self._recv_exact(_LEN.size) + if header is None: + break + (length,) = _LEN.unpack(header) + body = self._recv_exact(length) + if body is None: + break + try: + frame = json.loads(body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + self._dispatch(frame) + self._closed = True + + def _dispatch(self, frame: dict[str, Any]) -> None: + ev = frame.get("ev") + if ev == "post": + self._outbox.put(frame.get("message")) + elif ev == "error": + self._errors += 1 + self._outbox.put(self._rebuild_exception(frame)) + # "ready", "done", "log", and "metric" are lifecycle/telemetry frames + # that do not feed recv(); logging/metrics routing is added with the + # observability wiring for this backend. + + @staticmethod + def _rebuild_exception(frame: dict[str, Any]) -> Exception: + # Reconstruct a known pyisolate error by name so callers can still do + # ``pytest.raises(iso.PolicyError)``. Never eval arbitrary type names. + exc_type = frame.get("exc_type", "SandboxError") + message = frame.get("message", "") + cls = getattr(errors, exc_type, None) + if isinstance(cls, type) and issubclass(cls, Exception): + return cls(message) + return errors.SandboxError(f"{exc_type}: {message}") + + # -- cell ABI ---------------------------------------------------------- + + def exec(self, src: str) -> None: + self._ops += 1 + self._send({"op": "exec", "source": src}) + + def call(self, func: str, *args, timeout: float | None = None, **kwargs) -> Any: + self._ops += 1 + self._send({"op": "call", "target": func, "args": list(args), "kwargs": kwargs}) + try: + return self.recv(timeout) + except errors.SandboxError: + raise + except Exception as exc: # pragma: no cover - defensive + raise errors.SandboxError(str(exc)) from exc + + def recv(self, timeout: Optional[float] = None): + try: + result = self._outbox.get(timeout=timeout) + except queue.Empty: + raise errors.TimeoutError("no message received") + if isinstance(result, Exception): + raise result + return result + + # -- lifecycle --------------------------------------------------------- + + def is_alive(self) -> bool: + return self._proc.poll() is None + + def cancel(self, timeout: float = 0.2) -> bool: + with self._lock: + if not self._closed: + try: + data = json.dumps({"op": "stop"}).encode("utf-8") + self._sock.sendall(_LEN.pack(len(data)) + data) + except OSError: + pass + try: + self._proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return not self.is_alive() + + def kill(self, timeout: float = 0.2) -> bool: + if self.cancel(timeout=timeout): + self._teardown() + return True + self._proc.terminate() + try: + self._proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + self._proc.kill() + try: + self._proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + pass + self._teardown() + return not self.is_alive() + + def stop(self, timeout: float = 0.2) -> None: + self.kill(timeout=timeout) + + def reap(self) -> bool: + if self.is_alive(): + return False + self._teardown() + return True + + def _teardown(self) -> None: + with self._lock: + self._closed = True + try: + self._sock.close() + except OSError: + pass + + def quarantine(self, reason: str) -> None: + self._quarantine_reason = reason + self.kill(timeout=0.2) + + # -- telemetry --------------------------------------------------------- + + def get_denial_events(self) -> list[dict[str, str]]: + # Denial telemetry for the process backend is delivered by the kernel + # enforcement layers added in follow-up work; none is collected yet. + return [] + + def get_syscall_log(self) -> list[str]: + return [] + + @property + def stats(self) -> Stats: + # CPU/memory accounting for the process backend arrives with the rlimit + # and cgroup layers; operations and errors are tracked here already. + return Stats( + cpu_ms=0.0, + mem_bytes=0, + latency={"0.5": 0, "1": 0, "5": 0, "10": 0, "inf": 0}, + latency_sum=0.0, + errors=self._errors, + operations=self._ops, + cost=0.0, + denials=[], + ) + + def profile(self) -> Stats: + return self.stats + + # -- not-yet-supported handle surface ---------------------------------- + + def enable_tracing(self) -> None: + raise NotImplementedError( + "operation tracing is not supported for the process backend yet" + ) + + def snapshot(self) -> dict: + raise NotImplementedError( + "checkpointing is not supported for the process backend yet" + ) + + def reset_config(self) -> dict: + raise NotImplementedError( + "reset/recycle is not supported for the process backend yet" + ) + + def reset(self, *args: Any, **kwargs: Any) -> None: + raise NotImplementedError( + "reset/recycle is not supported for the process backend yet" + ) diff --git a/pyisolate/supervisor.py b/pyisolate/supervisor.py index d537cc2..82af2ca 100644 --- a/pyisolate/supervisor.py +++ b/pyisolate/supervisor.py @@ -13,7 +13,7 @@ import re import threading from pathlib import Path -from typing import Dict, Literal, Optional, cast +from typing import Any, Dict, Literal, Optional, Union, cast from . import cgroup, recovery from .capabilities import ROOT, RootCapability @@ -21,6 +21,7 @@ from .observability.alerts import AlertManager from .observability.trace import Tracer from .policy import resolve_policy +from .runtime.process_backend import ProcessSandbox from .runtime.protocol import CapabilityHandle, ControlRequest from .runtime.thread import SandboxThread from .telemetry import DenialEvent @@ -39,7 +40,7 @@ "process", "microvm", ) -IMPLEMENTED_BACKENDS: tuple[BackendMode, ...] = ("subinterpreter",) +IMPLEMENTED_BACKENDS: tuple[BackendMode, ...] = ("subinterpreter", "process") def _normalize_backend(backend: str) -> BackendMode: @@ -60,9 +61,20 @@ def _require_implemented_backend(backend: BackendMode) -> None: class Sandbox: - """Handle to a sandbox thread.""" + """Handle to a sandbox. - def __init__(self, thread: SandboxThread, supervisor: "Supervisor"): + Wraps either a :class:`~pyisolate.runtime.thread.SandboxThread` + (``backend="subinterpreter"``) or a + :class:`~pyisolate.runtime.process_backend.ProcessSandbox` + (``backend="process"``); both expose the same cell-ABI surface this handle + delegates to. + """ + + def __init__( + self, + thread: "Union[SandboxThread, ProcessSandbox]", + supervisor: "Supervisor", + ): self._thread = thread self._supervisor = supervisor @@ -174,6 +186,10 @@ def __init__( rollout_mode: str = "dev", ): self._sandboxes: Dict[str, SandboxThread] = {} + # Process-backed sandboxes live in a parallel registry: they are not + # SandboxThread instances, so the watchdog/warm-pool/cgroup machinery + # that iterates ``_sandboxes`` must not see them. + self._process_sandboxes: Dict[str, ProcessSandbox] = {} self._lock = threading.Lock() self._alerts = AlertManager() self._tracer = Tracer() @@ -226,13 +242,18 @@ def _record_tenant_usage(self, tenant: str, delta: int = 1) -> None: handle.write(f"{tenant},{delta}\n") def _mark_tenant_reservation( - self, thread: SandboxThread, tenant: str | None, tenant_quota: int | None + self, + thread: "Union[SandboxThread, ProcessSandbox]", + tenant: str | None, + tenant_quota: int | None, ) -> None: thread._tenant = tenant thread._tenant_quota = tenant_quota thread._tenant_quota_reserved = bool(tenant and tenant_quota is not None) - def _release_tenant_reservation(self, thread: SandboxThread) -> bool: + def _release_tenant_reservation( + self, thread: "Union[SandboxThread, ProcessSandbox]" + ) -> bool: tenant = getattr(thread, "_tenant", None) if tenant and getattr(thread, "_tenant_quota_reserved", False): self._record_tenant_usage(tenant, -1) @@ -307,9 +328,21 @@ def spawn( imports.update(allowed_imports) allowed_imports = list(imports) + if backend == "process": + return self._spawn_process( + name, + policy=policy, + allowed_imports=allowed_imports, + tenant=tenant, + tenant_quota=tenant_quota, + ) + with self._lock: existing = self._sandboxes.get(name) - if existing is not None and existing.is_alive(): + existing_proc = self._process_sandboxes.get(name) + if (existing is not None and existing.is_alive()) or ( + existing_proc is not None and existing_proc.is_alive() + ): raise RuntimeError(f"sandbox '{name}' already exists") usage_reserved = False if tenant and tenant_quota is not None: @@ -415,15 +448,71 @@ def spawn( NAME_PATTERN = DEFAULT_NAME_PATTERN return Sandbox(thread, self) + def _spawn_process( + self, + name: str, + *, + policy: Any, + allowed_imports: Optional[list[str]], + tenant: Optional[str], + tenant_quota: Optional[int], + ) -> Sandbox: + """Spawn a sandbox behind a real OS-process boundary. + + This path deliberately skips the SandboxThread-specific machinery + (warm pool, in-process quota watchdog, per-thread cgroup attach). Kernel + confinement of the guest process (seccomp/rlimits/Landlock/cgroups) is + layered on in follow-up work. + """ + with self._lock: + existing_thread = self._sandboxes.get(name) + existing_proc = self._process_sandboxes.get(name) + if (existing_thread is not None and existing_thread.is_alive()) or ( + existing_proc is not None and existing_proc.is_alive() + ): + raise RuntimeError(f"sandbox '{name}' already exists") + + usage_reserved = False + if tenant and tenant_quota is not None: + if self._tenant_usage.get(tenant, 0) >= tenant_quota: + raise TenantQuotaExceeded() + self._record_tenant_usage(tenant, 1) + usage_reserved = True + + try: + proc = ProcessSandbox( + name, + policy=policy, + allowed_imports=allowed_imports, + backend="process", + ) + except Exception: + if usage_reserved and tenant: + self._record_tenant_usage(tenant, -1) + raise + + self._mark_tenant_reservation(proc, tenant, tenant_quota) + self._process_sandboxes[name] = proc + self._cleanup() + return Sandbox(proc, self) + def list_active(self) -> Dict[str, Sandbox]: """Return currently active sandboxes.""" self._cleanup() with self._lock: - return { + active: Dict[str, Sandbox] = { name: Sandbox(t, self) for name, t in self._sandboxes.items() if t.is_alive() } + active.update( + { + name: Sandbox(p, self) + for name, p in self._process_sandboxes.items() + if p.is_alive() + } + ) + return active def get_active_threads(self) -> list[SandboxThread]: """Return active sandbox threads for internal consumers.""" @@ -490,8 +579,11 @@ def shutdown(self, cap: RootCapability = ROOT) -> None: sandboxes = list(self._sandboxes.values()) warm = list(self._warm_pool) self._warm_pool.clear() + procs = list(self._process_sandboxes.values()) for sb in sandboxes + warm: sb.stop() + for proc in procs: + proc.stop() self._cleanup() def quarantine(self, name: str, reason: str) -> None: @@ -558,6 +650,14 @@ def _cleanup(self) -> None: self._release_tenant_reservation(thread) del self._sandboxes[n] self._warm_pool = [t for t in self._warm_pool if t.is_alive()] + dead_procs = [ + n for n, p in self._process_sandboxes.items() if not p.is_alive() + ] + for n in dead_procs: + proc = self._process_sandboxes[n] + proc.reap() + self._release_tenant_reservation(proc) + del self._process_sandboxes[n] _supervisor: Supervisor | None = None diff --git a/tests/test_process_backend.py b/tests/test_process_backend.py new file mode 100644 index 0000000..9692c80 --- /dev/null +++ b/tests/test_process_backend.py @@ -0,0 +1,122 @@ +"""Tests for the ``backend="process"`` real process-boundary isolation mode.""" + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import pytest + +import pyisolate as iso + +# The object-graph escape that fully defeats the sub-interpreter backend: +# recover the *real* __import__ from a stdlib module's globals, bypassing the +# guarded builtins and the import allow-list entirely. +_ESCAPE_TO_OS = """ +for cls in ().__class__.__base__.__subclasses__(): + if cls.__name__ == "catch_warnings": + real_import = cls()._module.__builtins__["__import__"] + _os = real_import("os") + post(_os.getpid()) + break +""" + + +def test_process_backend_roundtrip(): + with iso.spawn("proc-rt", allowed_imports=["math"], backend="process") as sb: + sb.exec("from math import sqrt; post(sqrt(2))") + assert sb.recv(timeout=5) == pytest.approx(2**0.5) + + +def test_process_backend_reports_its_backend(): + with iso.spawn("proc-b", backend="process") as sb: + assert sb.backend == "process" + + +def test_process_backend_runs_in_a_separate_process(): + with iso.spawn("proc-pid", allowed_imports=["os"], backend="process") as sb: + sb.exec("import os; post(os.getpid())") + child_pid = sb.recv(timeout=5) + assert isinstance(child_pid, int) + assert child_pid != os.getpid() + + +def test_process_backend_import_allowlist_denies_unlisted_module(): + with iso.spawn("proc-deny", allowed_imports=["math"], backend="process") as sb: + sb.exec("import os") + with pytest.raises(iso.PolicyError): + sb.recv(timeout=5) + + +def test_object_graph_escape_is_confined_to_the_child_process(): + # The escape still *runs* -- a process boundary does not stop in-process + # Python tricks -- but it can only reach os inside its own process, never + # the supervisor's address space. That is the whole point of the boundary. + with iso.spawn("proc-escape", allowed_imports=["math"], backend="process") as sb: + sb.exec(_ESCAPE_TO_OS) + escaped_pid = sb.recv(timeout=5) + assert isinstance(escaped_pid, int) + assert escaped_pid != os.getpid() + + +def test_process_backend_surfaces_guest_exceptions(): + with iso.spawn("proc-err", allowed_imports=["math"], backend="process") as sb: + sb.exec("raise ValueError('boom')") + with pytest.raises(iso.SandboxError): + sb.recv(timeout=5) + + +def test_process_backend_non_json_result_is_rejected(): + with iso.spawn("proc-json", backend="process") as sb: + sb.exec("post(object())") + with pytest.raises(iso.SandboxError): + sb.recv(timeout=5) + + +def test_process_backend_call_returns_result(): + with iso.spawn("proc-call", allowed_imports=["math"], backend="process") as sb: + assert sb.call("math.gcd", 12, 18, timeout=5) == 6 + + +def test_close_terminates_the_child_process(): + sb = iso.spawn("proc-close", allowed_imports=["os"], backend="process") + sb.exec("import os; post(os.getpid())") + child_pid = sb.recv(timeout=5) + sb.close() + # After close the child is gone; signalling pid 0 group would be unsafe, so + # assert the specific pid no longer exists. + with pytest.raises(OSError): + os.kill(child_pid, 0) + + +def test_process_sandbox_appears_in_list_active(): + with iso.spawn("proc-list", backend="process"): + active = iso.list_active() + assert "proc-list" in active + assert active["proc-list"].backend == "process" + assert "proc-list" not in iso.list_active() + + +def test_process_backend_tracks_operations_in_stats(): + with iso.spawn("proc-stats", allowed_imports=["math"], backend="process") as sb: + sb.exec("post(1)") + sb.recv(timeout=5) + sb.exec("post(2)") + sb.recv(timeout=5) + assert sb.stats.operations == 2 + assert sb.stats.errors == 0 + + +def test_process_backend_unsupported_features_raise_not_implemented(): + with iso.spawn("proc-unsup", backend="process") as sb: + with pytest.raises(NotImplementedError): + sb.enable_tracing() + with pytest.raises(NotImplementedError): + sb.snapshot() + + +def test_microvm_backend_remains_unimplemented(): + with pytest.raises(RuntimeError): + iso.spawn("proc-vm", backend="microvm") diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 3683264..14a2f29 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -186,13 +186,13 @@ def test_spawn_backend_is_explicit_subinterpreter(): try: assert sb.backend == "subinterpreter" assert iso.SUPPORTED_BACKENDS == ("subinterpreter", "process", "microvm") - assert iso.IMPLEMENTED_BACKENDS == ("subinterpreter",) + assert iso.IMPLEMENTED_BACKENDS == ("subinterpreter", "process") finally: sb.close() -@pytest.mark.parametrize("backend", ["process", "microvm"]) -def test_spawn_explicit_boundary_backends_fail_closed(backend): +@pytest.mark.parametrize("backend", ["microvm"]) +def test_spawn_unimplemented_boundary_backends_fail_closed(backend): with pytest.raises(NotImplementedError, match=backend): iso.spawn(f"backend-{backend}", backend=backend)