Skip to content
American owned and operated
Nightshift

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.

Nightshift EngineeringAugust 30, 202610 min read

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.

The program a model wrote, run in a bare isolatejavascript
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);
}
Resultoutput
12 globals
[]
ReferenceError: fetch is not defined
Nothing was denied here. The network simply does not exist in this world, so there is no policy to get wrong and no bypass to find.

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.

main.rs: a runtime with a heap and an event looprust
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
}
From the Deno team’s guide to building a runtime. At this point the isolate can compute, and that is all it can do.

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.

The op is the checkpoint, not a wrapper around onerust
#[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"],
);
Canonicalise first, then compare. The check runs in Rust, on the resolved path, where the program cannot reach it.

On the JavaScript side, the op becomes an ordinary function. That is the whole API surface the model gets, and you can print it.

src/runtime.jsjavascript
globalThis.runjs = {
  readFile: (path) => core.ops.op_read_file(path),
};
The generated program, exercising its optionsoutput
> 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 workspace
The traversal fails for the same reason as the direct attempt: canonicalize resolved it before the comparison, so there is one check rather than a list of tricks to anticipate.
Figure 1: the only way out is a door you built

Host 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

fetchfsprocesschild_processnetrequire

ops: the whole surface

op_read_filepath checked against an allowlist
op_queryread only, row limited
op_logthe only way anything returns

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.

Bounding the run from the host siderust
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();
});
A tight loop cannot be interrupted from inside the isolate it is running in. The handle is thread safe for exactly this reason.
What the supervisor seesoutput
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."
The failure is reported back as an ordinary result the agent can reason about, rather than a hung request.

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

IsolateContainerMicroVM
Starting postureNothing exists yetA full operating systemA full operating system
StartupMillisecondsHundreds of ms and upTens of ms and up
Memory floorA couple of MBTens of MBTens of MB
Boundary enforced byV8, a JIT compilerThe kernelThe hypervisor
LanguageJavaScript and WASMAnythingAnything
Fresh instance per runRoutineExpensiveExpensive

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.

Ready to start building your AI workforce?

It only takes an hour, no fluff. Meet with a Nightshift engineer to get started.