If you want a model to generate code, you have to secure the place where that code runs. Prompts, output filters and “please don’t” instructions are not a boundary. A model will eventually write an infinite loop, a bad import, or something worse because a user, a document or a web page told it to. The boundary has to hold when the model gets it wrong.
I looked at two approaches:
- An embedded sandbox: QuickJS on the server plus Preact in a sealed iframe.
- A container sandbox: a container, hardened with gVisor, running Bun.
I picked the first one for my case. Both are sound, and the right choice depends on what the generated code needs to do. Here is how each one works, and how I decide.
The shared idea: no authority inside
Whichever you choose, the sandbox has the same job. Generated code starts with nothing: no network, no filesystem, no secrets, no ambient access. It gets what the host explicitly passes in, within hard limits on time and memory, and it is thrown away afterwards. The two approaches differ in how much machinery sits between the code and the host.
Approach 1: QuickJS + Preact (secure embedded sandbox)
Embedded means the sandbox is a library inside the host process. There is no container, VM or sidecar per app.
On the server, QuickJS. QuickJS is a small JavaScript engine that you embed in your program.
- It starts in microseconds and has no JIT, so the attack surface is small.
- Each call gets a heap limit, a stack limit and a deadline. An infinite loop ends at the deadline with a timeout error, and the host carries on.
- A fresh context has no network, no filesystem and no host objects. The host binds only the functions a given call needs.
- The module loader resolves an allowlist (Preact and its renderer) plus relative paths inside the app’s own files. An import of
fs, or a path that climbs out of the folder, fails to resolve. - A context is cheap, so you build a new one per call and discard it. No state leaks between requests.
In the browser, Preact in a sealed iframe. The page runs in an iframe with sandbox="allow-scripts" and without allow-same-origin, so it lives on an opaque origin. A strict CSP (default-src 'none', scripts from your own static origin only, connect-src 'none') blocks network requests. The iframe holds no cookies and no credentials. Data comes through a postMessage bridge that accepts messages only from its own iframe, only from a short list of shapes, and only with a per-frame nonce. The parent then calls your API as the signed-in user, with the normal permission and row rules.
What you get
- Millisecond-scale cost. I measured a cold QuickJS context rendering a 200-row Preact table in a median of 2.5 ms in a release build (8.5 ms in debug), including loading Preact and the renderer.
- A boundary small enough to review in one sitting: the bound host functions and the message bridge.
- No per-app infrastructure. One binary can run everything.
What you give up
- No npm ecosystem and no native modules. The code can only use what you allow it to import.
- No arbitrary processes, files or network. If the generated app needs those, this approach cannot provide them.
- You are trusting the engine. QuickJS is small and well understood, but a bug in an embedded engine is a bug in your process, so memory limits, deadlines and keeping the bound surface minimal matter.
Approach 2: container + gVisor + Bun
Here the generated code runs as a real program. Bun gives it a fast JavaScript and TypeScript runtime with a package manager, a bundler and a full standard library. The container packages it, and gVisor is what makes the container a stronger boundary.
A plain container shares the host kernel, so a kernel bug can become an escape. gVisor puts a user-space kernel in between. The sandboxed program makes syscalls to gVisor, which implements them itself and passes only a small, filtered set to the real host kernel. The attack surface shrinks from the whole Linux syscall interface to what gVisor chooses to forward.
What you get
- Full capability. Real npm packages, a real filesystem, subprocesses, and network access if you choose to allow it, restricted by egress rules.
- Language and tooling breadth. Anything that runs in a container can run here, so you are not limited to what you embedded.
- Strong isolation for untrusted native-ish workloads, because the syscall boundary is hardened rather than shared.
What you give up
- Startup and density. A container sandbox starts in a time range people notice, not microseconds, and each one costs memory. You end up keeping warm pools or snapshots to hide that, which is more machinery.
- Operations. You now run an orchestrator, images, a registry, network policy, per-run cleanup and a runtime like
runsc. That is real infrastructure with its own failure modes. - Syscall overhead and compatibility gaps. gVisor trades some performance and some Linux compatibility for isolation, so some workloads need testing or tuning.
- A bigger surface to reason about. Your guarantee now involves the image contents, the runtime, the kernel boundary and the network policy together.
Side by side
| QuickJS + Preact | Container + gVisor + Bun | |
|---|---|---|
| Isolation unit | Engine context + browser origin | Container behind a user-space kernel |
| Startup | Microseconds to milliseconds | Noticeably slower; warm pools common |
| What code can do | Only what the host binds | Anything a Linux program can, within policy |
| Dependencies | Allowlisted modules | Full npm |
| Infrastructure | None beyond the host | Orchestrator, images, network policy |
| Review surface | Bound functions + message bridge | Image + runtime + kernel boundary + network |
| Best for | Many small, short, untrusted units | Heavier, open-ended workloads |
How I chose
I asked what the generated code actually needs. If it is application logic, data rules and UI, and it can be expressed against an interface I control, the embedded sandbox is enough, and it is cheaper, faster and easier to reason about. If the generated code needs to install packages, run build steps, spawn processes or talk to arbitrary services, an embedded engine becomes a limitation and the container route is the honest answer.
For my case the answer was the first branch. The generated code is TypeScript functions and Preact components working against a data interface I define, so it never needs more than the host chooses to give it. That let me skip per-app infrastructure entirely.
Why either beats no sandbox for AI agents
- Failures stay small. Loops, bad imports and throwing components end at a deadline, a resolver error or an error boundary, with a message the model can read and use to fix its own code.
- Permissions live outside the generated code. The model can write code, but the host enforces access on every path, so it cannot widen its own rights.
- The feedback loop is fast. Generate, run, read the error, fix. The cheaper the sandbox, the more times that loop runs per minute, and that loop drives quality.
What a sandbox does not solve
A sandbox limits damage. It does not make generated logic correct. A function can still compute the wrong total, and a page can still confuse people. You still need tests, review and a deliberate step before anything reaches production. The sandbox tells you the code cannot hurt the host. It cannot tell you the app does what the user meant.
If you are building an agent harness, decide where generated code runs before you decide what it should do. Then pick the smallest sandbox that gives the code what it needs and nothing more.
I would be glad to compare notes with anyone running model-written code in production. Which approach did you pick, and why?
