Runtime
Why a V8 isolate is the right sandbox for code a model wrote
If an agent writes a program, something has to run it. A container starts with an operating system and asks you to remove things. An isolate starts with nothing and makes you add them back, one function at a time.
The moment you let a model write a program rather than pick a tool, you inherit a question the tool-calling world let you ignore: where does that program run? Nobody reviewed it. It was generated seconds ago from a prompt that may itself have been influenced by data you do not control.
The instinct is to reach for a container. That instinct is close, however, we propose a different isolation primative. This piece is about what a V8 isolate actually is, why its default posture suits generated code better than a container does, what it costs, and the several things it does not protect you from.
What an isolate is
V8 (the javascript engine inside of chrome and node) defines it in one line: an isolate is a javascript VM instance with its own heap. Inside it sits at least one context, which V8 defines as an execution environment that allows separate, unrelated JavaScript code to run in a single instance of V8.
Deno quotes the V8 documentation directly: isolates have completely separate states, and objects from one isolate must not be used in another. There is no shared heap.
A useful mental image is browser tabs. Each one runs JavaScript in its own world, and code in one tab does not casually reach into another. Isolates are that mechanism, taken out of the browser and used as a unit of deployment.
Isolates are empty by default
Here is the thing that makes isolates interesting for generated code.
A container starts with a filesystem, a network stack, a process table and a package manager, and your job is to take those away: drop capabilities, add seccomp filters, set a read-only root, remove the shell.
A bare isolate starts with the JavaScript language and nothing else. Things like fetch and module resolution are provided by the javascript runtimes, not the execution context itself.
const builtins = Object.getOwnPropertyNames(globalThis);
console.log(builtins.length, "globals");
console.log(builtins.filter((k) => /fetch|process|require|fs/.test(k)));
// Try the things generated code reaches for first.
try {
await fetch("https://example.com/exfiltrate");
} catch (e) {
console.log(e.constructor.name + ":", e.message);
}12 globals [] ReferenceError: fetch is not defined
Every capability is a door you built
Since the isolate starts empty, anything the program can do is something you put there. In deno_core, the crate the Deno team factored out for exactly this, those crossings are called ops.
A minimal runtime is genuinely small. This is most of one.
async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module =
deno_core::resolve_path(file_path, &std::env::current_dir()?)?;
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(Default::default()).await?;
result.await
}To let it touch a file, you write the function that touches the file. The signature is the entire contract, and because it is your Rust, it is also where policy goes.
#[op2(async)]
#[string]
async fn op_read_file(#[string] path: String) -> Result<String, AnyError> {
// The generated program never sees a path we did not agree to.
let resolved = std::fs::canonicalize(&path)?;
if !resolved.starts_with("/srv/workspace/") {
return Err(anyhow!("outside the workspace"));
}
let contents = tokio::fs::read_to_string(resolved).await?;
Ok(contents)
}
extension!(
runjs,
ops = [op_read_file],
esm_entry_point = "ext:runjs/runtime.js",
esm = [dir "src", "runtime.js"],
);On the JavaScript side, the op becomes an ordinary function. That is the whole API surface the model gets, and you can print it.
globalThis.runjs = {
readFile: (path) => core.ops.op_read_file(path),
};> await runjs.readFile("/srv/workspace/orders.csv")
"id,customer,total\n1041,Northwind,1840.00\n..."
> await runjs.readFile("/etc/passwd")
Uncaught Error: outside the workspace
> await runjs.readFile("/srv/workspace/../../etc/passwd")
Uncaught Error: outside the workspaceHost process: Rust, with seccomp and namespaces around it
V8 isolate: its own heap
The generated program runs here. Nothing in this heap is reachable from any other isolate.
agent-program.js
Never created in globalThis
ops: the whole surface
Host capabilities
- Files
- Database
- Network
Credentials live out here. The isolate never holds one.
A container starts with an operating system and you take things away. An isolate starts with nothing and you add them back one function at a time, which is the direction you want when you did not write the code.
Cheap enough to throw away
The performance argument is secondary to the shape argument, but it changes what designs are available to you.
Cloudflare puts a Worker at a couple of megabytes of memory, hosts many thousands of guest applications per machine, and switches between them thousands of times a second. Their comparison with the alternative is blunt: if each tenant lives in its own process, the overhead is orders of magnitude larger, and the CPU cost can easily be 10x.
For generated code that budget buys something specific: you can afford one isolate per program and destroy it afterwards. No reuse, no pooling, no cleanup routine that has to be correct. Whatever the program defined, monkey-patched or left on a prototype dies with the heap.
That matters more than it sounds. A long-lived sandbox shared across runs is a place for one program to leave something behind for the next one. A fresh heap per execution removes that category of problem by construction, and it is only affordable because the unit is this small.
Stopping a program that will not stop
Generated code loops forever sometimes. Not maliciously, just wrongly, and an isolate is a cooperative environment: JavaScript running a tight loop does not yield.
The embedder handles this from outside. V8 exposes execution termination that can be called from another thread, and the heap is capped at creation, so runaway allocation fails rather than taking the host with it. FastMCP, which we looked at in the code mode post, ships defaults of a 30 second wall clock and 100 MB, which are reasonable starting numbers.
let handle = js_runtime.v8_isolate().thread_safe_handle();
// The watchdog lives outside the isolate, which is the only place
// it can be effective: the loop inside will never yield to it.
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(30));
handle.terminate_execution();
});program: while (true) { total += 1 }
[30.0s] execution terminated
[30.0s] isolate disposed, 41 MB reclaimed
turn result: "The program exceeded its time budget and was stopped."What an isolate does not give you
This is the part that gets skipped in posts like this one, so here it is with the sources attached.
Deno states it directly: V8 isolates alone do not provide a perfect sandbox. Cloudflare is equally direct about the specific gap, saying V8 itself cannot defend against Spectre, and adding that it is unlikely any all-encompassing fix for Spectre will be found. An isolate is a memory boundary enforced by a JIT compiler. It is not a hardware boundary, and the thing enforcing it is a large, fast-moving C++ codebase whose bugs are worth real money to other people.
What both do instead is treat the isolate as one layer and build around it. It is worth seeing what that actually looks like, because "use isolates" is not a design.
What the layers look like in practice
- Cloudflare removes the clock: Date.now() returns the time of the last I/O and does not advance during execution, and multi-threading is not allowed. Timing attacks need a timer.
- Workers that behave abnormally, detected with CPU performance counters, get rescheduled into their own process. Isolation escalates when something looks wrong rather than being paid for up front by everyone.
- Runtime restarts and rescheduling across machines reset where things sit in memory, so an attacker cannot rely on a stable layout.
- Tenants are cordoned by trust: a free-plan customer is not scheduled in the same process as an enterprise customer.
- Deno Deploy runs each deployment in its own isolate in its own process, and wraps that in seccomp system call filtering, network restrictions, namespaces, and a runtime built in Rust for memory safety.
- Cloudflare keeps the V8 patch gap under 24 hours. When the boundary is a piece of software, how fast you can replace it is part of the security model.
Picking a boundary
| Isolate | Container | MicroVM | |
|---|---|---|---|
| Starting posture | Nothing exists yet | A full operating system | A full operating system |
| Startup | Milliseconds | Hundreds of ms and up | Tens of ms and up |
| Memory floor | A couple of MB | Tens of MB | Tens of MB |
| Boundary enforced by | V8, a JIT compiler | The kernel | The hypervisor |
| Language | JavaScript and WASM | Anything | Anything |
| Fresh instance per run | Routine | Expensive | Expensive |
The row that decides it for generated code is the first one, not the fast ones. If a program needs Python, a container is the answer and the cost is worth paying. If the program is JavaScript, an isolate gives you a default of nothing, which is the default you want for code nobody read.
A stabilizing target
One practical objection to a hand-built runtime is that you are inventing an API surface, and a model writing against it has never seen it before. That is a real cost, and it is getting smaller from two directions.
The first is that the runtime landscape has stopped being a monoculture. Jamie Birch’s survey of the last decade counts an inundation of new JavaScript runtimes and, more usefully, notes how differently they are built underneath: V8 in Deno and workerd, JavaScriptCore in Bun, SpiderMonkey in WinterJS, QuickJS in LLRT and Wasmer Edge. As the survey puts it, the backend is no longer solely a stage for Node.js and V8, and it is now fashionable to pick a runtime and engine optimised for the task. Building a small one for a specific job is a normal thing to do now.
The second is that the surface those runtimes agree on is being written down. WinterTC, the Ecma technical committee for server-side JavaScript, exists to get some level of API interoperability across server-side runtimes, particularly for APIs shared with the web, and publishes a minimum common API as the baseline. The closer your sandbox sits to that baseline, the more of the model’s existing knowledge transfers, and the less of your API surface has to be explained in a prompt.
What we take from it
The code mode post argued that letting a model write a program beats making it choose between tools. This is the other half of that argument, because the pattern is only responsible if the program lands somewhere you understand.
An isolate is the right shape for that landing. It starts with nothing, which means the interesting artefact is the list of ops, and that list is short enough to read, review and put in a changelog. Every capability is a Rust function you own, which makes each one a place to check identity, scope a query, mask a column or refuse. The whole thing is cheap enough to be per-execution, so nothing survives between runs.
That is the same answer we keep arriving at from different directions. Do not try to constrain the model. Constrain what is reachable, at a boundary you built and can point at, and keep the credentials on the other side of it.
Sources
- V8: embedding V8, on isolates, contexts, handles and scopes
- Deno: the anatomy of an isolate cloud
- Deno: roll your own JavaScript runtime
- Cloudflare: the Workers security model
- Jamie Birch: the many, many, many JavaScript runtimes of the last decade
- WinterTC: Ecma TC55, server-side JavaScript interoperability
