Run untrusted Python in an OS-isolated sandbox whose only exit is a set of host-defined, typed calls.
Project description
postern
Run untrusted Python in an OS-isolated sandbox whose only exit is a set of host-defined, typed gRPC methods.
A postern is the small guarded gate through an otherwise sealed wall. That is the model: guest code runs with no network, no filesystem beyond a workspace, no capabilities — and reaches the outside world only by calling the specific gRPC methods the host allowlists. The security boundary is that method set, not a coarse permission flag.
from postern import Sandbox, SandboxProfile
from postern.grpc import GrpcHatch
import greeter_pb2_grpc
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
profile = SandboxProfile.with_venv('/opt/analysis-env') # pandas, grpcio, stubs
result = Sandbox(profile, hatch=hatch).run_python(guest_code)
# guest dials unix:$POSTERN_HATCH with the generated stub; a non-allowlisted
# method → PERMISSION_DENIED; there is no network.
Why
Coarse sandbox permissions (--allow-net, --allow-read) are the wrong grain
for untrusted agent/tool code: you rarely want "the network", you want "this one
method that fetches this one resource". postern inverts the default — the guest
gets nothing except the host methods you allowlist, each a typed proto shape.
Whatever a method can reach (a database, a credentialed API, a compute backend)
the guest reaches only through that shape, never directly.
This is the design enclave
prototyped over WebAssembly (WASI-compiled CPython). postern delivers the same
"fine-grained function injection is the boundary" promise over a different
substrate — OS isolation (bubblewrap) + a gRPC-over-UDS hatch — which means
real CPython with arbitrary third-party packages (no custom toolchain), and
typed, language-neutral arguments/results (proto, buf breaking-gateable).
Isolation
Sandbox launches the guest under bubblewrap:
- empty network namespace — no egress of any kind (a socket can be created
but has no route). The user and cgroup namespaces are unshared strictly
(
--unshare-user/--unshare-cgroup, not--unshare-all's best-effort-tryvariants), so a host that can't provide a user namespace is a hard launch failure rather than a silent fall-through to a real-root guest; - surgical filesystem — read-only base system dirs + one writable
/workspace; no/etc,/home,/root, or host application code; --cap-drop ALL,--new-session(anti terminal-injection),--die-with-parent,--clearenv;- non-root guest — the guest runs as uid/gid
65534(nobody), so it holds no capabilities inside its user namespace, and if the userns fails to materialise on a root host it still drops to a non-root real uid (SandboxProfile(guest_uid=None)restores the legacy uid-0-in-userns); - a seccomp denylist blocking escape-enabling syscalls (
unshare,setns,mount,ptrace,bpf,keyctl, …).socketis deliberately not blocked — network isolation is the netns's job, and the guest needssocket(AF_UNIX)for the hatch; RLIMIT_NPROCas a fork-bomb backstop (set inside the guest), and an optionalRLIMIT_ASmemory backstop (SandboxProfile(rlimit_as=...), off by default; a cgroupmemory.maxat the deploy layer is the real isolation).
The hatch UDS is bind-mounted in as the single controlled opening. Because the RPC rides that socket, the guest's own stdin/stdout/stderr stay free.
Fail-closed boot check. Every control is enforced on the launch path: the
strict --unshare-* flags make bwrap abort if it can't create the namespaces,
apply --uid, or drop capabilities, and the seccomp loader refuses an uncovered
architecture — so a successful launch is the proof (no runtime probe, like
Chrome's sandbox). Sandbox(profile).verify() just triggers one trivial launch
at startup so a broken platform (no user namespace, gVisor, uncovered arch)
raises IsolationError there rather than on the first request. Call it at worker
startup and refuse to serve if it raises (examples/worker.py does this).
The environment (getting pandas etc. in)
The sandbox has no egress, so packages are provisioned ahead of time and
mounted read-only — never pip installed at run time.
SandboxProfile.with_venv('/opt/env')binds a venv read-only (at its own path, so itssite.pyresolution works) and runs its interpreter. The venv holds the guest's libraries and its hatch client (grpcio + the generated stubs).SandboxProfile(rootfs='/opt/guest-root')binds a curated base directory as the guest's system dirs instead of the host's — hiding the host userland entirely. Build it at image-build time (build-time Docker is fine; only runtime container engines are excluded):docker exporta container into a dir, or ship a single squashfs/erofs image file mounted read-only via FUSE (squashfuse, unprivileged, Cloud-Run-compatible) and pointrootfsat the mountpoint.bwrap --ro-overlaycan stack OCI layer dirs without flattening.
Requirements
Linux with bubblewrap and unprivileged user namespaces (a Cloud Run gen2 Job,
or any such host). postern.available() reports whether a sandbox can launch.
The seccomp filter is a prebuilt multi-arch BPF blob (x86_64, x86, x32, aarch64,
arm); on any other architecture it would be a default-allow no-op, so Sandbox
refuses to launch there (fail-closed) rather than run with an unenforced
filter — set SandboxProfile(seccomp=False) to override deliberately. Not
runnable on macOS except against a Linux target — import postern works
anywhere, Sandbox.run* needs the OS.
Ubuntu 23.10+ / 24.04 restrict unprivileged user namespaces by default
(kernel.apparmor_restrict_unprivileged_userns=1), which bubblewrap needs —
the symptom is bwrap: setting up uid map: Permission denied or loopback: Failed RTM_NEWADDR. Lift it with sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0, or install an AppArmor profile
that grants bwrap userns. Cloud Run gen2 does not have this restriction.
The bare Sandbox has no third-party dependencies and no cloud dependency —
it is a Linux primitive. The gRPC hatch pulls grpcio via the grpc extra.
Install
pip install postern # the bare sandbox (no deps)
pip install 'postern[grpc]' # + the gRPC hatch
Public API
Sandbox(profile=None, *, hatch=None)—.run(argv),.run_python(code)→ProcResult(returncode, stdout, stderr, ok);.verify()(fail-closed boot check, raisesIsolationError).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)andSandboxProfile.with_venv(venv, **kw).stubs=injects a dir or list of files at/run/postern/stubs(onPYTHONPATH) — a shared rootfs carries the heavy base, per-agent stubs bind in selectively.postern.grpc.GrpcHatch(allowlist, *, socket_path=None)—.add_servicer(register_fn, servicer);with hatch.accepting(): .... (grpcextra.)available()— bubblewrap present?
See examples/e2e_greeter.py for an end-to-end run (typed hatch call + pandas,
verified on a Linux host).
Deploy: bundle the rootfs into the Job image
For a Cloud Run Job you build an image anyway, so bundle the guest rootfs into
it and let postern bind it — no runtime container engine. examples/Dockerfile
is the recipe: a multi-stage build that (1) generates the stubs, (2) builds a
minimal guest rootfs (python:slim + grpcio + your data libs + the client
stubs), and (3) assembles the worker (bubblewrap + postern[grpc] + your
servicer) with the guest rootfs copied to /opt/guest-root. The worker
(examples/worker.py) binds it with SandboxProfile(rootfs='/opt/guest-root'),
so the guest sees only that curated image, never the worker's userland. Cloud
Run gen2 provides the unprivileged user namespaces bubblewrap needs.
Roadmap
- Checkpoint/restore — a
Storeprotocol + durable-glob workspace snapshots for run-lived state continuity. overlay=profile mode — emitbwrap --ro-overlayto stack layers with a tmpfs upper, instead of a single--ro-bindrootfs.- Agent-runtime adapters — drive the sandbox from Anthropic Managed Agents, Google ADK, or MCP (the same sandbox, provider-agnostic).
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file postern-0.1.0.tar.gz.
File metadata
- Download URL: postern-0.1.0.tar.gz
- Upload date:
- Size: 29.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f513fe1b1d94a1bdb68ba87635fb8ebfde9120f8f27b53109e54786d1b27ec5c
|
|
| MD5 |
a4e3dab526c0a3a17f25c31dc8dc53b6
|
|
| BLAKE2b-256 |
e270af767abf992c765cc5272240791b0f7bea5cd250a07bae87aee2131c1596
|
Provenance
The following attestation bundles were made for postern-0.1.0.tar.gz:
Publisher:
release.yml on populationgenomics/postern
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
postern-0.1.0.tar.gz -
Subject digest:
f513fe1b1d94a1bdb68ba87635fb8ebfde9120f8f27b53109e54786d1b27ec5c - Sigstore transparency entry: 2171853589
- Sigstore integration time:
-
Permalink:
populationgenomics/postern@2a6c6f52481cdb35dcbf08e6d1f57aaf9ed85659 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/populationgenomics
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2a6c6f52481cdb35dcbf08e6d1f57aaf9ed85659 -
Trigger Event:
release
-
Statement type:
File details
Details for the file postern-0.1.0-py3-none-any.whl.
File metadata
- Download URL: postern-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c544e10f620445dba1dc3f8ae8e4d2c74877630f6d4495273aca98d690e21ae2
|
|
| MD5 |
62d5827699a83c7b7802159dd817aa81
|
|
| BLAKE2b-256 |
26d3ba547b19ca7c68dd540876a65b2494ffc7909eca5e0006932d3cdcd99a90
|
Provenance
The following attestation bundles were made for postern-0.1.0-py3-none-any.whl:
Publisher:
release.yml on populationgenomics/postern
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
postern-0.1.0-py3-none-any.whl -
Subject digest:
c544e10f620445dba1dc3f8ae8e4d2c74877630f6d4495273aca98d690e21ae2 - Sigstore transparency entry: 2171853661
- Sigstore integration time:
-
Permalink:
populationgenomics/postern@2a6c6f52481cdb35dcbf08e6d1f57aaf9ed85659 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/populationgenomics
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2a6c6f52481cdb35dcbf08e6d1f57aaf9ed85659 -
Trigger Event:
release
-
Statement type: