What contains a hostile pull request

This agent reviews code written by strangers on a machine holding SSH keys, a GitHub token and other people's projects. That makes "can a pull request run code here" the question the whole design turns on.

There are two answers, and the first is the important one.

1. Usually, nothing runs at all

maintainer screen <pr> decides whether a pull request may be executed before anything is built. It returns DO NOT EXECUTE for any diff touching .rs, .sh, .py, .js, .ts, a dependency, a build script or a workflow, which is most of them, and it fails closed on anything it cannot classify.

cargo test compiles and runs a contributor's code, and build.rs runs it at compile time, so cargo build is already enough. An unattended run has no fallback: it reviews by reading and says so in the review.

Not executing is the strongest containment available and it is the default.

2. When something must run, it runs like this

Only maintainer-merge verify executes contributor code, and only inside a container. Every flag below is measured by tests/containment-probe.sh with the attack it is supposed to stop, because a flag on a command line is not evidence.

ControlWhat it stopsMeasured
--network=nonephoning home, fetching a stage twooutbound TCP and DNS both fail
--cap-drop=ALLeverything from CAP_NET_RAW to CAP_SYS_ADMINCapEff is empty
--security-opt=no-new-privilegesa setuid binary elevating insideNoNewPrivs: 1
--read-onlytampering with the imagewriting /etc/passwd fails
--tmpfs /tmp:noexec,nosuiddropping and running a payloadthe dropped binary will not execute
--pids-limita fork bombthe bomb hits the wall
--memory, --cpusstarving the hosta 4G balloon fails in a 256m tmpfs
--timeoutan infinite loop holding a timer for everthe container dies and none is left behind
no host environmentreading GH_TOKEN or an API keya planted canary never appears inside
one bind mountreaching the rest of the diskwriting outside the mount fails

Run it yourself:

./tests/containment-probe.sh

The one that had to be learned the hard way

timeout 60 podman run … does not stop the container. It kills the podman client, and conmon keeps the payload running. Measured while writing this: two containers still spinning three minutes after the client was killed, with the host load average at 5.5.

podman run --timeout=N is the control that works, because conmon enforces it. The probe asserts the stronger property: after an infinite loop, no container is left behind.

What this is not

It is not a boundary against a kernel exploit. Containers share the host kernel. Namespaces and seccomp raise the cost of an escape; they do not make one impossible, and the 2026 consensus is that shared-kernel isolation is no longer adequate for genuinely untrusted code.

Saying that plainly matters more than the table above. Anyone deploying this should know that the guarantee is "a hostile pull request cannot casually reach your keys or your network", not "a hostile pull request cannot escape".

What would close it: a user-space kernel (gVisor's runsc) or a micro-VM (Firecracker, Kata). Both are a runtime swap rather than a redesign, since every control above is expressed as flags to one run call. runsc is the cheaper step and is tracked as an issue.

Mount isolation on the host is unavailable here. On systemd 255 with kernel.apparmor_restrict_unprivileged_userns=1, ProtectHome, BindPaths and InaccessiblePaths are silently ignored for user units, and bwrap fails with setting up uid map: Permission denied. Those directives were removed rather than left in looking protective: a directive that no-ops reads as a guarantee.

Rootless podman under a hardened unit

newuidmap is setuid, so NoNewPrivileges and RestrictSUIDSGID stop podman establishing a user namespace, though it can reuse one. Measured: cold plus full hardening failed 10 times out of 10; warm plus full hardening succeeded. podman-userns-warmup.service, unhardened and running one fixed command with no model output, establishes the namespace first, and the maintainer unit is ordered after it and stays hardened. Without that ordering, container verification would silently vanish after a reboot, and a merge gate that quietly stops verifying still merges.

Prior art

The design follows the 2026 consensus for sandboxing agent-run code: rootless podman, drop everything, no network, read-only root, resource caps, and an honest statement that shared-kernel isolation stops short of a kernel exploit. See awesome-sandbox and Podman as a sandbox for untrusted code.