Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-22.04, ubuntu-24.04]
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down Expand Up @@ -101,6 +101,40 @@ jobs:
continue-on-error: true
run: pytest -q tests/test_matrix_hardening.py -m "race and no_gil"

tests-subinterpreter:
name: sub-interpreter cells / py3.14t
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14t"
- name: Install dependencies
# Same shape as the 3.13t job: install without the runtime deps that
# may not have free-threaded wheels yet, then re-add the pure-Python
# ones the import path needs. The suite's crypto stub (tests/conftest.py)
# covers the rest, and nothing in this job's scope needs real crypto.
run: |
python -m pip install -e . --no-deps
python -m pip install pytest pyyaml platformdirs
- name: Verify this build actually has sub-interpreters
# Every sub-interpreter test skips itself when the build cannot run it.
# That is right for the 3.11-3.13 matrix, but it means this job would
# report green on a runner that silently resolved to an older Python
# while testing nothing. Fail loudly instead.
run: |
python - <<'PY'
import sys
from pyisolate.runtime import subinterpreter as s
assert s.is_available(), f"no concurrent.interpreters on {sys.version}"
assert not sys._is_gil_enabled(), "expected a free-threaded build"
print("ok:", sys.version)
PY
- name: Run the sub-interpreter backend suite
run: pytest -q tests/test_subinterpreter_backend.py tests/test_supervisor.py
- name: Validate the no-GIL readiness axis on 3.14t
run: pytest -q tests/test_nogil.py

tests-soak:
name: soak / 2k spawn-kill cycles
if: github.event_name == 'schedule'
Expand Down
6 changes: 3 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import pyisolate as psi

PyIsolate supports exactly seven cell operations: `exec`, `call`, `post`, `recv`, `log`, `metric`, and `request`.

Isolation mode is explicit in the public API. Use `backend="subinterpreter"` for the execution-cell backend, `backend="process"` for one sandbox per OS process, and `backend="microvm"` for a process placed behind a microVM boundary. The cell contract is the same in every mode, but only the process and microVM modes are intended to represent hard blast-radius boundaries.
Isolation mode is explicit in the public API. Use `backend="thread"` for the execution-cell backend, `backend="process"` for one sandbox per OS process, and `backend="microvm"` for a process placed behind a microVM boundary. The cell contract is the same in every mode, but only the process and microVM modes are intended to represent hard blast-radius boundaries.

The canonical contract lives in [docs/execution-model.md](docs/execution-model.md). Keep this surface small; production systems win by refusing extra features.

