sandbin

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:

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:

  1. AppArmor. Ubuntu 24.04 restricts unprivileged user namespace creation by default. bwrap --unshare-all failed silently under it until CI explicitly disabled the restriction.
  2. Cgroup topology. A GitHub Actions job's own cgroup lives under system.slice, not the user.slice session hierarchy a desktop assumes — and worse, that cgroup already holds resident processes. cgroup v2 refuses to enable cgroup.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.