Sprout series · Part 1 of 5

Technical guide · 13 October 2026 · 8 min read

My C interpreter's sandbox had a process limit that did nothing

How to fence in untrusted or AI-generated code around an interpreter, and how to check that each limit holds.

ulimit -u 64 set no limit at all. The shell rejected the flag, and my runner threw the error away.

That line in playground/run.sh was meant to cap every submitted Sprout program at 64 processes. The images run the script under dash, which rejects ulimit -u, and 2>/dev/null || true hid the error, so only Docker’s pids limit applied. It wasn’t the only fence around strangers’ code with a hole.

The public playground at fizzexual.github.io/Sprout is a WebAssembly build that runs on the visitor’s machine, so strangers’ code never runs on a server of mine. The other is a Docker playground anyone can self-host, where a stranger’s program runs on your server. The dead line lived there. All three gaps I describe here are now fixed and merged into main (bc7dd44), each reproduced in Docker first and checked again after the fix.

What the flag does and doesn’t stop

Reading /etc/passwd and looping forever are different problems, and the --sandbox flag only handles the first. Sprout’s SECURITY.md says a host must set OS or container limits for loops and memory.

Submission Problem Stopped by
show read("/etc/passwd") reach the interpreter’s --sandbox gate
repeat while yes: with no exit CPU ulimit -t plus a wall clock
a show loop printing forever output a streamed cap in the runner
allocating a lot memory ulimit -v and the container’s ceiling

Two ceilings live inside the interpreter. Nested task calls stop at #define MAX_DEPTH 6000, and a regex gives up after RE_BUDGET 3000000L steps rather than hanging.

One C function decides what a program can reach

Builtins in Sprout aren’t values you can pass around, so every builtin call goes through one C function, call_builtin. The sandbox check sits at its top, before any argument is evaluated.

