postern
Run untrusted Python in an OS-isolated sandbox whose only exit is what the host opens: a set of typed gRPC methods, or one raw stream per resource.
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 through the hatch the host binds in. The security boundary is what that hatch exposes, 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) plus a hatch over a Unix socket — so the guest is real CPython with arbitrary third-party packages, no custom toolchain, and the arguments and results are typed and language-neutral.
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. 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— postern's guest init is PID 1 of the guest's PID namespace. A resident bwrap there would share the guest uid, putting its/proc/1(cmdline, maps, read/write mem, env) within reach from inside. What the init does with that role: see PID 1 and the resource backstops below;- 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.
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.
PID 1 and the resource backstops
Everything above is a bwrap flag, so every entrypoint gets it. The rlimits and the init role are not flags: they come from the guest's init, which postern binds in and makes the entrypoint for every run.
Use the C init. SandboxProfile(init=...) names a small static C program
(_init.c, ~190 lines) that you build once, at image-build time, from the source
postern ships. It needs nothing from the guest's rootfs, and with it the only
things that need a Python interpreter in the sandbox are run_python and the stream
hatch's git_url connector, so a rootfs for run/run_bash need not carry one.
Without init=, postern falls back to the Python shim (_guest.py) as the init,
which does the same job at the cost of an interpreter in every rootfs and an
interpreter start on every run: about 16 ms per run_bash('true') against 2.9 ms
under the C init, which is what a bare launch costs.
Either way, the init is a real PID 1. It marks itself PR_SET_DUMPABLE=0 so a
co-uid process the guest spawns cannot read it, forks the work, reaps orphaned
descendants that reparent to it, forwards termination signals (the C init to the
work's whole process group, the shim to the work alone), and propagates the
work's exit status, as 128+N for death by signal N. The forked child applies
RLIMIT_NPROC (SandboxProfile(rlimit_nproc=1024)) as a fork-bomb backstop
and, when set, RLIMIT_AS (SandboxProfile(rlimit_as=...), off by default; a
cgroup memory.max at the deploy layer is the real memory isolation), then execs
the work. Setting the limits before the exec is what carries them to a program
that would not set them itself.
What the child execs is the only difference between the entrypoints:
Sandbox.runexecs the caller's argv, andSandbox.run_bashexecsshell -c script(bashby default). Neither has to tolerate being PID 1 or manage its own limits. A program the init cannot exec is areturncodeof 127, not an exception.Sandbox.run_pythonexecsprofile.pythonon the shim, which runs the code in that fresh interpreter.RLIMIT_ASis applied after the interpreter is up, because a bare CPython's virtual size at startup can exceed a cap that its resident use never approaches, and capping before theexecwould abort the startup. Under the C init this is one interpreter start; under the fallback shim it is two (the supervising shim, then the re-exec).
Building the C init. It ships as source in the package, not as a binary. Build it in a throwaway image stage, so no compiler reaches the rootfs or the worker:
FROM python:3.12-slim AS init
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
&& pip install 'postern==X.Y.Z' && python -m postern.build_init /postern-init
# ... later, in the worker stage:
COPY --from=init /postern-init /opt/postern-init
profile = SandboxProfile(rootfs='/opt/guest-root', init='/opt/postern-init')
Install the same postern version in both stages: the binary is stamped with the
version it was built from, and verify() refuses a mismatch. The check reads the
init's own --version from a run inside the sandbox: postern never executes the
init on the host, which only ever runs bwrap. init= must be an absolute path. It is static, so it
needs nothing from the rootfs; postern binds it in at /run/postern/init.
examples/Dockerfile does all of this.
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, and there is no runtime
probe. Sandbox(profile).verify() 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, as examples/worker.py does.
Streaming and stopping a run
run, run_bash and run_python block until the run ends. start, start_bash
and start_python return a Process at once, which streams the output as the
guest writes it and can be stopped:
with sandbox.start_bash('make test') as process:
for stream, chunk in process.output(): # stream is 'stdout' or 'stderr'
show(stream, chunk)
if user_pressed_stop():
process.terminate(grace=5) # SIGTERM now, SIGKILL after 5 s
status = process.returncode
The same run for asyncio comes from astart, astart_bash and astart_python:
async with await sandbox.astart_bash('make test') as process:
async for stream, chunk in process.output():
await show(stream, chunk)
status = await process.wait()
- One way to read output.
output()yields(stream, bytes)chunks from both pipes in the order they arrive; a chunk can end mid-line or mid-character. There are deliberately no separate stdout and stderr readers: draining one while the other's pipe fills stalls the guest. For a single stream, write2>&1.communicate(timeout)collects whateveroutput()has not into aProcResult, which is allrun*does. - Stopping is graceful. bwrap does not forward signals, so
terminate()signals the guest's init directly, through a pidfd on the host pid bwrap reports on--info-fd(so a recycled pid is never signalled). The init forwards the SIGTERM to the command (the C init to its whole process group), which can trap it and clean up; if it is still running aftergrace, the init is killed through the same pidfd and the kernel kills the rest of its namespace with it. A launch raisesIsolationErrorwhere pidfds do not work (before Linux 5.3, or under a seccomp profile blockingpidfd_open) or/proccannot vouch for the init (a/procfrom another pid namespace,hidepid), since a stop could not then be sure of reaching it. A SIGTERM sent before the init is ready for it is held pending until it is, not dropped: bwrap is launched with SIGTERM blocked, and the init inherits that. bwrap therefore ignores SIGTERM itself (pkill -TERM bwrapdoes nothing); a SIGTERM to the whole cgroup or process group still reaches the init, which forwards it.terminate()returns at once and is safe from another thread, so keep reading to see what the command says on its way out.kill()skips the grace. - Leaving early stops it too. Leaving the
withblock while the run is still going (on an exception, abreak, or an asyncio task cancellation) terminates it with a 1 s grace, discarding its remaining output. Close theProcess(thewithblock does) to release the run's hatches, which serve until then. - When the loop ends.
output()ends when both pipes close, which is normally when the run ends: the init's exit takes everything else in the namespace with it, so a backgrounded straggler cannot hold the loop open. A command that closes its own output ends the loop early;wait()gives the status either way. - No threads for asyncio.
AsyncProcessreads the non-blocking pipes with loop readers and waits for the exit on a pidfd on bwrap. One reader per process:terminate()andkill()are the calls meant to come from elsewhere.
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. With the C init (SandboxProfile(init=...)) the rootfs needsprofile.pythononly forrun_pythonandgit_url; under the fallback Python shim every entrypoint needs it.
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; the
stream hatch (below) is stdlib-only.
Stream hatch
Some protocols are neither typed RPC nor request/response. StreamHatch gives
the guest one socket and nothing else; per accepted connection a handler
decides what its bytes are spliced to — a host-side subprocess's stdio, or
nothing. The motivating case is git, whose native wire protocol is pkt-line over
a raw bidirectional stream and whose ext:: transport carries that over a
command's stdin/stdout.
from postern import Sandbox, SandboxProfile
from postern.stream import StreamHatch, git_url, splice_subprocess
hatch = StreamHatch(splice_subprocess(['git', 'upload-pack', '/srv/repo.git']), name='repo')
profile = SandboxProfile()
sandbox = Sandbox(profile, hatch=hatch)
sandbox.run(['git', '-c', 'protocol.ext.allow=always', 'clone',
git_url('repo', profile=profile), 'work'])
Pass profile= to git_url: the in-guest interpreter comes from
profile.python, the same place run_python gets it, so the URL cannot disagree
with the sandbox it runs in. Without it the default is a bare python3 off the
guest PATH, which is wrong for with_venv or a curated rootfs. The connector
is Python, so this needs an interpreter in the rootfs even under the C init.
The socket is the capability. The same access could be brokered through an HTTP forward proxy with a handler policing each request; a bound stream socket differs in four ways.
- Capability by descriptor, not policy by parser. Through a proxy the guest
names a URL, so "only this one repository" means validating request targets in
a handler — a parser in the policy path, fed attacker-controlled input. One
socket per resource makes the wrong resource unrepresentable. The service is
fixed too: a hatch bound to
git upload-packcannot be talked intoreceive-pack. - No body buffering, and no copying. A proxy that inspects request bodies has to buffer them, and so has to cap them. Here the socket is the command's stdin and stdout, so the kernel moves every byte and postern is not on the data path. The kernel also propagates the command's disposition: a command that consumed its input and exited gives the guest end-of-stream, one that died mid-request gives it a reset — the only failure signal a stream with no framing of its own has.
- No protocol translation. The host side runs a subprocess and splices its stdio, rather than decoding framing and re-emitting headers to reach the same subprocess.
- It works with
Sandbox.run. A hatch needs nothing in-guest, so a plaingitentrypoint reaches it, not only arun_pythonguest. Every entrypoint binds and serves every configured hatch.
Stream hatches are named, so a sandbox carries as many as it has resources:
each binds at /run/postern/<name>.sock and is exported as
$POSTERN_HATCH_<NAME>, while the unnamed GrpcHatch keeps $POSTERN_HATCH.
hatch= takes one hatch or a sequence. The in-guest connector that bridges a
command's stdio to the socket is bound in at $POSTERN_CONNECT — stdlib-only,
one blocking thread per direction, leaving with os._exit rather than finalising,
because finalising the interpreter around a reader still parked on a descriptor
git has torn down aborts the connector (python3 died of signal 6). git gates ext:: behind protocol.ext.allow because an ext:: URL is
command execution; inside the sandbox that gate protects nothing, since the guest
is already running untrusted code, so enable it per invocation with -c and leave
the host's git config alone.
Guest bytes reach a host-side process only as its stdin — never its argv,
env, cwd, or a dial's destination, all fixed when the hatch is constructed.
splice_subprocess gives the process a fixed minimal PATH and a fixed cwd
rather than the worker's, so ambient host state cannot decide what the capability
is, and discards its stderr, because a command's diagnostics quote host paths.
max_conns gates accepting rather than dispatch: a stream connection is
long-lived, so a queue of accepted-but-unserved connections would be a queue of
host file descriptors. Past the cap, dials wait in the kernel backlog.
Teardown has two known holes. Per connection the hatch waits for the command and then signals its whole process group, because a child the command left behind inherits the guest's socket and would otherwise hold the connection open for ever. Two things bound that:
- Another reaper in your process voids it. If the embedding process reaps
arbitrary children — a supervisor loop calling
waitpid(-1),multiprocessing, an asyncio child watcher,SIGCHLDset toSIG_IGN— thenPopensynthesises an exit status of0onECHILD, which is indistinguishable from a clean exit, and the group signal is skipped. Declining is the answer to that ambiguity: the foreign reap freed the pid, so signalling anyway could land on a recycled one. What leaks is the command's children; the worker thread and the slot come back. If your host process has its own reaper, keep the outerSandbox.run(timeout=...)short. - A grandchild that calls
setsid()escapes it, because it is no longer in the group. A shell's&child stays and is collected; a daemonising sidecar does not. Prefer a command that does not daemonise.
postern.stream's module docstring covers the mechanism under "Teardown and its
caveats", including which platforms can observe a command's exit without reaping
it (waitid on Linux, and on darwin from CPython 3.13; kqueue on macOS and the
BSDs) and what a platform with neither loses.
The command's stdin grammar is part of the capability. A fixed argv means
guest bytes never become that process's argv. It does not stop them becoming a
downstream process's argv or a shell command, and splice_subprocess cannot
check a grammar for you. git upload-pack grants nothing beyond the repository.
sqlite3 — even -readonly — has .shell, so splicing it is host command
execution; so are psql (\!, COPY … FROM PROGRAM), mysql (system),
redis-cli, ftp, gdb and ed.
stderr=DEVNULL covers fd 2 and nothing else: a command that multiplexes
diagnostics onto stdout routes around it (git upload-archive reports
fatal: '<path>' does not appear to be a git repository on its pkt-line
sideband), so pass cwd and a bare basename rather than an absolute host path.
Logging
postern logs through the stdlib and configures nothing. Each module logs to
logging.getLogger('postern.<module>'); the package attaches a NullHandler to
postern and adds no handler, sets no level and installs no format. Wiring the
sink is the application's job — on Cloud Run, structured stdout is ingested, so a
logging.basicConfig (or the app's own JSON formatter) is the whole integration.
There is no google-cloud-logging dependency and no OpenTelemetry: tracing is a
separate concern and a separate dependency decision.
logging.getLogger('postern').setLevel(logging.INFO) # hatch start and stop
logging.getLogger('postern').setLevel(logging.DEBUG) # every guest-driven event
The level split is a security property. A guest reaches the hatch, so a
per-event line on a guest-driven path is an amplifier whose rate the guest sets,
for as long as the run lives: until its timeout for run*, and until the
caller stops it for start*.
WARNING— evidence about the host: a handler that raised, with the exception type and message, so a host bug is not indistinguishable from the guest's clean end-of-stream; a reap or dispose that failed and therefore leaked a subprocess; closing an unclosedWorkspacefailing;accept()failing transiently because the embedding worker is out of descriptors (bounded at1/_ACCEPT_RETRY_DELAY= 20 lines/second, and unreachable by the guest). Also a gRPC method called that is not on the allowlist: in correct operation the guest only calls what the host allowlisted, so it is either a misconfigured allowlist or a guest probing the boundary.INFO— a hatch starting and stopping: two lines per hatch per run, for bothGrpcHatchandStreamHatch.DEBUG— what a well-behaved guest drives at its own rate: a handler refusing by policy (that is the handler working), a connection in flight whenclose()runs, and the traceback behind aWARNINGhandler failure.
Nothing is rate-limited or aggregated: every event gets a line.
A handler failure is the one WARNING a guest can drive. A handler runs on
guest input, so a handler that raises on malformed input logs once per connection
at the default level. That is deliberate — a host bug that only fires on hostile
input is exactly what you want to see — but it means a handler should refuse by
returning None (DEBUG) and raise only when something is genuinely wrong.
Guest-derived values are never interpolated raw, tracebacks included. A gRPC
method name, and a handler exception's type, message and traceback, are passed to
the log call wrapped in postern._log.Guest, which renders them escaped and
length-capped when the record is formatted, so a newline cannot start what reads
like a new host-attributed entry in an aggregated stream. Wrapping is the call
site's job: nothing does it for a value passed bare. Nothing on a guest-reachable
path is handed to exc_info; the traceback is wrapped and logged at DEBUG
instead.
Install
pip install postern # the bare sandbox + the stream hatch (no deps)
pip install 'postern[grpc]' # + the gRPC hatch
Public API
Sandbox(profile=None, *, hatch=None)—.run(argv),.run_bash(script, *, shell='bash'),.run_python(code)→ProcResult(returncode, stdout, stderr, ok);.start(argv),.start_bash(script, *, shell='bash'),.start_python(code)→Process, andawait .astart(...),.astart_bash(...),.astart_python(...)→AsyncProcess(streaming and stopping);.verify()(fail-closed boot check, raisesIsolationError, as does any launch where pidfds do not work). All three bind and serve every configured hatch, get the identical bwrap profile, and run under the guest init with the same rlimits and orphan reaping (above); they differ only in what the init's child execs.hatchtakes one hatch or a sequence, with at most one unnamed hatch since that one owns a fixed guest env var.Process—.output()→ iterator of(stream, bytes);.terminate(*, grace=5.0),.kill(),.wait(timeout=None),.communicate(timeout=None)→ProcResult,.close();.pid,.returncode,.terminated; a context manager.AsyncProcessis the same run for asyncio:async forover.output(),await .wait(),await .communicate(timeout=None),await .aclose(),async with.SandboxProfile(workspace=None, rootfs=None, python='python3', ro_binds=[], required_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, init=None)andSandboxProfile.with_venv(venv, **kw).ro_binds=are--ro-bind-try(a missing source is skipped silently);required_ro_binds=are--ro-bind(a missing source fails the launch), for a bind the guest cannot run without.host_uid=runs bwrap itself at a non-root real uid; the deploy must then make every bind source reachable by it.init=names the static C init built bypython -m postern.build_init, the recommended PID 1 (above);Nonefalls back to the Python shim, andverify()checks the init was built from this postern version.stubs=injects a dir or list of files at/run/postern/stubs, prepended toPYTHONPATH.rlimit_nproc=/rlimit_as=are applied by the guest init, so every entrypoint gets them.postern.grpc.GrpcHatch(allowlist, *, socket_path=None)—.add_servicer(register_fn, servicer);with hatch.accepting(): .... (grpcextra.)postern.stream.StreamHatch(handler, *, name='stream', socket_path=None, max_conns=8, backlog=64, grace=5.0)— a raw bidirectional byte stream over the sandbox UDS, reached as a plain file at$POSTERN_HATCH_<NAME>, sorun()works and not onlyrun_python(). Named, so several coexist: one socket per resource. Stdlib-only.with hatch.accepting(): ..., andclose()is terminal asGrpcHatch's is.handler(stream) -> Process | None: returnProcess(argv, cwd=None, env=None, stderr=DEVNULL)to hand the connection to a subprocess as its stdio, orNoneto refuse. The hatch spawns it, so the descriptor is in ordinary-stdio shape (blocking, no signal-driven I/O, no socket timeouts) before there is a child to race.Process.from_popen(popen)adopts a subprocess you spawned yourself, for what the declarative form does not cover (pass_fds,user=, an rlimit). An already-spawned verdict can only be validated, and it has none of the declarative path'sstderr/env/cwddefaults.stderr=PIPEis refused because nothing drains it and the command would deadlock;stderr=STDOUTis refused because stdout is the guest socket, so it would relay host-path diagnostics to the guest. Checked at construction forProcess(argv), and by detection for an adoptedPopenwhere the platform allows, sincesubprocesskeeps no record of thestderrit was passed.Stream.read_preamble(max_bytes, timeout)bounds a preamble read without touching socket flags. Do not usesettimeoutfor that: the command shares the descriptor.splice_subprocess(argv, *, cwd=None, env=None, stderr=DEVNULL)is the stock handler;git_url(name, *, profile=None, python=None)builds theext::URL for the in-guest connector at$POSTERN_CONNECT.
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()— whether bubblewrap is on the PATH.
Reading the workspace safely
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 or restore it
through the host-side accessor and no guest-planted symlink, .., or special file
is ever followed out of the tree.
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. Pure stdlib, no mount privilege, so
it runs on an unprivileged host such as 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, and reports what it neutralized.
examples/e2e_greeter.py is a runnable end-to-end example: a typed hatch call
plus pandas, 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, with no runtime container engine. examples/Dockerfile
is the recipe: a three-stage build that generates the stubs, builds a minimal
guest rootfs (python:3.12-slim + grpcio + your data libs + the client stubs),
and assembles the worker (bubblewrap + postern[grpc] + your servicer) with the
guest rootfs copied to /opt/guest-root. examples/worker.py binds it with
SandboxProfile(rootfs='/opt/guest-root'), so the guest sees only that curated
image. Cloud Run gen2 provides the unprivileged user namespaces bubblewrap needs.
Cloud Run stops a task with SIGTERM to the container's entrypoint, then SIGKILL
10 s later. As the container's PID 1, a worker with no SIGTERM handler ignores
the SIGTERM, so its guests get no chance to clean up before the SIGKILL.
examples/worker.py installs one: it passes the stop on to the run with
terminate(), so the guest's own cleanup runs, and exits 143.
Roadmap
Not implemented:
- Checkpoint/restore — a
Storeprotocol and durable-glob workspace snapshots, sitting on the reference-closedWorkspaceaccessor (pack_tar/restore_tar). overlay=profile mode — emitbwrap --ro-overlayto stack layers with a tmpfs upper, instead of a single--ro-bindrootfs.- Agent-runtime adapters — drive the same sandbox from Anthropic Managed Agents, Google ADK, or MCP.
License
MIT.
Metadata
Release files for postern 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| postern-0.5.0.tar.gz | 153.2 kB | With provenance | Details for postern-0.5.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| postern-0.5.0-py3-none-any.whl | Python 3 | none | any | Details for postern-0.5.0-py3-none-any.whl |
Total release size: 238.9 kB
Release files / postern-0.5.0.tar.gz
| Download URL | postern-0.5.0.tar.gz |
|---|---|
| Size | 153.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
56061d1f307da7fa4e7125b6e66ad400e2b443528d1a6b01b0284e63939f03ab
|
|
BLAKE2b-256 checksum How to use checksums |
b81a357e29fd88e6a114dda989a4a3bc4f89fd189937569e663d216b55a0a189
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / postern-0.5.0-py3-none-any.whl
| Download URL | postern-0.5.0-py3-none-any.whl |
|---|---|
| Size | 85.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4091e394d4ac8c10c23150550d469e00d5b4badab10ab467c1b37e2e6764e635
|
|
BLAKE2b-256 checksum How to use checksums |
d8ed4ceda5b47c952621551a853406980b4ee9b4e1747ee21ae2669986164e45
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logSecurity for postern 0.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for postern-0.5.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.