sandbin

Languages

Two interpreters, one runtime with its binary in an unusual place, and two compiled languages — enough to prove the design isn't interpreter-only.

LanguageRuns asNotable
pythonpython3 -I -B -uisolated mode, no bytecode cache, unbuffered
bashbash --noprofile --norcno startup files read
nodethe exact node binary running sandbin itselfresolved at startup, not assumed to live in /usr
cgcc, then the resulting binarytwo separate sandboxed invocations, not one
gogo build, then the resulting binaryonly offered when a toolchain is actually found; shares a warmed, persistent build cache across runs

Python

Runs with -u for unbuffered stdout/stderr. Without it, CPython fully buffers output whenever it isn't attached to a TTY — which it never is here — so several print() calls would arrive as one lump at process exit instead of streaming live. This was caught by asserting on the actual wall-clock gaps between chunk arrivals in the WebSocket test, not by reading the code: three prints separated by real sleeps showed up as a single chunk until the flag was added.

Bash

Plain bash, no profile or rc files. Bash's own builtins write directly via write() without extra userspace buffering, so it streams correctly without needing an equivalent flag.

Node.js

A node install rarely lives in a fixed, predictable place — mise, nvm, a CI tool-cache directory and /usr/bin all put it somewhere different. Rather than assume one, sandbin resolves it once at startup from the interpreter already running its own server code:

const NODE_BIN = realpathSync(process.execPath);
const NODE_ROOT = path.dirname(path.dirname(NODE_BIN));

NODE_ROOT is bind-mounted read-only into the sandbox alongside /usr, and the guest's argv[0] points straight at NODE_BIN. This is portable by construction: wherever sandbin itself is running from is exactly where its sandboxed Node guests run from too.

C

The only compiled language, and the only one that needs two sandboxed phases instead of one:

  1. Compile. gcc -O2 -o /box/a.out /box/main.c runs under its own, more permissive resource profile — 256 MB and 10 seconds, since compiling legitimately needs more of both than running a typical script does.
  2. Execute. Only if compilation exits zero, a second, completely separate sandboxed invocation runs /box/a.out under the caller's normal limits.

A compile failure short-circuits before the execute phase ever starts, returning verdict compile_error with the compiler's own diagnostic as stderr.

The compile phase is also the one exception to a rule that holds everywhere else in sandbin: its /box is mounted read-write (--bind, not --ro-bind), because gcc has to write a.out somewhere. Every execute-phase mount, for every language including C's own second phase, stays read-only.

What actually broke

Compiling worked on the first real test; running the result didn't — a.out came out of the sandbox at file mode 644 instead of 755. The cause was three layers away from the symptom: umask itself was missing from the seccomp allowlist. See Architecture for the full story.

Go

The second compiled language, and only registered at all when go env GOROOT actually resolves to something — the same "don't assume, resolve" approach Node's binary path already uses, extended to an entire language. Clone the repo without Go installed and go simply isn't in the language list; nothing breaks, nothing silently fails at request time.

go build runs with GOMAXPROCS=2 and GOFLAGS=-p=2 to keep its own parallel compilation within the compile sandbox's process-count ceiling, and CGO_ENABLED=0 so networking code compiles against Go's own pure-Go resolver instead of shelling out to gcc for cgo.

Why Go needs a real build cache and C doesn't

gcc links against a precompiled system libc; it never recompiles it. Go's own toolchain, on a truly empty GOCACHE, compiles the entire standard library from source the first time anything imports it — and every sandboxed run starts with an empty, private tmpfs, so without intervention that would happen on every single run. It surfaced immediately as two different failures: the compiler's own thread pool hit the compile sandbox's pids.max, and past that, an intermediate archive for the runtime package alone exceeded the file-size ulimit. The fix is a real, persistent, writable GOCACHE under the OS temp directory (shared across every run) warmed once with a trivial program outside the sandbox entirely, before any guest code ever runs. Every real compile after that only has its own small package left to build — the same "hello world" went from failing on a cold cache to a 14 ms ok once it was warm, same limits, nothing else changed.

The cache directory itself was briefly a CI-only bug

It originally lived under process.cwd(), which worked everywhere it was tested by hand — until real CI runs got checked (not just assumed green from local npm test output), eight pushes late. bwrap, under --unshare-all, refused to bind that directory with a plain Can't find source path: Permission denied — even running as real root — because it sat under an ancestor directory (a CI runner's home directory) that wasn't world-traversable and was owned by a different user than the one running the sandboxed process. Moving it under the OS temp directory, the same place the per-run sandbox directory itself already lived, fixed it outright. See ROADMAP.md for the full investigation.