static int builtin_blocked(const char *name) {
  static const char *const b[] = { "read", "write", "append", "exists",        /* filesystem */
                                   "remember", "recall", "forget",             /* on-disk store */
                                   "get", "explore",                           /* network */
                                   "env" };                                    /* host environment (may hold secrets) */
...
static Value call_builtin(Expr *call, Env *env) {
  const char *name = call->name;
  int n = call->nargs;
  if (g_sandbox && builtin_blocked(name))
    failf(call->line, "'%s' is turned off in sandbox mode — file, shell, and network access are disabled here.", name);

The list is compiled in, with no file to misconfigure and no runtime API to extend it. A builtin filter only sees builtin calls, though, and two doors in Sprout aren’t builtin calls: the system module and the use statement, which loads and runs another file from disk. Each has its own gate.

  if (g_sandbox) fail(e->line, "the 'system' module is turned off in sandbox mode — no shell access here.");
...
        if (g_sandbox) fail(s->line, "'use' is turned off in sandbox mode — a program can't load other files.");

The switch only turns one way: main() sets g_sandbox = 1 in exactly two places, from the SPROUT_SANDBOX variable and the --sandbox flag, and nothing sets it back. The variable is checked for existence, so even SPROUT_SANDBOX=0 turns the sandbox on and a typo fails closed. Both Docker images bake ENV SPROUT_SANDBOX=1 in, so the gate stays shut even if someone bypasses the entrypoint.

The probe that counted any error as a block

A deny-list is a claim, so the repo has a probe that tries eleven operations, each inside try, and counts the failures. CI runs it under SPROUT_SANDBOX=1 on Linux, macOS and Windows and fails unless it prints ok: all N dangerous ops blocked.

make total = 11

try:
    read("x")
caught e:
    set blocked += 1
...
when blocked == total:
    show "ok: all " + total + " dangerous ops blocked"

Every arm is caught e: set blocked += 1, so it counts any error as a block. use somefile also fails when there’s no such module, and write("x", "y") also fails in a read-only directory, so a change that reopened either door could still print “ok: all 11”. The probe never calls env either. env() arrived several releases after the sandbox, and it’s on the deny-list, but if it fell off, CI would stay green.

A separate CI job is stricter. It pipes read("/etc/passwd"), write("/x","y") and get("http://169.254.169.254/") into the Docker image and greps each output for “sandbox mode”. That covers three doors. The fix (a147486) makes each arm count only the sandbox’s own refusal and adds env, twelve operations in all. To prove it can fail, I deleted the use and env gates from sprout.c: the old probe still printed “ok: all 11”, the new one prints FAIL: only 11 of 12.

Clocks, caps and one dead line in run.sh

Every Docker submission goes through playground/run.sh. These are the lines that do the limiting.

ulimit -t "${SPROUT_CPU_SECONDS:-5}"   2>/dev/null || true   # CPU seconds  (RLIMIT_CPU)
ulimit -v "${SPROUT_VMEM_KB:-262144}"  2>/dev/null || true   # address space (~256 MB)
ulimit -u "${SPROUT_PROCS:-64}"        2>/dev/null || true   # processes/threads (dash may ignore -u)
...
{ timeout -s KILL "$wall" sprout --sandbox run "$prog" 2>&1; echo $? > "$rcf"; } \
  | head -c "$cap" > "$out"

ulimit -t catches a busy loop, and timeout -s KILL on wall time catches a program that burns no CPU at all, like one calling wait in a loop. Piping through head -c truncates output as it flows, so the temp file never grows past the cap, where a ulimit -f would kill the writer with SIGXFSZ mid-output.

The third ulimit is the one not to copy. Both images are Debian-based and run the script with /bin/sh, which there is dash, and dash spells the process limit -p. ulimit -u 64 fails with “Illegal option -u”, and the redirect and || true erase the evidence. The comment on that very line says dash may ignore -u, and I still listed ulimit -u as enforced in the playground README.

The obvious one-letter fix is wrong. With ulimit -p 64 in the image, the runner’s own fork failed with Cannot fork, because that limit counts every process the user owns, not one run. So the fix (adc3ee1) deletes the process ulimit, documents Docker’s pids limit as the cap, and makes the CPU and memory limits refuse to run, with exit code 71, when they can’t be applied. Before, a mistyped memory setting (SPROUT_VMEM_KB=abc) left the running process with no memory limit at all; now the runner refuses to start. With the CPU limit set to 2 seconds, a busy loop is killed after 2 seconds.

The container holds the cap the script lost

For hostile traffic the repo recommends one throwaway container per submission.

echo 'show [n*n for each n in 1 to 5] |> sum' | docker run --rm -i \
  --network none \
  --memory 256m --memory-swap 256m \
  --cpus 0.5 \
  --pids-limit 64 \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --user 10001:10001 \
  sprout-playground

--pids-limit 64 is the process cap that applies. --network none blocks egress even if the language gate has a bug, and equal --memory and --memory-swap values mean no swap. A read-only root with a noexec tmpfs makes dropping and running a binary much harder, though not impossible, and --cap-drop ALL with no-new-privileges closes the setuid and capability routes to root.

The one-command web playground, docker compose up, is weaker: one shared container for every submission (mem_limit: 512m, pids_limit: 256), built from whatever main is that day. The wiki said --network none, yet docker-compose.yml set no network mode, and from inside the container connections to 1.1.1.1 and example.com opened. network_mode: none can’t work there, because the server still has to accept HTTP. The fix (522d3fe) puts the container that runs code on an internal-only network and adds a small relay container as the one way in. Now the code that runs has no internet, no metadata endpoint and no outside DNS.

The public playground has no server to attack

The browser build is the only public playground. Its workflow sums it up: “Everything runs client-side; Pages only serves static files.” The compile is one emcc call.

emcc src/sprout.c -O2 -o site/sprout.js \
  -sMODULARIZE=1 -sEXPORT_NAME=createSprout \
  -sINVOKE_RUN=0 -sEXIT_RUNTIME=1 \
  -sEXPORTED_RUNTIME_METHODS=callMain,FS \
  -sALLOW_MEMORY_GROWTH=1 -sSTACK_SIZE=4mb \
  -sSUPPORT_LONGJMP=1 -lm

With MODULARIZE, the page calls createSprout() on every Run, so each run gets a fresh instance, and callMain and FS are the page’s entire surface. The page writes the editor’s text into Emscripten’s in-memory filesystem and calls main the way a terminal would:

    M.FS.writeFile('/program.sprout', code.value);
    try{ M.callMain(['/program.sprout','--sandbox']); }

That gives one entry point and the native build’s own g_sandbox gate. Underneath, run_command and http_get are compiled out with #ifdef __EMSCRIPTEN__ and return NULL, the value native code already returns when a command or download fails. A hole in the gate would find no shell or network code behind it. Output is rendered with textContent, so show "<script>..." stays text.

What this build lacks is a clock. callMain runs on the main thread, so only the browser’s “page unresponsive” prompt can stop repeat while yes:, and a Worker that gets terminate()d on a timer would fix that.

Kernel bugs, memfd and side channels

A container shares the host’s kernel, so a kernel bug can get past every layer above. noexec doesn’t stop code run from an in-memory file made with memfd_create, and nothing here addresses side channels. For code you have reason to think is hostile, add a tight seccomp profile and a VM-grade boundary like gVisor or Firecracker. This is the list I wish I’d had before writing run.sh:

  1. Gate every door at one choke point, before arguments are evaluated, including statements and imports.
  2. Have the probe check the reason for each failure, and generate it from the gate’s own list.
  3. Use two clocks, CPU and wall time, ending in SIGKILL, and stream the output cap.
  4. Read every limit back inside the running container.
  5. For hostile traffic, use a fresh container per run: no network, no swap, read-only root, non-root user, no capabilities.
  6. Skip the server when you can: compile to WebAssembly and run it in a Worker you can kill.

Next I’d build one capability table. Today the deny-list, the probe and the docs are three separate lists, and the env gap shows how they drift. I want each builtin tagged with what it can reach, denied by default, and both the gate and the probe generated from that table.