Sandbox Isolation Design
How we ran untrusted student code safely — the threat model, the hardening, and where isolation and scale pull against each other.
If you build a tool that lets students run their own Python in the browser, you've signed up to execute code written by strangers on infrastructure you care about. Most of it is a first-year student fumbling toward a working loop. Some of it, eventually, is someone poking at the box just to see what happens. The system can't tell the two apart at submission time — so it has to treat every submission as hostile and still return a result in the time it takes to click "Run."
This is a writeup of the isolation design behind HelloG8r, a platform that generates and auto-checks CS1-level practice problems. It was a five-person senior project; the piece I owned was the execution layer — the sandbox that runs the untrusted code, and the API that stands between it and everything else. This post is about that layer: the threat model, the specific hardening choices and why each one is there, and the point where isolation strength starts fighting with throughput.
The threat model: assume the code is hostile
The trap is thinking "it's just homework." Once you accept arbitrary submissions, you're defending against two different failure modes at once. The accidental one is the common case: infinite loops, runaway memory allocation, a fork bomb someone wrote by mistake, code that tries to write until the disk fills. The deliberate one is rarer but far more expensive: code that reads other students' submissions, reaches out over the network, tries to break out of the container, or turns your host into a foothold for something else.
A grading sandbox has to survive both — and it has to assume the second, because you cannot audit every submission before it runs. So the design principle is least privilege by default: the code gets exactly enough to run a CS1 exercise — a Python interpreter, its own scratch space, and enough CPU and memory to finish a small task — and nothing else. Everything past that is denied, and has to be argued back in.
The isolation layers
No single control makes this safe. Each one closes a specific door, and the design assumes any one of them might fail.
Network isolation. CS1 code has no reason to touch the network. Cutting it off entirely removes a whole category of risk at once: no exfiltrating data, no pulling a payload down, no phoning home, no using your host as a launch point against someone else. It's the cheapest, highest-value denial in the stack.
Read-only filesystem with disposable scratch. The container's root filesystem is read-only; the only writable space is a small scratch area that's thrown away after the run. That means untrusted code can't tamper with the runtime, can't persist anything between executions, and can't leave anything behind for the next student's run to trip over. Every execution starts from the same clean, immutable base.
Dropped Linux capabilities, no new privileges. Containers start with a set of Linux capabilities that CS1 code will never legitimately need — mounting filesystems, changing file ownership, raw socket access, and more. The sandbox drops them and adds none back. Alongside that, no-new-privileges prevents a process from gaining privileges partway through execution via setuid binaries, which closes one of the most common escalation paths inside a container.
CPU and memory ceilings. This is the defense against the accidental case. An unbounded infinite loop or a runaway allocation isn't just one bad submission — without limits it's a denial-of-service against the whole host, and every other student's "Run" hangs behind it. Hard CPU and memory caps, plus an execution timeout that reaps anything still running, turn "one bad submission breaks grading for everyone" into "one submission gets killed, everyone else is unaffected."
The through-line is that none of these trust the code. They constrain the environment so that whether a submission is a confused beginner or someone deliberately probing the walls, the blast radius is a single throwaway container.
The execution API as the boundary
The sandbox is only half the design. The other half is the contract around it. /api/execute/python is the single narrow doorway between the untrusted world and the rest of the system. It takes code plus input and returns a structured result — stdout, errors, pass/fail against the problem's checks, and the achievement data that drives the gamified side.
What makes that boundary matter is what's on the other side of it. The Go backend that orchestrates everything, the Clojure service that generates the problems, the Next.js frontend the student actually uses — none of them ever touch untrusted code directly. They talk to the execution API, and the API is the only thing that ever runs a line the student wrote. So the dangerous work lives in exactly one place. There's one component to harden, one contract to validate, one thing to reason about carefully — and everything else in the system gets to be ordinary web code.
Where isolation fights scale
Strong isolation has a cost, and at CS1 scale — a full cohort clicking "Run" around the same deadline — that cost is real. The safest possible unit of execution is a fresh, fully-hardened container per run, torn down afterward: nothing is ever reused, so nothing can ever leak between students. But container startup isn't free. If every single execution spins one up cold, latency per run and load on the host both climb fast under a burst.
That's the tension, stated plainly: the strongest isolation — one disposable container per execution — is also the most expensive per execution. The design space in the middle is all about where you're willing to trade. Do you pre-warm or pool containers to amortize startup, accepting a slightly weaker "freshness" guarantee for a large latency win? Do you cap concurrency so a submission spike can't exhaust the host? Where is a few milliseconds worth spending to keep a stronger guarantee, and where isn't it?
There's no single right answer — the point is that "make it secure" and "make it fast at cohort scale" aren't two problems. They're the same problem wearing two hats, and the honest move is to decide where on that curve you're sitting deliberately, rather than discovering it under load.
What it taught me
Running untrusted code is a small idea with a large surface. The interesting part wasn't any single control — it was building the whole thing on two assumptions held at once: the code is hostile, and the environment is disposable. Get those right, draw one narrow boundary the rest of the system can trust, and everything downstream gets simpler.
That shape shows up all over production security: contain the dangerous thing, make its blast radius survivable, and hand everything around it a clean contract to build on. It's the same instinct that makes the difference between a demo that runs code and a system you'd actually let a thousand students loose on.