Overview
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.
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 an instruction that was never yours can end up inside the program in front of you. Only the thing that runs the code can still bound what that program does.
Here that is a V8 isolate, one per execution, created with an empty global and disposed the moment the run ends. V8 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.
Other ways to contain a program
Section titled “Other ways to contain a program”Containment that survives a program written to break it needs four things: a heap of its own, a memory limit the program cannot raise, a CPU bound that outlives an infinite loop, and a process boundary. Options separate on those four:
| Approach | Own heap | Memory bound | CPU bound | Process boundary |
|---|---|---|---|---|
node:vm |
no, the host’s | none | timeout on the call |
none |
| SES and ShadowRealm | no, the host’s | none | none | none |
| QuickJS compiled to WebAssembly | yes | JS_SetMemoryLimit on the runtime |
JS_SetInterruptHandler, documented as the way to build an execution timeout |
none |
| workerd, embedded | yes, one per Worker | none in its README | none in its README | none of its own, and its README directs you to a virtual machine for possibly-malicious code |
| A V8 isolate through isolated-vm, in a child process | yes | V8, on the isolate’s heap | a deadline on the host clock, outside the program’s reach | one supervised child per isolate by default |
Node states it outright, in the module’s own documentation: “The node:vm module is not a security mechanism. Do not use it to run untrusted code.” SES is as plain about resources. A program inside a compartment can “execute for an indefinite amount of time, allocate arbitrary amounts of memory”. QuickJS isolates, and its page describes an interpreter with no compiler named behind it. Its global is as empty as a bare isolate’s, so the same standard library still has to be written. Embedding workerd buys a finished runtime instead, at the price of a whole platform around one isolate. This executor is in the same position as QuickJS. The standard library inside the isolate is written in this repository from the standards that govern each interface, and the surface grows one capability at a time.
Names a program can reach
Section titled “Names a program can reach”A program sees only the names you install. 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. A name nobody installed reads undefined under typeof, exactly as it would in any other JavaScript:
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. But a bare reference to process, with no typeof in front of it, throws:
await executor.execute({ code: 'async () => process.version' })// { result: undefined, error: 'process is not defined', logs: [] }process is not defined is the isolate’s own ReferenceError, and it comes back in error like any other throw from a program. require is missing from the global too. But a module the map declares as cjs is handed a require of its own. That one resolves against the module map alone and throws Cannot find module '<id>' for every other name. The names this runtime publishes do not include WebSocket, so it reads undefined like any other name nobody installed. Data is the only thing that crosses. A tool call leaves the isolate as three strings and comes back as one, and the function behind it runs in your own process. No credential of yours enters the isolate.
Fields that grant a capability
Section titled “Fields that grant a capability”code is the only field a request must set. The rest each grant the program something, and timeout, countHostWaits, modules and outbound fall back to the executor’s own setting when a request leaves them out:
| Field | What it gives a program | Guide |
|---|---|---|
providers |
one callable name in the sandbox per provider you declare, and no filter on the names a program actually calls | Tools |
dispatch |
the function every tool call arrives at, running in your process | Tools |
outbound |
where fetch goes: the executor’s own network when the field is absent, your function when you pass one, and nowhere when it is null, where the refusal text tells the model to ask through a tool instead |
Network |
modules |
the specifiers import resolves |
Modules |
timeout |
the clock one run gets | Bounds |
countHostWaits |
whether time your own tools spend counts against that clock | Bounds |
retainHandles |
an isolate that stays alive for invokeHandle when the run returns a request handler |
Handles |
Refusing a name you never declared is your dispatch’s job. The tools guide shows one arriving, under Undeclared names reach your dispatch.
Three more bounds apply without appearing on the request: a memory limit V8 enforces on the isolate’s heap, a queue that rejects instead of growing, and a ceiling on any single message a run sends outward. The memory limit and the ceiling reach the model as declared texts, so a program that ran out of memory is told exactly that. A full queue is the one your own code catches, because it rejects the call before any program runs. Bounds takes them one at a time, with the two more an operator can set above every caller.
In your process, or as a service
Section titled “In your process, or as a service”Two arrangements run the same executor, and both implement 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. This arrangement fits a Node service that runs model code as part of its own work.
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. Use it for a fleet behind one pool, or where your host cannot spawn a child process of its own. The remote client names no runtime API, so a Cloudflare Worker can run it.
Claims the executor does not make
Section titled “Claims the executor does not make”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, and a child process is the layer 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 running 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.
Nothing here reads the code for meaning. Scanning, denylists and content filters have each been measured failing at that job, and judging the code cites the measurements. Run a scanner in your own pipeline for the quality signal. No boundary here rests on one.
Run it in your own process, or run it as a service and connect to that. Both implement the same contract, so a host that starts with the first can move to the second without rewriting a call.