Skip to content

Commit 76f06bf

Browse files
committed
Grant an isolated child what it may reach
Isolated mode bounded what Python could consume but nothing it could reach: the child held every authority the node's user held. The caps option names its directories, environment and addresses, and refuses the rest. The model and its vocabulary come from erlang_wasm's WASI implementation, so a grant means the same thing in both. It is a cooperative policy over Python, not a boundary: built on an audit hook, it binds Python and not a C extension, and covers only what CPython announces. The guide says what holds and what does not, and names the kernel work that would change the answer.
1 parent 9458981 commit 76f06bf

20 files changed

Lines changed: 2411 additions & 17 deletions

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# Changelog
22

3+
## 5.1.0 (unreleased)
4+
5+
### Added
6+
7+
- **Capabilities for isolated children** - `py_context:new(#{mode => isolated,
8+
caps => ...})` names the directories, environment variables and network
9+
addresses a child may reach; anything not named is refused. Leaving a key
10+
out grants none of it, and omitting `caps` leaves existing behaviour
11+
unchanged. Paths are resolved a component at a time with `openat` and
12+
`O_NOFOLLOW` from the descriptor of the grant, so `..`, absolute paths,
13+
symlinks out of a grant and symlinked directory prefixes are all refused,
14+
and refusals are `PermissionError` rather than `FileNotFoundError` so they
15+
disclose nothing about what exists outside. Network rules name addresses
16+
and never host names, resolution is its own capability covering every
17+
resolver, and binding is checked against `listen` rather than `connect`.
18+
Process creation, `ctypes`, signals to another process and Unix-socket
19+
addresses are refused outright. The model and its vocabulary come from
20+
erlang_wasm's WASI implementation.
21+
22+
This is a cooperative policy over Python and not a boundary: it is built
23+
on a CPython audit hook, so it binds Python and not a C extension, and it
24+
covers only what CPython announces. `docs/capabilities.md` says what holds
25+
and what does not. Shared memory and capability sets do not combine yet,
26+
because a region reaches the child as a path.
27+
328
## 5.0.0 (2026-08-29)
429

530
### Added