Expand All @@ -27,7 +27,7 @@ The canonical contract lives in [docs/execution-model.md](docs/execution-model.m
## 2  Executing code

```python
sb = psi.spawn("guest42", allowed_imports=["math"], numa_node=0, policy="defaults", backend="subinterpreter")
sb = psi.spawn("guest42", allowed_imports=["math"], numa_node=0, policy="defaults", backend="thread")
sb.exec("from math import sqrt; post(sqrt(2))")
result = sb.recv(timeout=0.1) # 1.4142135623
```
Expand Down Expand Up @@ -76,7 +76,7 @@ policy.refresh("/tmp/policy.yml", token="secret")
The policy names below are routing/configuration labels in the prototype release; they must not silently imply kernel-enforced isolation.

```python
@psi.sandbox(policy="ml-inference", timeout="30s", backend="subinterpreter")
@psi.sandbox(policy="ml-inference", timeout="30s", backend="thread")
def run_model(data):
...

Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ guarantees; **no release should be treated as a hardened security boundary**.
## [Unreleased]

### Added
- `backend="subinterpreter"`: real CPython sub-interpreter cells on 3.14+, via
`concurrent.interpreters`, with a pre-warmed `CellPool`. Each guest gets its
own `sys.modules` and its own `builtins`, so the import allow-list is a
property of the interpreter rather than thread-local state. Still an
execution cell, not a boundary against hostile Python. Fails closed below
3.14 rather than degrading to the thread backend.

- `backend="process"` boundary mode: a real separate-process boundary confined
by `no_new_privs` + a seccomp deny-list, Landlock filesystem rules, Landlock
TCP-egress rules (Landlock ABI ≥ 4), a coarse per-cgroup eBPF/LSM deny-mask,
Expand All @@ -24,12 +31,28 @@ guarantees; **no release should be treated as a hardened security boundary**.
- `pyisolate[operator]` optional-dependency group for the Kubernetes operator.

### Changed
- CI covers CPython 3.14: the unit matrix gains `3.14`, and a new
`sub-interpreter cells / py3.14t` job runs the sub-interpreter backend on a
free-threaded build. That job asserts the interpreter really is a
free-threaded 3.14 before running anything, because every sub-interpreter
test skips itself when the build cannot run it -- correct for the 3.11-3.13
matrix, but it would otherwise let the job report green having tested
nothing.
- `backend="subinterpreter"` is renamed to `backend="thread"`, which is what it
has always run, and the `subinterpreter` name now selects the real
sub-interpreter backend. `DEPRECATED_BACKEND_ALIASES` is exported alongside
`SUPPORTED_BACKENDS` and is currently empty.
- Threat model and `SECURITY.md` reconciled with the real, backend-conditional
boundary (the sub-interpreter backend is an execution cell, not a boundary
against hostile Python).

### Known gaps
- The broker `request` op is surfaced but not yet executed end-to-end.
- A running sub-interpreter cell cannot be reclaimed: one that overruns its
wall-time deadline is abandoned, and its thread stays pinned until the
process exits. Cells enforce a wall-time deadline and no other quota;
`sys.getallocatedblocks()` is process-global, so per-cell memory
accounting needs a worker-process layer that does not exist yet.
- Process-backed sandboxes are not attached to cgroups or watched by the
resource watchdog (they get `rlimit` only).
- `backend="microvm"` fails closed: the guest agent and vsock cell transport are
Expand Down
67 changes: 47 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ PyIsolate `0.0.x` is a prototype for API, policy, broker, observability, and tes

## Features and roadmap

* **Sub-interpreter sandbox API** — the API surface is available for prototype development and conformance testing. The backend currently executes guests in a dedicated thread, not a CPython sub-interpreter; see [Sub-interpreter status](#sub-interpreter-status).
* **Sub-interpreter sandbox API** — the API surface is available for prototype development and conformance testing. The backend currently executes guests in a dedicated thread, not a CPython sub-interpreter; see [Backend names and what they run](#backend-names-and-what-they-run).
* **Import allow-listing and user-space quotas** — available as prototype guardrails; not a complete adversarial security boundary.
* **No-GIL/free-threaded CPython support** — experimental roadmap target for CPython 3.13+ `--disable-gil` builds.
* **Kernel enforcement** — experimental roadmap target; eBPF-LSM, cgroup, and verifier-backed policy enforcement are not guaranteed by the current release.
Expand Down Expand Up @@ -210,37 +210,64 @@ Use `pyisolate.policy.refresh("policy/<name>.yml", token="secret")` to hot‑loa

---

## Sub-interpreter status
## Backend names and what they run

`backend="subinterpreter"` does **not** currently use a CPython sub-interpreter.
`pyisolate/runtime/thread.py` runs each guest in a `threading.Thread` and
`exec`s guest source against a restricted `__builtins__` mapping. The backend
carries the name of its intended implementation.
`backend="thread"` runs each guest in a `threading.Thread` of the supervisor
process, `exec`ing guest source against a restricted `__builtins__` mapping. It
is the default.

This does not change any security claim in this repository — that backend is
documented throughout as an execution cell and *not* a boundary against hostile
Python, which is equally true of a thread and of a real sub-interpreter. What it
changes is the mechanism you should assume when reasoning about it:
This backend used to be spelled `backend="subinterpreter"`, which named an
implementation it did not have: `pyisolate/runtime/thread.py` has always used a
thread. That spelling still works and emits a `DeprecationWarning` pointing at
`"thread"`. It is **not** a permanent synonym — the name is reserved for a real
CPython sub-interpreter backend, so pass `"thread"` if you want today's runtime.

| | thread (today) | sub-interpreter (intended) |
The rename changes no security claim. That backend is documented throughout as
an execution cell and *not* a boundary against hostile Python, which is equally
true of a thread and of a real sub-interpreter. What the old name obscured was
the mechanism you should assume when reasoning about it:

| | `thread` | `subinterpreter` |
| --- | --- | --- |
| Address space | shared with supervisor | shared with supervisor |
| `sys.modules` | shared with supervisor | per-interpreter |
| Import allow-list | thread-local bookkeeping | a property of the interpreter |
| Boundary vs hostile Python | none | none |
| GIL | shared | per-interpreter on free-threaded builds |

Landing the real implementation (`concurrent.interpreters` on 3.14, `_interpreters`
on 3.12+) is roadmap work. Until then, treat "sub-interpreter" as the name of an
API mode, not a description of the runtime, and use `backend="process"` for any
guest you do not trust.
| GIL | shared | per-interpreter; irrelevant on free-threaded builds |
| Requires | any supported Python | CPython 3.14+ |

`backend="subinterpreter"` runs each guest in its own CPython interpreter via
`concurrent.interpreters`. It needs CPython 3.14+ and **fails closed** below
that rather than quietly handing back a thread, which isolates differently.
It is not the default for that reason.

Neither is a boundary against hostile Python: both share the supervisor's
address space, `ctypes` imports cleanly inside a cell, and any C extension can
reach the whole process. Use `backend="process"` for any guest you do not
trust. What a cell buys over a thread is that one tenant's imports,
monkey-patches and globals cannot be seen or clobbered by another.

Cells are pooled and pre-warmed, because creating one costs 10-57 ms while
dispatching onto a warm one costs 0.8 ms — see
[Performance snapshot](#performance-snapshot). A released cell is *retired*
rather than returned to the pool: an interpreter cannot be reset, so reusing
one across tenants would carry the first tenant's globals into the second.

One operational limit is worth knowing before you deploy it: **a running cell
cannot be reclaimed.** `Interpreter.close()` refuses while the guest is
executing and there is no `kill`, so a cell that overruns its deadline is
*abandoned* — the sandbox raises, the pool stops using that cell, and its
thread stays pinned until the process exits. If you need to survive runaway
guests, run a pool of worker processes and treat the worker as the kill
domain.

---

## Canonical execution model

A cell is intentionally limited to seven operations: `exec`, `call`, `post`, `recv`, `log`, `metric`, and `request`.

The API makes the isolation choice explicit: `backend="subinterpreter"` means an execution cell, `backend="process"` means a separate OS process boundary, and `backend="microvm"` means a process behind a microVM boundary. The cell contract stays the same across modes, but the security boundary does not: sub-interpreters are not treated as a hard boundary.
The API makes the isolation choice explicit: `backend="thread"` means an execution cell, `backend="process"` means a separate OS process boundary, and `backend="microvm"` means a process behind a microVM boundary. The cell contract stays the same across modes, but the security boundary does not: sub-interpreters are not treated as a hard boundary.

See [docs/execution-model.md](docs/execution-model.md). We keep this model small on purpose: production systems are safer when they refuse features outside a single contract.

Expand All @@ -250,12 +277,12 @@ See [docs/execution-model.md](docs/execution-model.md). We keep this model small

**The boundary is the backend.** Pick the backend to match your trust level:

* **`backend="subinterpreter"`** (default) - an **execution cell**, not a
* **`backend="thread"`** (default) - an **execution cell**, not a
boundary against hostile Python. Today the guest runs in a dedicated
*thread* of the supervisor's own process, with guest code `exec`'d against a
restricted `__builtins__` mapping — **not** in a CPython sub-interpreter; the
backend is named for its intended implementation, which is roadmap work (see
[Sub-interpreter status](#sub-interpreter-status)). Restricted builtins and
[Backend names and what they run](#backend-names-and-what-they-run)). Restricted builtins and
the import allow-list are bypassable guardrails (adversarial Python can walk
`object.__subclasses__()` to reach the real `os`/`open`). Use it for
**trusted** code, or for scheduling and organization.
Expand Down
24 changes: 15 additions & 9 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ normative statement.

## Delivered

- **Backends** — `backend="subinterpreter"` (execution cell; currently a
dedicated thread rather than a CPython sub-interpreter — see below) and
- **Backends** — `backend="thread"` (execution cell; a dedicated thread of the
supervisor process, previously spelled `subinterpreter`),
`backend="subinterpreter"` (real CPython sub-interpreter cells on 3.14+, with
a pre-warmed pool; an execution cell, not a boundary), and
`backend="process"` (the boundary mode): a real separate-process boundary with
`no_new_privs` + a seccomp deny-list, Landlock filesystem rules, Landlock
TCP-egress rules (ABI ≥ 4), a coarse per-cgroup eBPF/LSM deny-mask, and
Expand All @@ -31,13 +33,17 @@ normative statement.

## Now / next

- **Real sub-interpreters for `backend="subinterpreter"`** — the backend is
named for its intended implementation but runs guests in a `threading.Thread`
today, so guests share `sys.modules` and the GIL with the supervisor. Build it
on `concurrent.interpreters` (3.14) / `_interpreters` (3.12+), or rename the
backend to `thread` and let this item own the real thing. Either way the
boundary claim is unchanged: it is an execution cell, not a boundary against
hostile Python.
- **A kill domain for cells** — a running sub-interpreter cannot be reclaimed:
`close()` refuses while the guest is executing and there is no `kill`, so a
cell that overruns is abandoned and its thread is pinned for the life of the
process. The fix is not in-process. Add a layer of pre-forked worker
processes between the supervisor and the cells, size them by tenant, and make
the worker the unit that gets killed and replaced. This is what turns the cell
pool into something that survives a hostile-by-accident tenant.
- **Per-cell resource accounting** — there is none today.
`sys.getallocatedblocks()` is process-global on both free-threaded and GIL
builds, so memory has to be capped at the worker/cgroup level rather than per
cell. Cells currently enforce a wall-time deadline and nothing else.
- **Broker request execution** — the `request` op currently surfaces a
`BrokerRequest` to the host but nothing executes it or returns a result. Add a
request/response round-trip and a pluggable, capability-scoped handler so the
Expand Down
13 changes: 7 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
**The security boundary depends on the backend you choose.** This is the single
most important thing to understand before deploying PyIsolate:

- `backend="subinterpreter"` (the default) is an **execution cell**, not a
boundary against hostile Python. Run only trusted code in it. It currently
runs guests in a dedicated thread of the supervisor process, not a CPython
sub-interpreter — see "Sub-interpreter status" in the README. Neither is a
boundary, so nothing below changes.
- `backend="thread"` (the default) is an **execution cell**, not a
boundary against hostile Python. Run only trusted code in it. It runs guests
in a dedicated thread of the supervisor process. It was previously spelled
`backend="subinterpreter"`, which named an implementation it did not have —
see "Backend names and what they run" in the README. Neither a thread nor a
sub-interpreter is a boundary, so nothing below changes.
- `backend="process"` is the **boundary mode**: the guest runs in a separate OS
process confined in depth by the kernel.
- `backend="microvm"` is reserved and not yet implemented.
Expand Down Expand Up @@ -154,7 +155,7 @@ CPython built with `-fstack-protector-strong`/`-fsanitize=cfi`, path-aware

| Item | Rationale / mitigation |
| ---- | ---------------------- |
| **Hostile Python under `backend="subinterpreter"`** | Not a boundary; use `backend="process"` or an external VM/container. |
| **Hostile Python under `backend="thread"`** | Not a boundary; use `backend="process"` or an external VM/container. |
| **Hostile native extensions** (`ctypes`, `cffi`, `dlopen`, native wheels) | Deny by default; only allow vetted code. Native code can subvert interpreter-level assumptions. |
| **Kernel exploits / verifier bypass** | Run inside a VM or microVM if the attacker is assumed to have 0-day power. |
| **Side-channel leakage** (cache, branch predictor, Spectre) | Use one process per tenant on highly sensitive workloads; PyIsolate adds no microarchitectural mitigations. |
Expand Down
17 changes: 9 additions & 8 deletions docs/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@ It is versioned in `pyisolate.runtime.protocol` as `MINIMAL_CELL_ABI` and is
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.
The public API names the isolation backend explicitly: `backend="thread"` 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.
`thread` and `process` are implemented; `microvm` is reserved and fails closed
until a launcher is available.

The `subinterpreter` backend currently executes guests in a dedicated **thread**
of the supervisor process rather than a CPython sub-interpreter — the mode is
named for its intended implementation. The cell ABI below is identical either
way, and so is the boundary claim (neither is one). See "Sub-interpreter status"
in the README.
The `thread` backend executes guests in a dedicated **thread** of the supervisor
process. It was previously spelled `subinterpreter`, which named an
implementation it did not have; that spelling still resolves and warns, and is
reserved for a real CPython sub-interpreter backend. The cell ABI below is
identical either way, and so is the boundary claim (neither is one). See
"Backend names and what they run" in the README.

The `process` backend runs guest code in a separate OS process, so in-process
Python escapes (for example recovering an unrestricted `__import__` by walking
Expand Down
Loading
Loading