|
| 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 |
0 commit comments