‎README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,14 +622,21 @@ When creating Python contexts, you can choose the execution mode:
622622
%% segfault only takes the child down, rlimits bound memory and CPU.
623623
{ok, Ctx} = py_context:new(#{mode => isolated, kill_after => 1000,
624624
rlimits => #{as => 512 * 1024 * 1024}}).
625+
626+
%% Name what the child may reach, and it reaches nothing else.
627+
{ok, Ctx} = py_context:new(#{mode => isolated,
628+
caps => #{dirs => [{"/srv/models", read}],
629+
net => #{connect => [{tcp, <<"10.0.0.0/8">>, 5432}]}}}).
625630
```
626631

627632
**Isolated mode** is the only mode with a hard bound: `py_context:interrupt/1`
628633
stops a blocking C call, and `SIGKILL` is the backstop. It costs a process per
629634
context (about 16 MB and 40 ms to start) and roughly twice the call latency.
630635
Bulk data crosses through shared memory (`py_shm`, with the optional
631-
[iommap](https://hex.pm/packages/iommap) dependency). See
632-
[Isolated Contexts](docs/isolated.md).
636+
[iommap](https://hex.pm/packages/iommap) dependency), and the `caps` option
637+
names the files, addresses and environment the child may reach, as a
638+
cooperative policy over Python rather than a kernel boundary. See
639+
[Isolated Contexts](docs/isolated.md) and [Capabilities](docs/capabilities.md).
633640

634641
**Worker mode is recommended** because it works with any Python version and automatically benefits from free-threaded Python (3.13t+) when available. Each context owns a dedicated pthread, providing stable thread affinity for libraries with thread-local state (numpy, torch, tensorflow).
635642

‎docs/capabilities.md‎

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Capabilities
2+
3+
This guide covers `caps`, the option that says what an isolated child may
4+
reach: which directories, which environment variables, which addresses.
5+
Python that asks for anything else is refused. It is the WASI model, and
6+
the vocabulary is the same as
7+
[erlang_wasm](https://github.com/benoitc/erlang_wasm)'s, so a grant means
8+
the same thing in both.
9+
10+
**Read this before you rely on it.** `caps` is a cooperative policy over
11+
Python, not a boundary. It stops code that is not trying to get out, and it
12+
makes what a job may touch explicit and reviewable. It does not stop code
13+
that is trying to get out: a C extension calling `open(2)` never reaches
14+
the audit hook it is built on. Use it for code you partly trust; use the
15+
process boundary in [Isolated Contexts](isolated.md) for the rest, and see
16+
[what holds and what does not](#what-holds-and-what-does-not) for the
17+
detail.
18+
19+
Read [Isolated Contexts](isolated.md) first: `caps` only applies there.
20+
21+
## Grant what it needs
22+
23+
```erlang
24+
{ok, Ctx} = py_context:new(#{
25+
mode => isolated,
26+
caps => #{
27+
dirs => [{"/srv/models", read},
28+
{"/var/data/job42", write}],
29+
env => #{<<"MODEL_DIR">> => <<"/srv/models">>},
30+
net => #{connect => [{tcp, <<"10.0.0.0/8">>, {5432, 5432}}],
31+
resolve => deny}
32+
}}),
33+
{ok, _} = py_context:call(Ctx, scorer, run, [<<"job42">>]).
34+
```
35+
36+
Inside the child, ordinary Python works inside the grants and fails outside
37+
them:
38+
39+
```python
40+
open('/srv/models/weights.bin', 'rb') # granted
41+
open('/var/data/job42/out.csv', 'w') # granted
42+
open('/etc/passwd') # PermissionError
43+
open('/srv/models/w', 'w') # PermissionError: read grant
44+
```
45+
46+
Leave a key out and there is none of it. `caps => #{}` grants nothing but the
47+
interpreter's own files, and no `caps` key at all is the behaviour you have
48+
today: the child holds every authority the user running the node holds.
49+
50+
## What each key grants
51+
52+
| key | grants | leaving it out means |
53+
| --- | --- | --- |
54+
| `dirs` | directories, `read` or `write` | no filesystem beyond the interpreter's own |
55+
| `env` | environment variables | zero variables, **not** the node's |
56+
| `net` | sockets, to the addresses you name | no network at all |
57+
58+
`read` covers opening, reading and listing. `write` adds creating, renaming,
59+
unlinking and truncating. Rights apply to everything below the directory, so
60+
a `read` grant yields no writable file however the code opens it.
61+
62+
Always granted, because nothing works otherwise: the interpreter's own
63+
`sys.path`, `sys.prefix` and `sys.base_prefix` for reading, so imports work,
64+
and `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/urandom`. WASI preopens
65+
its sysroot for the same reason. Directories you list in the `paths` option
66+
are granted for reading too, since that option tells the child to import
67+
from them.
68+
69+
The `env` option and `caps` cannot be used together. The option adds
70+
variables and a grant says what the whole environment is, so taking both
71+
would let the option quietly win; `caps.env` is the one that names an
72+
environment, and using both is `{error, {bad_caps,
73+
env_option_conflicts_with_caps_env}}`.
74+
75+
## Name a network
76+
77+
```erlang
78+
net => #{connect => [{tcp, <<"10.0.0.0/8">>, {8000, 8099}}],
79+
listen => [{tcp, <<"127.0.0.1">>, 8080}],
80+
resolve => allow}
81+
```
82+
83+
A rule is `{Proto, Addr, Port}`. `Proto` is `tcp` or `udp`, `Addr` is an
84+
address tuple, a binary address or a binary CIDR, and `Port` is an integer, a
85+
`{Lo, Hi}` range, or `any`.
86+
87+
Four things to know before writing your first grant:
88+
89+
- **`connect` and `listen` are separate**, and neither implies the other.
90+
Binding claims a local address, which is what `listen` grants, so code
91+
wanting a particular source port needs a `listen` rule for it.
92+
- **You name addresses, never names.** There is no rule that says
93+
`example.com`: a name would have to be resolved to be checked and resolved
94+
again to be used, and the two answers can differ. `resolve` is its own
95+
capability, off unless granted, and what it returns carries no authority.
96+
Code may learn an address it cannot reach, and the connect is refused then.
97+
- **`::ffff:127.0.0.1` is `127.0.0.1`.** IPv4-mapped addresses are folded
98+
before matching, so the mapped notation cannot walk past an IPv4 rule.
99+
- **Nothing is denied implicitly.** `<<"0.0.0.0/0">>` really does include
100+
link-local and cloud metadata addresses. Name what you mean.
101+
102+
A socket Erlang opened and handed over with `py_context:pass_fd/2` needs no
103+
rule: the child was given the descriptor, and the descriptor is the
104+
capability. That is how you serve on a port under a capability set.
105+
106+
```erlang
107+
{ok, LSock} = gen_tcp:listen(8080, [binary, {active, false}]),
108+
{ok, Fd} = inet:getfd(LSock),
109+
{ok, ChildFd} = py_context:pass_fd(Ctx, Fd),
110+
ok = py_context:start_loop(Ctx),
111+
{ok, _} = py_context:submit_await(Ctx, myapp, serve, [ChildFd]).
112+
```
113+
114+
## Shared memory does not combine with this yet
115+
116+
A `py_shm` region reaches the child as a path, so under a capability set it
117+
is refused like any other ungranted path. Granting it would mean granting
118+
the directory the node keeps every region in, which hands over every
119+
region, and an open grant cannot prevent truncation anyway because
120+
`file.truncate()` announces nothing. A truncated region is a `SIGBUS` in
121+
the VM that mapped it, so the half-measure is worse than the refusal.
122+
123+
The fix is to pass the region's descriptor rather than its name, with
124+
`memfd_create` and `F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_SEAL` on Linux,
125+
which keeps a writable mapping while making the object unresizable, and
126+
cooperative-only handling where sealing does not exist. Until that lands,
127+
use shared memory or a capability set, not both.
128+
129+
## What is refused
130+
131+
Every route below has its own test case in `py_isolated_caps_SUITE`. All are
132+
refused with a `PermissionError` and never a `FileNotFoundError`, so the
133+
error cannot be used to find out what exists outside a grant.
134+
135+
| the code asks for | it gets |
136+
| --- | --- |
137+
| `note.txt`, `./note.txt`, `sub/deep.txt` | opened |
138+
| `sub/../note.txt` | opened: it never leaves the grant |
139+
| `../secret/key.txt` | refused |
140+
| `/etc/passwd` | refused |
141+
| `escape.txt`, a symlink out of the grant | refused |
142+
| `outdir/key.txt`, through a symlinked directory | refused |
143+
| `sub/../../secret/key.txt` | refused |
144+
| a symlink cycle | refused |
145+
| `missing.txt` inside a grant | `FileNotFoundError` |
146+
| `subprocess.run`, `os.fork`, `os.exec*` | refused |
147+
| `ctypes.CDLL` | refused |
148+
| `os.kill` at another process, `os.killpg` | refused |
149+
| `socket.gethostbyname` and every other resolver | refused unless `resolve` |
150+
| `os.mkfifo`, `os.mknod` | not there: see below |
151+
| a Unix-socket address, connect or bind | refused |
152+
153+
Signalling is refused because the child usually shares the node's user and
154+
its parent is the BEAM, so an unchecked `os.kill` is a way to take the node
155+
down. Signalling itself is allowed. Every resolver is gated, not only
156+
`getaddrinfo`: a name lookup is a message to whoever answers it, so gating
157+
one of them would leave the rest as a way out. A Unix-socket address is
158+
refused rather than checked as a file, because reaching one is talking to
159+
whatever is behind it, which a directory grant says nothing about; a
160+
descriptor Erlang passed over with `py_context:pass_fd/2` is unaffected.
161+
162+
Subprocesses are refused outright: a capability set names what may be
163+
reached, and another process is not something you granted. `ctypes` is
164+
refused because it reaches libc directly, which would make every rule above
165+
advisory. A library that needs `ctypes` cannot run under a capability set.
166+
167+
## What holds and what does not
168+
169+
Enforcement is a CPython audit hook, so the whole of what it can see is
170+
what CPython announces, and everything below follows from that.
171+
172+
**What holds.** Python code that asks for a path, an address or a process
173+
outside the grants is refused, whether it asks through `open`, `os.open`,
174+
`pathlib`, numpy or any other library, because the event is raised by the
175+
interpreter rather than by the caller. Path containment is resolved by the
176+
kernel one component at a time, so `..`, absolute paths, symlinks out of a
177+
grant and symlinked directory prefixes are all refused rather than
178+
lexically guessed at. Nothing on the decision path is reachable by name:
179+
the grants, the tables and even the `os` functions the check uses are bound
180+
into the hook's closure when it is installed, so assigning to this module
181+
changes nothing.
182+
183+
**What does not hold.**
184+
185+
- A C extension calling `open(2)` or `connect(2)` never reaches an audit
186+
hook. Neither does `file.truncate()` or `mmap.resize()`, which CPython
187+
does not announce, so a writable descriptor can always shorten its own
188+
file. That is part of what a `write` grant grants.
189+
- A thread that replaces a path between the check and the kernel's own
190+
resolution is not stopped. Do not point a `write` grant at a directory
191+
another party writes to concurrently.
192+
- `os.stat`, `os.access` and the rest of the calls that observe without
193+
reaching are left alone, so what exists outside a grant stays visible
194+
even though reading it does not.
195+
- Closure state is a bar, not a wall. Python exposes its own object graph,
196+
and code that goes looking can reach a hook's cells.
197+
198+
**What would make it hold.** A kernel. Landlock on Linux takes the same
199+
grant table and enforces it below the interpreter, which is the point at
200+
which a C extension stops being an exception; moving the state and the hook
201+
into the NIF would take the rest. Neither is here yet. Until then, the
202+
boundary you have is the process: `rlimits`, `kill_after`, and the
203+
supervision in [Isolated Contexts](isolated.md).
204+
205+
## Cost
206+
207+
The check is an audit hook, so it runs on every open, and paths are resolved
208+
one component at a time against the descriptor of the grant. Measured on
209+
macOS with Python 3.14, an open inside a grant costs about 11 microseconds
210+
more than an unguarded one, and the cost grows with the depth of the path
211+
below its grant. Grant close to what the code reads: `/srv/models` rather
212+
than `/`.
213+
214+
Nothing else changes. Calls, results, shared memory and interrupts are what
215+
they were.
216+
217+
## Check what a child got
218+
219+
```erlang
220+
{ok, Info} = py_context:child_info(Ctx),
221+
maps:get(caps, Info).
222+
```
223+
224+
```python
225+
import erlang
226+
erlang.caps() # None when no capability set was given
227+
```
228+
229+
Both report the grants as the child holds them, including the automatic
230+
ones, and `strict_paths` tells you whether the platform resolved paths
231+
component by component or fell back to a lexical check.
232+
233+
## See also
234+
235+
- [Isolated Contexts](isolated.md) for the process boundary itself
236+
- [Security](security.md) for what the embedded modes do instead
237+
- [decision 0009](decisions/0009-child-capabilities.md) for why it is shaped
238+
this way

‎docs/code-map.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ exercised by suites). Guides are in `docs/`, suites in `test/`. Start with
2929
| `py_channel`, `py_byte_channel` | Term and byte queues between Erlang and Python coroutines (NIF resources) | live | channel | `py_channel_SUITE`, `py_byte_channel_SUITE` |
3030
| `py_buffer` | Native streaming input buffer; shared variant delegates to `py_shm` | live | buffer, isolated | `py_buffer_SUITE`, `py_isolated_buffer_SUITE` |
3131
| `py_shm` | Shared memory regions over iommap and the ring behind shared buffers | live | isolated | `py_isolated_shm_SUITE` |
32+
| `py_caps` | The `caps' option: what an isolated child may reach, and its wire form | live | capabilities | `py_isolated_caps_SUITE` |
3233
| `py_import` | Registry of imports and `sys.path` entries applied to every interpreter | live | imports | `py_import_SUITE` |
3334
| `py_preload` | Code run once per interpreter at start | live | preload | `py_preload_SUITE` |
3435
| `py_state` | Shared key/value store visible from Python as `erlang.state_get/set/delete/keys` | live | README (shared state) | `py_state_SUITE` |
@@ -80,6 +81,7 @@ loop, channels and servers.
8081
| `_erlang_impl/_etf.py` | Pure-Python ETF codec with the `py_convert.c` mapping | isolated child |
8182
| `_erlang_impl/_isolated.py` | Child runtime: socket frames, reader thread, re-entrant main loop, interrupt signal, asyncio loop, the `erlang` shim | isolated child |
8283
| `_erlang_impl/_shm.py` | `SharedMemory` and `SharedBuffer` wrappers over mmap | all |
84+
| `_erlang_impl/_caps.py` | Capability enforcement in the child: path containment, address matching, the audit hook | isolated child |
8385
| `py_isolated_child.py` | Child launcher: rlimits, parent-death signal, cgroup join, connect | isolated child |
8486
| `test_erlang_loop.py`, `test_async_task.py`, `test_channel_ref.py`, `tests/` | Python-side tests of the loop, tasks and channels | test |
8587

0 commit comments

Comments
 (0)