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.
| Language | Runs as | Notable |
|---|---|---|
python | python3 -I -B -u | isolated mode, no bytecode cache, unbuffered |
bash | bash --noprofile --norc | no startup files read |
node | the exact node binary running sandbin itself | resolved at startup, not assumed to live in /usr |
c | gcc, then the resulting binary | two separate sandboxed invocations, not one |
go | go build, then the resulting binary | only 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:
- Compile.
gcc -O2 -o /box/a.out /box/main.cruns 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. - Execute. Only if compilation exits zero, a second,
completely separate sandboxed invocation runs
/box/a.outunder 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.
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.
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.
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.