Architecture
Four independent layers wrap every run. Each one is enough to stop a different class of misbehavior; together, no single bug in any one of them is an escape.
Namespaces
Every run gets its own bwrap invocation with
--unshare-all: fresh PID, network, mount, IPC, UTS and user
namespaces, plus a private tmpfs root. The guest sees no host files
(aside from a read-only /usr it needs to run at all), no
host processes, and no network interface — not even loopback.
One detail matters more than it looks: the guest is PID 1 in its own namespace. When it exits, the kernel reaps everything it spawned along with it. A classic fork bomb dies in about 15 ms without the process-count ceiling ever being touched — the namespace itself is what kills it.
Seccomp
Namespaces stop a guest from reaching the host's files, network and processes. They do nothing about a guest reaching the kernel through a syscall the kernel itself mishandles. Seccomp is the layer that shrinks that surface.
The filter is a strict allowlist — default ERRNO(EPERM),
146 syscalls explicitly permitted — not a blocklist of known-bad
calls. It's compiled from seccomp/policy.c via libseccomp on
first use and cached as raw BPF, then handed to bubblewrap through
--seccomp FD.
The clone problem
clone() has to stay allowed for ordinary fork and thread
use, but an unprivileged guest can call it with
CLONE_NEWUSER and friends to create a fresh, nested
namespace it would be "privileged" inside — a real
privilege-escalation path from inside an already-sandboxed process.
Denying clone outright breaks every interpreter's threading
and forking. The fix is a single masked-equality rule:
allow clone WHEN (flags & DANGEROUS_MASK) == 0
where DANGEROUS_MASK is the OR of
CLONE_NEWUSER/NEWNS/NEWPID/NEWNET/NEWUTS/NEWIPC/NEWCGROUP.
Ordinary fork/thread calls carry none of those bits and pass; anything
requesting a new namespace falls through to the filter's default deny.
Verified against the raw syscall directly, not just the libc wrapper: a
clone() call with CLONE_NEWUSER fails with
EPERM even when it bypasses unshare() entirely.
clone3 is denied too, but specifically with
ENOSYS rather than EPERM — glibc treats
ENOSYS from clone3 as "kernel doesn't support
this" and transparently falls back to clone(). Returning
EPERM instead skips that fallback and aborts the program
outright, which very nearly shipped as a bug here.
Two findings that weren't obvious from reading the policy
Both surfaced only once real language runtimes ran inside the sandbox, not from auditing the syscall list itself:
umaskwas missing entirely — an oversight, not a deliberate exclusion. With it denied,gcc/ldproduced executables at mode644instead of755, and execution then failed with a plain permission error three layers away from the actual cause.getsockopt/getsocknamewere denied as part of blocking the whole socket family. Node's libuv probes any stdio fd with both to tell a pipe from a socket from a TTY, regardless of what it actually is; with both blocked it silently misdetected the stream and dropped every write, producing a clean exit code with no output at all. They're allowed now — they only read metadata on an fd the guest already holds.socket()itself, which would create a new one, stays fully denied.
See Security for the full list of what's denied and why.
Compat architectures
The filter covers the native x86_64 syscall table plus the 32-bit and x32 compat ABIs. Filtering only the native table is a well-known bypass class: a 64-bit binary can still reach the kernel through the 32-bit entry point, with a completely different syscall numbering, unless the filter explicitly covers it too.
cgroup v2
Every run gets its own cgroup with memory.max,
cpu.max and pids.max set before the guest
process ever starts. Teardown is a single write to
cgroup.kill, which takes down the whole tree at once —
no PID chasing, no process surviving the reaper.
Finding a place to put the cgroup
Delegating cgroup control to an unprivileged process is exactly what breaks first when this runs somewhere other than a desktop login session, and it broke in two different, real ways getting sandbin's own CI green:
- AppArmor. Ubuntu 24.04 restricts unprivileged user
namespace creation by default.
bwrap --unshare-allfailed silently under it until CI explicitly disabled the restriction. - Cgroup topology. A GitHub Actions job's own cgroup
lives under
system.slice, not theuser.slicesession hierarchy a desktop assumes — and worse, that cgroup already holds resident processes. cgroup v2 refuses to enablecgroup.subtree_control(delegating resource control to children) on a cgroup that still holds processes directly: the "no internal process" constraint.
The fix generalizes rather than hardcodes a path. At startup, sandbin
reads its own /proc/self/cgroup and walks upward
until it finds an ancestor that both exposes
cpu/memory/pids and is either
already delegated or holds no processes of its own:
function canHostDelegation(dir):
return hasAllControllers(dir)
and (alreadyEnabled(dir) or isProcessFree(dir))
function findDelegatedRoot():
dir = ownCgroupPath()
while dir is not filesystem root:
if canHostDelegation(dir): return dir
dir = parent(dir)
return ownCgroupPath() // fails loudly downstream instead
It never moves another process into a different cgroup to force a level
to qualify — a session's cgroup might hold entirely unrelated
processes, and relocating them as a side effect of starting a sandbox
run is not something this code should ever do. If no ancestor qualifies,
cgroup assignment fails loudly with the setup_failed verdict
instead of silently running the guest unconfined.
rlimits
Applied via the shell wrapper before bwrap ever execs:
ulimit -f caps file size, ulimit -n caps open
descriptors, ulimit -c 0 disables core dumps. These catch
what cgroups don't — a single huge file write, or a crash dump
that would otherwise land on disk.
The supervisor's own guarantee
run() always settles within wallClockMs + 2s,
regardless of what the guest or its descendants do. The wall-clock
deadline itself triggers cgroup.kill; the extra two seconds
are a hard backstop that force-closes the process and its pipes if
something downstream is still holding them open. A misbehaving
submission can slow the supervisor down, never wedge it indefinitely.