Skip to content

What this is

Code mode gives a model one move where it used to have many. It writes a program, the program calls your tools, and one execution does the whole job. So what arrives at your host is a program, and none of the usual defences apply to it.

Nobody read it. Nothing at all stands between the token the model emitted and the statement your machine runs, and no review step is coming, because the code arrives at machine speed and in volume. Your prompt is no boundary either: a record the model read on the way in can steer what it writes, so the program in front of you may be carrying an instruction that was never yours. That leaves exactly one place for the boundary: the thing that runs the code.

Here that is a V8 isolate, one per execution, created with an empty global and disposed the moment the run settles. The engine enforces a memory limit on the isolate’s heap, and a deadline runs on the host clock, so a program that never yields still ends on time. Each isolate sits inside an ordinary Node child process the executor supervises, and that process boundary keeps a native crash to the single caller who caused it.

Programs reach only what you hand them. Every name inside was installed on purpose: the standard library this repository writes against ECMA-429, the minimum common web API, the tools you declared for this execution, and the outbound setting in whichever state you chose. Reading a name nobody installed throws a ReferenceError, and typeof on it reads undefined, exactly as it would in any other JavaScript. So a program written for some other runtime goes looking and finds this:

await executor.execute({
code: `async () => ({
process: typeof process,
require: typeof require,
Buffer: typeof Buffer,
WebSocket: typeof WebSocket,
})`,
})
// { result: { process: 'undefined', require: 'undefined', Buffer: 'undefined', WebSocket: 'undefined' }, logs: [] }

process and Buffer read undefined because the isolate is not the Node process around it. It has a heap and a global of its own, and the only paths out of it are the ones this code installed. require is absent from the global too. But an entry of the module map declared cjs is handed a require of its own, and that one resolves against the map alone, throwing Cannot find module for every other name. WebSocket is absent for a plainer reason: nobody has built one here yet.

What a program does reach, it reaches as data. A tool call leaves the isolate as three strings and comes back as one, the function that serves it never leaves your process, and the sandbox holds no credential worth stealing.

Each field of the request either grants a capability or withholds one. providers and dispatch are how a program reaches your data. You declare the providers your model is told about, the sandbox turns each name into a callable proxy, and your dispatch serves the calls that arrive. Programs are free to call a name the declaration never carried. That call still reaches your dispatch, and your dispatch is where you turn it down. The tools guide works through what that function has to decide.

outbound is the network setting, and it takes three values. Leave it out and the executor performs the request itself. Set it to null and fetch rejects, with a text telling the model to ask through a tool instead. Give it a function and every request and every response crosses your own code as plain data: the only place an allow list or a credential can live. The network guide says what to put in that function.

Four bounds are the rest, and the bounds guide takes them one at a time: a timeout read on the host clock, a memory limit the engine enforces, a queue that rejects instead of growing, and a ceiling on any single message a run sends outward. Each of the four reports a text of its own, so a model that ran out of memory is told exactly that.

Two further fields grant a capability the same way. modules is the module map, and it names what a program may import. retainHandles asks for a run that keeps serving after it returns.

Two arrangements run the same executor, and both serve one contract. They differ in where the isolate lives and what has to cross a network:

flowchart LR
  subgraph yours["your process"]
    host["your host"]
    tools["your tool functions<br/><small>and your outbound policy</small>"]
  end

  subgraph inproc["in your process"]
    pool1["pool"] --> child1["child process"] --> iso1["isolate"]
  end

  subgraph service["as a service"]
    daemon["daemon"] --> pool2["pool"] --> child2["child process"] --> iso2["isolate"]
  end

  host -- "execute" --> pool1
  host -- "execute, over a WebSocket" --> daemon
  iso1 -. "tool call" .-> tools
  iso2 -. "tool call, back over the same socket" .-> tools

In your process, you construct an executor, it owns a pool of child processes beside you, and the isolates run in those. Nothing crosses a network, and the pool starts and stops with your service. One Node service that runs model code as part of its own work wants this shape.

As a service, you start the daemon, your host opens a WebSocket to it, and the isolates run wherever that daemon runs. Those dashed arrows are the tool calls: your tool functions stay in your process either way, and every call one of them gets travels back to you while the program is still running. A fleet wants this shape, and so does any host with no way to spawn a child process of its own. The remote client names no runtime API, so a Cloudflare Worker can run it.

Every isolation layer built out of a language engine eventually meets an attacker it cannot deflect, and V8 is no exception. This design assumes exactly that, so the isolate is one layer here and never the last: the child process stands behind it. Semi-trusted code inside one caller’s trust domain is the design point. Your model may be wrong on its own, or steered wrong by something it read, and the executor keeps whatever it then wrote inside the bounds you granted. But serving tenants who distrust each other on one machine is a stronger requirement. The threat model is exact about which controls live in this code and which your deployment adds.

This executor never judges the code either. No scanner reads it and no denylist filters it, because obfuscation defeats static analysis of JavaScript and denylists are measured failing at the same job. Both measurements are under judging the code. But a scanner is still worth running as a quality signal in your own pipeline, and no boundary here rests on one.

Run it in your own process, or run it as a service and connect to that. Both serve the same contract, so a host that starts with the first can move to the second without rewriting a call.