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.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).
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. bwrap's fresh--procre-exposes the procfs sysctl surface writable and discards the runtime's mask, so/proc/sys(and/proc/sysrq-trigger,/proc/irq,/proc/kcore, …) are re-masked read-only — without it a guest whose mapped kernel uid is root can writecore_pattern/modprobeand gain init-namespace root (a full host escape); --cap-drop ALL,--new-session(anti terminal-injection),--die-with-parent,--clearenv;--as-pid-1— the guest entrypoint is PID 1 of the guest's PID namespace, so no resident bwrap process sits there for the guest to read. That bwrap shared the guest uid, so its/proc/1(cmdline, maps, read/write mem, and env) was reachable from inside; with the entrypoint as PID 1 there is no such process.run_python's shim then acts as a minimal init — it forks the guest, reaps orphaned descendants, propagates the guest's exit status, and marks itselfPR_SET_DUMPABLE=0so a co-uid process the guest spawns cannot read the init either;- 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). bwrap maps--uidto its own real uid, so a root bwrap still gives the guest kernel uid 0;SandboxProfile(host_uid=…)opts into running bwrap itself non-root (defense in depth beyond the/proc/sysmask) — off by default because the deploy must then make every bind source reachable by that uid; - a seccomp denylist blocking escape-enabling syscalls (
unshare,setns,mount,ptrace,bpf,keyctl,io_uring_setup, …).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 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.
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.
pip install postern # the bare sandbox (no deps)
pip install 'postern[grpc]' # + the gRPC hatchSandbox(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, host_uid=None, host_gid=None)andSandboxProfile.with_venv(venv, **kw).host_uid=opts bwrap into running at a non-root real uid (defense in depth for the sysctl surface; the deploy must make bind sources reachable by it).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.)Sandbox.accessor()/postern.Workspace(dir)— a reference-closed handle to a workspace;WorkspacePathis itspathlib-like facade..pack_tar(f, *, exclude=…)and.restore_tar(f, *, max_entries=…, max_bytes=…)→WorkspaceReport;ws / 'a/b',.iterdir(),.walk(),.open(),.read_bytes().reference_closed_filterplugs intotarfile.extractall(filter=...)(member-vetting only — see below).available()— bubblewrap present?
The workspace is the one writable surface the guest and host share, and it
outlives the sandbox. Nothing stops the guest planting a reference that points
outside it — ln -s /proc/self/environ doc, ln -s / root, a FIFO. Inside the
jail these are inert; the danger is when the host later reads, tars, or
restores the tree in its own namespace and privileges and becomes a confused
deputy (exfiltrating its own secrets, or writing through the link to a host path).
postern guarantees the workspace is reference-closed: read/pack/restore it
through the host-side accessor and no guest-planted symlink, .., or special
file is ever followed out of the tree — by construction, not by consumer
vigilance.
with sandbox.accessor() as ws: # or Workspace(some_dir)
# only regular files + dirs; symlinks/FIFOs and escaping hardlinks are
# neutralized, and exclude drops paths a checkpoint persists elsewhere
report = ws.pack_tar(open('snap.tar', 'wb'), exclude=lambda p: p == 'document.md')
# report.skipped is the audit trail of what was neutralized (never silent)
ws.restore_tar(open('snap.tar', 'rb'), max_entries=20_000, max_bytes=512 << 20)
data = (ws / 'out' / 'result.json').read_bytes()Every path resolves one component at a time with O_NOFOLLOW relative to a
directory fd (the model is Go's os.Root / Rust's cap-std::Dir); the accessor
never hands back a dereferenceable host path. It is pure stdlib and needs no
mount privilege, so it runs on an unprivileged host (e.g. a Cloud Run container).
A sticky world-writable workspace (0o1777) additionally stops the guest
unlinking host-written files to swap in escaping symlinks. restore_tar's
max_entries/max_bytes bound a decompression bomb from an untrusted store.
reference_closed_filter plugs into stock TarFile.extractall(filter=...) for
consumers that keep tarfile, but it only vets members — stock extraction
still follows a symlink that already exists in the destination, so it is safe
only into a fresh host-controlled directory. To extract into a workspace a guest
may have touched, use restore_tar: it writes through the confined root (never
through an in-tree symlink, pre-existing or planted) and reports what it
neutralized.
See examples/e2e_greeter.py for an end-to-end run (typed hatch call + pandas,
verified on a Linux host).
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.
- Checkpoint/restore — a
Storeprotocol + durable-glob workspace snapshots for run-lived state continuity, sitting on the reference-closedWorkspaceaccessor (pack_tar/restore_tar) so snapshots are safe by construction. 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).
MIT.