postern 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- postern-0.1.0/LICENSE +21 -0
- postern-0.1.0/PKG-INFO +183 -0
- postern-0.1.0/README.md +156 -0
- postern-0.1.0/pyproject.toml +110 -0
- postern-0.1.0/setup.cfg +4 -0
- postern-0.1.0/src/postern/__init__.py +36 -0
- postern-0.1.0/src/postern/_guest.py +32 -0
- postern-0.1.0/src/postern/_sandbox.py +348 -0
- postern-0.1.0/src/postern/_seccomp.bpf +0 -0
- postern-0.1.0/src/postern/_seccomp.py +181 -0
- postern-0.1.0/src/postern/_seccomp.spec +4 -0
- postern-0.1.0/src/postern/grpc.py +126 -0
- postern-0.1.0/src/postern/py.typed +0 -0
- postern-0.1.0/src/postern.egg-info/PKG-INFO +183 -0
- postern-0.1.0/src/postern.egg-info/SOURCES.txt +22 -0
- postern-0.1.0/src/postern.egg-info/dependency_links.txt +1 -0
- postern-0.1.0/src/postern.egg-info/requires.txt +3 -0
- postern-0.1.0/src/postern.egg-info/top_level.txt +1 -0
- postern-0.1.0/tests/test_grpc_hatch.py +76 -0
- postern-0.1.0/tests/test_hatch_e2e.py +88 -0
- postern-0.1.0/tests/test_profile.py +104 -0
- postern-0.1.0/tests/test_sandbox_e2e.py +113 -0
- postern-0.1.0/tests/test_seccomp.py +79 -0
- postern-0.1.0/tests/test_workspace.py +25 -0
postern-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Centre for Population Genomics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
postern-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: postern
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run untrusted Python in an OS-isolated sandbox whose only exit is a set of host-defined, typed calls.
|
|
5
|
+
Author: Centre for Population Genomics
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/populationgenomics/postern
|
|
8
|
+
Project-URL: Issues, https://github.com/populationgenomics/postern/issues
|
|
9
|
+
Keywords: sandbox,bubblewrap,isolation,untrusted-code,seccomp,rpc,agent,tool-use
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Interpreters
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: grpc
|
|
25
|
+
Requires-Dist: grpcio>=1.60; extra == "grpc"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# postern
|
|
29
|
+
|
|
30
|
+
Run untrusted Python in an OS-isolated sandbox whose **only** exit is a set of
|
|
31
|
+
host-defined, typed gRPC methods.
|
|
32
|
+
|
|
33
|
+
A postern is the small guarded gate through an otherwise sealed wall. That is the
|
|
34
|
+
model: guest code runs with no network, no filesystem beyond a workspace, no
|
|
35
|
+
capabilities — and reaches the outside world only by calling the specific gRPC
|
|
36
|
+
methods the host allowlists. The security boundary is that method set, not a
|
|
37
|
+
coarse permission flag.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from postern import Sandbox, SandboxProfile
|
|
41
|
+
from postern.grpc import GrpcHatch
|
|
42
|
+
import greeter_pb2_grpc
|
|
43
|
+
|
|
44
|
+
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
|
|
45
|
+
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
|
|
46
|
+
|
|
47
|
+
profile = SandboxProfile.with_venv('/opt/analysis-env') # pandas, grpcio, stubs
|
|
48
|
+
result = Sandbox(profile, hatch=hatch).run_python(guest_code)
|
|
49
|
+
# guest dials unix:$POSTERN_HATCH with the generated stub; a non-allowlisted
|
|
50
|
+
# method → PERMISSION_DENIED; there is no network.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Why
|
|
54
|
+
|
|
55
|
+
Coarse sandbox permissions (`--allow-net`, `--allow-read`) are the wrong grain
|
|
56
|
+
for untrusted agent/tool code: you rarely want "the network", you want "this one
|
|
57
|
+
method that fetches this one resource". postern inverts the default — the guest
|
|
58
|
+
gets **nothing** except the host methods you allowlist, each a typed proto shape.
|
|
59
|
+
Whatever a method can reach (a database, a credentialed API, a compute backend)
|
|
60
|
+
the guest reaches only through that shape, never directly.
|
|
61
|
+
|
|
62
|
+
This is the design [enclave](https://github.com/populationgenomics/enclave-py)
|
|
63
|
+
prototyped over WebAssembly (WASI-compiled CPython). postern delivers the same
|
|
64
|
+
"fine-grained function injection is the boundary" promise over a different
|
|
65
|
+
substrate — **OS isolation (bubblewrap) + a gRPC-over-UDS hatch** — which means
|
|
66
|
+
real CPython with arbitrary third-party packages (no custom toolchain), and
|
|
67
|
+
typed, language-neutral arguments/results (proto, `buf breaking`-gateable).
|
|
68
|
+
|
|
69
|
+
## Isolation
|
|
70
|
+
|
|
71
|
+
`Sandbox` launches the guest under [bubblewrap](https://github.com/containers/bubblewrap):
|
|
72
|
+
|
|
73
|
+
- **empty network namespace** — no egress of any kind (a socket can be created
|
|
74
|
+
but has no route). The user and cgroup namespaces are unshared **strictly**
|
|
75
|
+
(`--unshare-user`/`--unshare-cgroup`, not `--unshare-all`'s best-effort `-try`
|
|
76
|
+
variants), so a host that can't provide a user namespace is a hard launch
|
|
77
|
+
failure rather than a silent fall-through to a real-root guest;
|
|
78
|
+
- **surgical filesystem** — read-only base system dirs + one writable
|
|
79
|
+
`/workspace`; no `/etc`, `/home`, `/root`, or host application code;
|
|
80
|
+
- **`--cap-drop ALL`**, **`--new-session`** (anti terminal-injection),
|
|
81
|
+
**`--die-with-parent`**, **`--clearenv`**;
|
|
82
|
+
- **non-root guest** — the guest runs as uid/gid `65534` (`nobody`), so it holds
|
|
83
|
+
no capabilities inside its user namespace, and if the userns fails to
|
|
84
|
+
materialise on a root host it still drops to a non-root real uid
|
|
85
|
+
(`SandboxProfile(guest_uid=None)` restores the legacy uid-0-in-userns);
|
|
86
|
+
- a **seccomp denylist** blocking escape-enabling syscalls (`unshare`, `setns`,
|
|
87
|
+
`mount`, `ptrace`, `bpf`, `keyctl`, …). `socket` is deliberately *not* blocked
|
|
88
|
+
— network isolation is the netns's job, and the guest needs `socket(AF_UNIX)`
|
|
89
|
+
for the hatch;
|
|
90
|
+
- **`RLIMIT_NPROC`** as a fork-bomb backstop (set inside the guest), and an
|
|
91
|
+
optional **`RLIMIT_AS`** memory backstop (`SandboxProfile(rlimit_as=...)`, off
|
|
92
|
+
by default; a cgroup `memory.max` at the deploy layer is the real isolation).
|
|
93
|
+
|
|
94
|
+
The hatch UDS is bind-mounted in as the single controlled opening. Because the
|
|
95
|
+
RPC rides that socket, the guest's own stdin/stdout/stderr stay free.
|
|
96
|
+
|
|
97
|
+
**Fail-closed boot check.** Every control is enforced on the launch path: the
|
|
98
|
+
strict `--unshare-*` flags make bwrap abort if it can't create the namespaces,
|
|
99
|
+
apply `--uid`, or drop capabilities, and the seccomp loader refuses an uncovered
|
|
100
|
+
architecture — so a successful launch *is* the proof (no runtime probe, like
|
|
101
|
+
Chrome's sandbox). `Sandbox(profile).verify()` just triggers one trivial launch
|
|
102
|
+
at startup so a broken platform (no user namespace, gVisor, uncovered arch)
|
|
103
|
+
raises `IsolationError` there rather than on the first request. Call it at worker
|
|
104
|
+
startup and refuse to serve if it raises (`examples/worker.py` does this).
|
|
105
|
+
|
|
106
|
+
## The environment (getting pandas etc. in)
|
|
107
|
+
|
|
108
|
+
The sandbox has no egress, so packages are provisioned **ahead of time** and
|
|
109
|
+
mounted read-only — never `pip install`ed at run time.
|
|
110
|
+
|
|
111
|
+
- `SandboxProfile.with_venv('/opt/env')` binds a venv read-only (at its own path,
|
|
112
|
+
so its `site.py` resolution works) and runs its interpreter. The venv holds the
|
|
113
|
+
guest's libraries **and** its hatch client (grpcio + the generated stubs).
|
|
114
|
+
- `SandboxProfile(rootfs='/opt/guest-root')` binds a curated base directory as the
|
|
115
|
+
guest's system dirs *instead of the host's* — hiding the host userland
|
|
116
|
+
entirely. Build it at image-build time (build-time Docker is fine; only
|
|
117
|
+
*runtime* container engines are excluded): `docker export` a container into a
|
|
118
|
+
dir, or ship a single squashfs/erofs image file mounted read-only via FUSE
|
|
119
|
+
(`squashfuse`, unprivileged, Cloud-Run-compatible) and point `rootfs` at the
|
|
120
|
+
mountpoint. `bwrap --ro-overlay` can stack OCI layer dirs without flattening.
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
|
|
124
|
+
Linux with **bubblewrap** and unprivileged user namespaces (a Cloud Run gen2 Job,
|
|
125
|
+
or any such host). `postern.available()` reports whether a sandbox can launch.
|
|
126
|
+
The seccomp filter is a prebuilt multi-arch BPF blob (x86_64, x86, x32, aarch64,
|
|
127
|
+
arm); on any other architecture it would be a default-allow no-op, so `Sandbox`
|
|
128
|
+
**refuses to launch** there (fail-closed) rather than run with an unenforced
|
|
129
|
+
filter — set `SandboxProfile(seccomp=False)` to override deliberately. Not
|
|
130
|
+
runnable on macOS except against a Linux target — `import postern` works
|
|
131
|
+
anywhere, `Sandbox.run*` needs the OS.
|
|
132
|
+
|
|
133
|
+
**Ubuntu 23.10+ / 24.04** restrict unprivileged user namespaces by default
|
|
134
|
+
(`kernel.apparmor_restrict_unprivileged_userns=1`), which bubblewrap needs —
|
|
135
|
+
the symptom is `bwrap: setting up uid map: Permission denied` or `loopback:
|
|
136
|
+
Failed RTM_NEWADDR`. Lift it with `sudo sysctl -w
|
|
137
|
+
kernel.apparmor_restrict_unprivileged_userns=0`, or install an AppArmor profile
|
|
138
|
+
that grants bwrap `userns`. Cloud Run gen2 does not have this restriction.
|
|
139
|
+
|
|
140
|
+
The bare `Sandbox` has **no third-party dependencies and no cloud dependency** —
|
|
141
|
+
it is a Linux primitive. The gRPC hatch pulls `grpcio` via the `grpc` extra.
|
|
142
|
+
|
|
143
|
+
## Install
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install postern # the bare sandbox (no deps)
|
|
147
|
+
pip install 'postern[grpc]' # + the gRPC hatch
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Public API
|
|
151
|
+
|
|
152
|
+
- `Sandbox(profile=None, *, hatch=None)` — `.run(argv)`, `.run_python(code)` → `ProcResult(returncode, stdout, stderr, ok)`; `.verify()` (fail-closed boot check, raises `IsolationError`).
|
|
153
|
+
- `SandboxProfile(workspace=None, rootfs=None, python='python3', ro_binds=[], stubs=None, env=..., seccomp=True, rlimit_nproc=1024, rlimit_as=None, guest_uid=65534, guest_gid=65534)` and `SandboxProfile.with_venv(venv, **kw)`. `stubs=` injects a dir or list of files at `/run/postern/stubs` (on `PYTHONPATH`) — a shared rootfs carries the heavy base, per-agent stubs bind in selectively.
|
|
154
|
+
- `postern.grpc.GrpcHatch(allowlist, *, socket_path=None)` — `.add_servicer(register_fn, servicer)`; `with hatch.accepting(): ...`. (`grpc` extra.)
|
|
155
|
+
- `available()` — bubblewrap present?
|
|
156
|
+
|
|
157
|
+
See `examples/e2e_greeter.py` for an end-to-end run (typed hatch call + pandas,
|
|
158
|
+
verified on a Linux host).
|
|
159
|
+
|
|
160
|
+
## Deploy: bundle the rootfs into the Job image
|
|
161
|
+
|
|
162
|
+
For a Cloud Run Job you build an image anyway, so bundle the guest rootfs into
|
|
163
|
+
it and let postern bind it — no runtime container engine. `examples/Dockerfile`
|
|
164
|
+
is the recipe: a multi-stage build that (1) generates the stubs, (2) builds a
|
|
165
|
+
minimal guest rootfs (`python:slim` + grpcio + your data libs + the client
|
|
166
|
+
stubs), and (3) assembles the worker (bubblewrap + `postern[grpc]` + your
|
|
167
|
+
servicer) with the guest rootfs copied to `/opt/guest-root`. The worker
|
|
168
|
+
(`examples/worker.py`) binds it with `SandboxProfile(rootfs='/opt/guest-root')`,
|
|
169
|
+
so the guest sees only that curated image, never the worker's userland. Cloud
|
|
170
|
+
Run gen2 provides the unprivileged user namespaces bubblewrap needs.
|
|
171
|
+
|
|
172
|
+
## Roadmap
|
|
173
|
+
|
|
174
|
+
- **Checkpoint/restore** — a `Store` protocol + durable-glob workspace snapshots
|
|
175
|
+
for run-lived state continuity.
|
|
176
|
+
- **`overlay=` profile mode** — emit `bwrap --ro-overlay` to stack layers with a
|
|
177
|
+
tmpfs upper, instead of a single `--ro-bind` rootfs.
|
|
178
|
+
- **Agent-runtime adapters** — drive the sandbox from Anthropic Managed Agents,
|
|
179
|
+
Google ADK, or MCP (the same sandbox, provider-agnostic).
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT.
|
postern-0.1.0/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# postern
|
|
2
|
+
|
|
3
|
+
Run untrusted Python in an OS-isolated sandbox whose **only** exit is a set of
|
|
4
|
+
host-defined, typed gRPC methods.
|
|
5
|
+
|
|
6
|
+
A postern is the small guarded gate through an otherwise sealed wall. That is the
|
|
7
|
+
model: guest code runs with no network, no filesystem beyond a workspace, no
|
|
8
|
+
capabilities — and reaches the outside world only by calling the specific gRPC
|
|
9
|
+
methods the host allowlists. The security boundary is that method set, not a
|
|
10
|
+
coarse permission flag.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from postern import Sandbox, SandboxProfile
|
|
14
|
+
from postern.grpc import GrpcHatch
|
|
15
|
+
import greeter_pb2_grpc
|
|
16
|
+
|
|
17
|
+
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
|
|
18
|
+
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
|
|
19
|
+
|
|
20
|
+
profile = SandboxProfile.with_venv('/opt/analysis-env') # pandas, grpcio, stubs
|
|
21
|
+
result = Sandbox(profile, hatch=hatch).run_python(guest_code)
|
|
22
|
+
# guest dials unix:$POSTERN_HATCH with the generated stub; a non-allowlisted
|
|
23
|
+
# method → PERMISSION_DENIED; there is no network.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
Coarse sandbox permissions (`--allow-net`, `--allow-read`) are the wrong grain
|
|
29
|
+
for untrusted agent/tool code: you rarely want "the network", you want "this one
|
|
30
|
+
method that fetches this one resource". postern inverts the default — the guest
|
|
31
|
+
gets **nothing** except the host methods you allowlist, each a typed proto shape.
|
|
32
|
+
Whatever a method can reach (a database, a credentialed API, a compute backend)
|
|
33
|
+
the guest reaches only through that shape, never directly.
|
|
34
|
+
|
|
35
|
+
This is the design [enclave](https://github.com/populationgenomics/enclave-py)
|
|
36
|
+
prototyped over WebAssembly (WASI-compiled CPython). postern delivers the same
|
|
37
|
+
"fine-grained function injection is the boundary" promise over a different
|
|
38
|
+
substrate — **OS isolation (bubblewrap) + a gRPC-over-UDS hatch** — which means
|
|
39
|
+
real CPython with arbitrary third-party packages (no custom toolchain), and
|
|
40
|
+
typed, language-neutral arguments/results (proto, `buf breaking`-gateable).
|
|
41
|
+
|
|
42
|
+
## Isolation
|
|
43
|
+
|
|
44
|
+
`Sandbox` launches the guest under [bubblewrap](https://github.com/containers/bubblewrap):
|
|
45
|
+
|
|
46
|
+
- **empty network namespace** — no egress of any kind (a socket can be created
|
|
47
|
+
but has no route). The user and cgroup namespaces are unshared **strictly**
|
|
48
|
+
(`--unshare-user`/`--unshare-cgroup`, not `--unshare-all`'s best-effort `-try`
|
|
49
|
+
variants), so a host that can't provide a user namespace is a hard launch
|
|
50
|
+
failure rather than a silent fall-through to a real-root guest;
|
|
51
|
+
- **surgical filesystem** — read-only base system dirs + one writable
|
|
52
|
+
`/workspace`; no `/etc`, `/home`, `/root`, or host application code;
|
|
53
|
+
- **`--cap-drop ALL`**, **`--new-session`** (anti terminal-injection),
|
|
54
|
+
**`--die-with-parent`**, **`--clearenv`**;
|
|
55
|
+
- **non-root guest** — the guest runs as uid/gid `65534` (`nobody`), so it holds
|
|
56
|
+
no capabilities inside its user namespace, and if the userns fails to
|
|
57
|
+
materialise on a root host it still drops to a non-root real uid
|
|
58
|
+
(`SandboxProfile(guest_uid=None)` restores the legacy uid-0-in-userns);
|
|
59
|
+
- a **seccomp denylist** blocking escape-enabling syscalls (`unshare`, `setns`,
|
|
60
|
+
`mount`, `ptrace`, `bpf`, `keyctl`, …). `socket` is deliberately *not* blocked
|
|
61
|
+
— network isolation is the netns's job, and the guest needs `socket(AF_UNIX)`
|
|
62
|
+
for the hatch;
|
|
63
|
+
- **`RLIMIT_NPROC`** as a fork-bomb backstop (set inside the guest), and an
|
|
64
|
+
optional **`RLIMIT_AS`** memory backstop (`SandboxProfile(rlimit_as=...)`, off
|
|
65
|
+
by default; a cgroup `memory.max` at the deploy layer is the real isolation).
|
|
66
|
+
|
|
67
|
+
The hatch UDS is bind-mounted in as the single controlled opening. Because the
|
|
68
|
+
RPC rides that socket, the guest's own stdin/stdout/stderr stay free.
|
|
69
|
+
|
|
70
|
+
**Fail-closed boot check.** Every control is enforced on the launch path: the
|
|
71
|
+
strict `--unshare-*` flags make bwrap abort if it can't create the namespaces,
|
|
72
|
+
apply `--uid`, or drop capabilities, and the seccomp loader refuses an uncovered
|
|
73
|
+
architecture — so a successful launch *is* the proof (no runtime probe, like
|
|
74
|
+
Chrome's sandbox). `Sandbox(profile).verify()` just triggers one trivial launch
|
|
75
|
+
at startup so a broken platform (no user namespace, gVisor, uncovered arch)
|
|
76
|
+
raises `IsolationError` there rather than on the first request. Call it at worker
|
|
77
|
+
startup and refuse to serve if it raises (`examples/worker.py` does this).
|
|
78
|
+
|
|
79
|
+
## The environment (getting pandas etc. in)
|
|
80
|
+
|
|
81
|
+
The sandbox has no egress, so packages are provisioned **ahead of time** and
|
|
82
|
+
mounted read-only — never `pip install`ed at run time.
|
|
83
|
+
|
|
84
|
+
- `SandboxProfile.with_venv('/opt/env')` binds a venv read-only (at its own path,
|
|
85
|
+
so its `site.py` resolution works) and runs its interpreter. The venv holds the
|
|
86
|
+
guest's libraries **and** its hatch client (grpcio + the generated stubs).
|
|
87
|
+
- `SandboxProfile(rootfs='/opt/guest-root')` binds a curated base directory as the
|
|
88
|
+
guest's system dirs *instead of the host's* — hiding the host userland
|
|
89
|
+
entirely. Build it at image-build time (build-time Docker is fine; only
|
|
90
|
+
*runtime* container engines are excluded): `docker export` a container into a
|
|
91
|
+
dir, or ship a single squashfs/erofs image file mounted read-only via FUSE
|
|
92
|
+
(`squashfuse`, unprivileged, Cloud-Run-compatible) and point `rootfs` at the
|
|
93
|
+
mountpoint. `bwrap --ro-overlay` can stack OCI layer dirs without flattening.
|
|
94
|
+
|
|
95
|
+
## Requirements
|
|
96
|
+
|
|
97
|
+
Linux with **bubblewrap** and unprivileged user namespaces (a Cloud Run gen2 Job,
|
|
98
|
+
or any such host). `postern.available()` reports whether a sandbox can launch.
|
|
99
|
+
The seccomp filter is a prebuilt multi-arch BPF blob (x86_64, x86, x32, aarch64,
|
|
100
|
+
arm); on any other architecture it would be a default-allow no-op, so `Sandbox`
|
|
101
|
+
**refuses to launch** there (fail-closed) rather than run with an unenforced
|
|
102
|
+
filter — set `SandboxProfile(seccomp=False)` to override deliberately. Not
|
|
103
|
+
runnable on macOS except against a Linux target — `import postern` works
|
|
104
|
+
anywhere, `Sandbox.run*` needs the OS.
|
|
105
|
+
|
|
106
|
+
**Ubuntu 23.10+ / 24.04** restrict unprivileged user namespaces by default
|
|
107
|
+
(`kernel.apparmor_restrict_unprivileged_userns=1`), which bubblewrap needs —
|
|
108
|
+
the symptom is `bwrap: setting up uid map: Permission denied` or `loopback:
|
|
109
|
+
Failed RTM_NEWADDR`. Lift it with `sudo sysctl -w
|
|
110
|
+
kernel.apparmor_restrict_unprivileged_userns=0`, or install an AppArmor profile
|
|
111
|
+
that grants bwrap `userns`. Cloud Run gen2 does not have this restriction.
|
|
112
|
+
|
|
113
|
+
The bare `Sandbox` has **no third-party dependencies and no cloud dependency** —
|
|
114
|
+
it is a Linux primitive. The gRPC hatch pulls `grpcio` via the `grpc` extra.
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install postern # the bare sandbox (no deps)
|
|
120
|
+
pip install 'postern[grpc]' # + the gRPC hatch
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Public API
|
|
124
|
+
|
|
125
|
+
- `Sandbox(profile=None, *, hatch=None)` — `.run(argv)`, `.run_python(code)` → `ProcResult(returncode, stdout, stderr, ok)`; `.verify()` (fail-closed boot check, raises `IsolationError`).
|
|
126
|
+
- `SandboxProfile(workspace=None, rootfs=None, python='python3', ro_binds=[], stubs=None, env=..., seccomp=True, rlimit_nproc=1024, rlimit_as=None, guest_uid=65534, guest_gid=65534)` and `SandboxProfile.with_venv(venv, **kw)`. `stubs=` injects a dir or list of files at `/run/postern/stubs` (on `PYTHONPATH`) — a shared rootfs carries the heavy base, per-agent stubs bind in selectively.
|
|
127
|
+
- `postern.grpc.GrpcHatch(allowlist, *, socket_path=None)` — `.add_servicer(register_fn, servicer)`; `with hatch.accepting(): ...`. (`grpc` extra.)
|
|
128
|
+
- `available()` — bubblewrap present?
|
|
129
|
+
|
|
130
|
+
See `examples/e2e_greeter.py` for an end-to-end run (typed hatch call + pandas,
|
|
131
|
+
verified on a Linux host).
|
|
132
|
+
|
|
133
|
+
## Deploy: bundle the rootfs into the Job image
|
|
134
|
+
|
|
135
|
+
For a Cloud Run Job you build an image anyway, so bundle the guest rootfs into
|
|
136
|
+
it and let postern bind it — no runtime container engine. `examples/Dockerfile`
|
|
137
|
+
is the recipe: a multi-stage build that (1) generates the stubs, (2) builds a
|
|
138
|
+
minimal guest rootfs (`python:slim` + grpcio + your data libs + the client
|
|
139
|
+
stubs), and (3) assembles the worker (bubblewrap + `postern[grpc]` + your
|
|
140
|
+
servicer) with the guest rootfs copied to `/opt/guest-root`. The worker
|
|
141
|
+
(`examples/worker.py`) binds it with `SandboxProfile(rootfs='/opt/guest-root')`,
|
|
142
|
+
so the guest sees only that curated image, never the worker's userland. Cloud
|
|
143
|
+
Run gen2 provides the unprivileged user namespaces bubblewrap needs.
|
|
144
|
+
|
|
145
|
+
## Roadmap
|
|
146
|
+
|
|
147
|
+
- **Checkpoint/restore** — a `Store` protocol + durable-glob workspace snapshots
|
|
148
|
+
for run-lived state continuity.
|
|
149
|
+
- **`overlay=` profile mode** — emit `bwrap --ro-overlay` to stack layers with a
|
|
150
|
+
tmpfs upper, instead of a single `--ro-bind` rootfs.
|
|
151
|
+
- **Agent-runtime adapters** — drive the sandbox from Anthropic Managed Agents,
|
|
152
|
+
Google ADK, or MCP (the same sandbox, provider-agnostic).
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "postern"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run untrusted Python in an OS-isolated sandbox whose only exit is a set of host-defined, typed calls."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Centre for Population Genomics" }]
|
|
13
|
+
keywords = ["sandbox", "bubblewrap", "isolation", "untrusted-code", "seccomp", "rpc", "agent", "tool-use"]
|
|
14
|
+
# The core has NO runtime dependencies — pure stdlib (subprocess, socket, json),
|
|
15
|
+
# bubblewrap at the OS layer. gRPC hatch and provider adapters are extras.
|
|
16
|
+
dependencies = []
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Topic :: Software Development :: Interpreters",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# The gRPC hatch: serve any grpc.aio servicer over the sandbox UDS, method-allowlisted.
|
|
33
|
+
grpc = ["grpcio>=1.60"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/populationgenomics/postern"
|
|
37
|
+
Issues = "https://github.com/populationgenomics/postern/issues"
|
|
38
|
+
|
|
39
|
+
# Supply-chain age gate: never resolve a release younger than 7 days (relative,
|
|
40
|
+
# in-repo so CI and contributors inherit it).
|
|
41
|
+
[tool.uv]
|
|
42
|
+
exclude-newer = "7 days"
|
|
43
|
+
|
|
44
|
+
# grpcio (the `grpc` extra) is in the dev groups so the hatch tests run and
|
|
45
|
+
# pyright resolves postern.grpc + the hatch tests; consumers opt in via the
|
|
46
|
+
# `grpc` extra.
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
test = ["pytest", "grpcio>=1.60"]
|
|
49
|
+
# typing-extensions: type-check-only backport of typing.Self for the 3.10 floor
|
|
50
|
+
# (see src/postern/_sandbox.py) — not a runtime dependency.
|
|
51
|
+
lint = ["ruff", "pyright", "pre-commit", "pytest", "grpcio>=1.60", "typing-extensions"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.packages.find]
|
|
54
|
+
where = ["src"]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.package-data]
|
|
57
|
+
postern = ["py.typed", "_seccomp.bpf", "_seccomp.spec"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 120
|
|
61
|
+
target-version = "py310"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.format]
|
|
64
|
+
quote-style = "single"
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = [
|
|
68
|
+
"A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "W",
|
|
69
|
+
"ANN", "ARG", "BLE", "COM", "DTZ", "ERA", "EXE", "ICN", "ISC",
|
|
70
|
+
"PGH", "PIE", "PL", "PT", "PYI", "RET", "RSE", "RUF",
|
|
71
|
+
"SIM", "SLF", "T10", "TID", "UP", "YTT",
|
|
72
|
+
]
|
|
73
|
+
fixable = ["ALL"]
|
|
74
|
+
ignore = [
|
|
75
|
+
"COM812",
|
|
76
|
+
"D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107",
|
|
77
|
+
"ISC001",
|
|
78
|
+
"PLR0913",
|
|
79
|
+
"PLR2004",
|
|
80
|
+
"Q000", "Q003",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint.per-file-ignores]
|
|
84
|
+
# The in-sandbox shim is stdlib-only, dynamic (host.<attr> dispatch), run as a
|
|
85
|
+
# script and never imported by the package — held to correctness, not API rigor.
|
|
86
|
+
"src/postern/_guest.py" = ["T201", "BLE001", "ANN", "D", "SLF001"]
|
|
87
|
+
"src/postern/_sandbox.py" = ["S603"]
|
|
88
|
+
"tests/**" = ["S101", "S102", "S603", "S607", "SLF001", "ANN", "D", "PLR2004", "T201"]
|
|
89
|
+
# Runnable examples: gRPC servicer methods are PascalCase; print is the UI; no
|
|
90
|
+
# package __init__; they import generated stubs not present at lint time.
|
|
91
|
+
"examples/**" = ["N802", "T201", "ANN", "D", "S", "INP001", "ARG"]
|
|
92
|
+
# Build-time tooling (not shipped): imports libseccomp, prints, no package init.
|
|
93
|
+
"tools/**" = ["T201", "ANN", "D", "S", "INP001", "E402", "SLF001"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.pydocstyle]
|
|
96
|
+
convention = "google"
|
|
97
|
+
|
|
98
|
+
[tool.ruff.lint.isort]
|
|
99
|
+
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
|
100
|
+
|
|
101
|
+
[tool.pyright]
|
|
102
|
+
# Check against the floor of requires-python; run via `uv run --group lint` so
|
|
103
|
+
# the uv-managed environment supplies the imports.
|
|
104
|
+
pythonVersion = "3.10"
|
|
105
|
+
include = ["src", "tests"]
|
|
106
|
+
exclude = ["**/__pycache__", "src/postern/_guest.py"]
|
|
107
|
+
typeCheckingMode = "standard"
|
|
108
|
+
|
|
109
|
+
[tool.pytest.ini_options]
|
|
110
|
+
testpaths = ["tests"]
|
postern-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""postern — untrusted code in a sealed sandbox with one typed doorway.
|
|
2
|
+
|
|
3
|
+
Run untrusted Python in an OS-isolated `Sandbox` (bubblewrap: empty network
|
|
4
|
+
namespace, surgical filesystem, dropped capabilities, seccomp) whose *only*
|
|
5
|
+
interface to the outside is a hatch — host-provided gRPC methods, gated by an
|
|
6
|
+
allowlist, that the guest calls with the generated stub. The security boundary
|
|
7
|
+
is that method set, not a coarse permission flag.
|
|
8
|
+
|
|
9
|
+
from postern import Sandbox, SandboxProfile
|
|
10
|
+
from postern.grpc import GrpcHatch
|
|
11
|
+
import greeter_pb2_grpc
|
|
12
|
+
|
|
13
|
+
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
|
|
14
|
+
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
|
|
15
|
+
|
|
16
|
+
profile = SandboxProfile.with_venv('/opt/analysis-env') # pandas, grpcio, stubs
|
|
17
|
+
Sandbox(profile, hatch=hatch).run_python(guest_code)
|
|
18
|
+
|
|
19
|
+
The bare `Sandbox` has no third-party dependencies and no cloud dependency — it
|
|
20
|
+
is a Linux + bubblewrap primitive. The gRPC hatch lives behind the ``grpc``
|
|
21
|
+
extra; provider/runtime adapters behind their own.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib.metadata
|
|
27
|
+
|
|
28
|
+
from postern._sandbox import IsolationError, ProcResult, Sandbox, SandboxProfile, available
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = importlib.metadata.version('postern')
|
|
32
|
+
except importlib.metadata.PackageNotFoundError:
|
|
33
|
+
# Running from a source tree without an install.
|
|
34
|
+
__version__ = '0.0.0+unknown'
|
|
35
|
+
|
|
36
|
+
__all__ = ['IsolationError', 'ProcResult', 'Sandbox', 'SandboxProfile', '__version__', 'available']
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""In-sandbox entrypoint for `Sandbox.run_python`.
|
|
2
|
+
|
|
3
|
+
Runs *inside* the bubblewrap sandbox, so it is stdlib-only. It applies the
|
|
4
|
+
process-count limit (a fork-bomb backstop set here rather than via a fork-time
|
|
5
|
+
callback in the host) and then execs the guest code.
|
|
6
|
+
|
|
7
|
+
The guest reaches the hatch by dialing the bound Unix socket with an ordinary
|
|
8
|
+
gRPC channel + the generated stub — that machinery lives in the guest's own
|
|
9
|
+
environment, not here. The socket path is exported as ``POSTERN_HATCH``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import resource
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
nproc = int(os.environ.get('POSTERN_NPROC') or 0)
|
|
19
|
+
if nproc:
|
|
20
|
+
resource.setrlimit(resource.RLIMIT_NPROC, (nproc, nproc))
|
|
21
|
+
# Address-space backstop: a partial guard against a memory bomb starving the
|
|
22
|
+
# co-located trusted worker (F3). It is per-process, not a true total-memory
|
|
23
|
+
# bound — a cgroup memory.max set by the worker/deploy is the real fix.
|
|
24
|
+
as_bytes = int(os.environ.get('POSTERN_AS') or 0)
|
|
25
|
+
if as_bytes:
|
|
26
|
+
resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes))
|
|
27
|
+
code = os.environ.get('POSTERN_CODE', '')
|
|
28
|
+
exec(code, {'__name__': '__main__'}) # noqa: S102 — executing guest code is the whole point
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == '__main__':
|
|
32
|
+
sys.exit(main())
|