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 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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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())