Threat model
Three parts make up the executor’s security contract: the boundaries the executor enforces, the attack classes that are out of scope and the evidence for each, and the controls that belong to the operator who deploys it. Every shipped text claiming safety points here. A claim this page does not back is a defect in that text, not a promise of this one.
Four layers sit between the program and the machine, and this code is only the two innermost:
flowchart TB
subgraph network["the operator's network<br/><small>a NetworkPolicy, an ingress, a mesh</small>"]
subgraph kernel["kernel policy<br/><small>no root, no capabilities, read-only root, seccomp</small>"]
subgraph child["Node child process<br/><small>one execution at a time by default</small>"]
subgraph isolate["V8 isolate<br/><small>empty global, memory limit, deadline</small>"]
guest["the program a model wrote"]
end
end
end
end
No code this repository ships enforces either of the outer two. Kernel policy arrives with the manifests under deploy/k8s, and the network around the daemon belongs to whoever runs the cluster.
Semi-trusted code in one trust domain
Section titled “Semi-trusted code in one trust domain”Language models write the JavaScript this executor runs. That code is untrusted by construction: nothing reviews it between generation and execution, and no wording in the prompt constrains what it does once it runs. This executor is built for semi-trusted code inside one caller’s trust domain. A model may be wrong on its own, or steered wrong through its inputs, and either way the executor keeps whatever it wrote inside the bounds the caller granted. Serving mutually distrusting tenants on one machine is a stronger requirement, and meeting it takes controls the operator adds around the daemon.
Boundaries the executor enforces
Section titled “Boundaries the executor enforces”On every pnpm verify the containment corpus in apps/executor-benchmark runs hostile programs against every boundary a program inside the isolate can reach. Its verdict comes from what the calling process observed, not from what the program reported, under the method of the benchmark. One boundary sits outside the process that corpus runs in, and the manifests under deploy/k8s are the proof for that one:
| Boundary | What enforces it | How it is proven |
|---|---|---|
| A fresh isolate per execution | V8, on an isolate whose global starts empty | Programs that write a global, pollute a prototype or leave a timer behind, each followed by a witness run that reads the next isolate back |
| The memory limit | V8, metering the allocator it was given | Two suites: the corpus refuses a resizable ArrayBuffer, a SharedArrayBuffer and an indirect WebAssembly lookup, and memory.spec.ts samples the process rss while each one asks for more than the limit |
| The deadline | The run clock, measured outside the isolate | Worked cases that end a run at its bound whatever the guest does, a monolithic builtin the interrupt cannot cut included |
| One child process per worker | The pool, which spawns the children and supervises them | The pool’s own suite, where a child that dies mid-execution surfaces WORKER_CRASHED and a replacement spawns |
| Tool authority | The dispatch function, in the caller’s own process | Programs that call a tool and a provider the declaration never named, and one that replaces the serializer under the completion envelope |
| The outbound setting | The mode the caller chose for that execution | Programs that look for a second network surface beside fetch, and an http specifier that finds the module map instead of the network |
| Payload and console bounds | The executor, measuring each message where it is built | Programs that return, throw, log, call a tool and fetch past every one of those bounds |
| Kernel policy | The manifests under deploy/k8s |
Reading them |
Each execution runs inside its own isolate
Section titled “Each execution runs inside its own isolate”Every execution runs in a fresh V8 isolate that starts with an empty global, and each capability inside it was installed on purpose, under the doctrine of Architecture. Two bounds apply to one execution: the memory limit the engine enforces on the isolate’s heap, and the deadline the run clock of the executor protocol measures. Neither bound can be evaded, because no native frame of this repository runs inside a guest call, and the host JavaScript that does run inside one is capped where it is built: the captured console at its capture limit, one getRandomValues call at the 65,536 bytes Web Cryptography allows, the rest under Bounds around the engine. The standard library of the runtime is all JavaScript, so the isolate executes guest code and the engine’s own builtins. Every allocation goes through the metered heap, and the interrupt reaches guest code at its back edges.
The engine meters what passes through the allocator it was given. But V8 keeps a few kinds of memory outside that allocator: a WebAssembly memory, and the growth of a resizable buffer, shared or not. Each of them can take gigabytes under a limit of megabytes, so the runtime closes all of them, per Runtime deviations. One case per path asks for far more memory than the limit could grant, and it reads the memory of the process running the isolate while it asks. Without that reading, a path that returned something plausible and allocated anyway would count as closed.
Some of a run’s work happens on the host: console capture, a native outbound request, crypto.subtle. The memory limit meters none of it, and the interrupt cannot stop it once it has started. But the deadline still ends the run, because that work spends the run clock like any other, per the run clock. Each of the three has bounds of its own, stated under Bounds around the engine.
One frame runs past the interrupt without taking the run with it. A single long-running V8 builtin, a large BigInt conversion among them, runs to completion on the isolate’s thread. It cannot poison the isolate or stretch the reported run, because the deadline is measured on the host and the run ends at its bound whatever the guest is doing. That builtin spends its own CPU until it returns, the isolate is disposed after it, and the worker’s process memory and retirement bounds cover the residue.
Process boundary behind the isolate
Section titled “Process boundary behind the isolate”Each worker is an ordinary Node process running one execution at a time by default, so a native crash ends exactly the culprit. Its caller gets a retryable WORKER_CRASHED fault, the pool spawns a replacement, and no other execution is touched. The process is a second layer because the bound inside it is soft: isolated-vm calls its memory limit a guideline, not a strict limit, and says a determined attacker could use two to three times it before the script is terminated. So the pool retires a worker once its process memory passes the worker bound, and again after a bounded number of executions. Container limits in the deploy manifests are sized over the documented overshoot.
Nothing survives an execution
Section titled “Nothing survives an execution”Every isolate is disposed when its run finishes, per the persistence contract. retainHandles is the one negotiated exception, and it stays bounded. Every invocation runs under the execution’s own timeout and memory limit, an idle bound closes a retained execution nobody invokes, and the granted executionId is minted with crypto.randomUUID in the process that ran the isolate. Holding that identifier is the only way back into that isolate. It is a bearer capability on purpose, because every caller one daemon admits belongs to one trust domain. What that leaves for callers who distrust each other is under Controls the operator owns: a daemon per tenant, or authorization at the gateway in front.
Outbound is disabled, served by the caller, or native
Section titled “Outbound is disabled, served by the caller, or native”Disabled means the network does not exist for that execution. fetch rejects with the declared text. A caller-supplied function means every request and response crosses the caller’s own code as plain data, so the caller’s policy sees every exchange. Native means the executor performs the request itself. That work counts toward the run’s deadline. But native applies no URL policy of its own, and the guest reaches whatever the executor’s network reaches. Where that is too wide, the caller disables outbound or the operator closes the network around the daemon.
Tool authority travels with values, not names
Section titled “Tool authority travels with values, not names”Provider implementations never leave the caller’s process. The sandbox holds no credential to steal. Inside the isolate the guest gets generated proxies whose every call crosses the boundary as data, and the sandbox scope names those proxies and the codec. Every one of those names is reserved, so no provider declaration can shadow it. The run reports its result through a global of its own, and that global can be neither redefined nor deleted. A program that writes it gets its own value back, and return already gives it that.
The executor tests no name a guest calls against the declaration the caller passed. Guests may name a tool or a provider that no descriptor lists, and the call reaches the caller’s dispatch function under the name they chose, per providers and the sandbox scope. A dispatch that routes whatever name arrives has granted the execution everything it can route, so a caller checks every name against the declaration it sent.
Bounds around the engine
Section titled “Bounds around the engine”Callers that arrive at a full daemon get a retryable QUEUE_OVERFLOW fault at the queue bound, instead of waiting behind work the machine cannot finish. Everything a run sends outward is bounded where it is built, per the payload bound: the answer, a tool call, an outbound request, a handle answer, and the captured console text together. Each of those becomes memory in the executor’s own process and a message it has to deliver, and every transport in the path caps one message. Without the bound, one line of a guest’s code ends the worker running it instead of returning. Console output is captured outside the isolate as it is written, so a run the engine killed still returns what it logged. A server’s response limit caps what a result may contain beyond that, at the operator’s own number.
For the guest, the host performs crypto.subtle operations. Key material sits where the memory limit cannot meter it, and an operation already running is past the interrupt. So that work has bounds of its own: a cap on the keys one execution may create, and one operation at a time. Both are stated in Runtime deviations.
Kernel policy the manifests carry
Section titled “Kernel policy the manifests carry”Containers from the image run as the unprivileged node user, and both manifests under deploy/k8s set the rest of the posture themselves. These are the fields standalone.yaml sets, under spec.template of the Deployment:
# deploy/k8s/standalone.yaml, spec.template.specspec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 seccompProfile: type: RuntimeDefault containers: - name: executor securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALLRuntimeDefault takes the container runtime’s own seccomp profile, and a Pod without it runs unconfined unless the kubelet turns seccompDefault on. same-pod.yaml sets the same fields, with the user on the executor’s own container so that the application beside it keeps whichever user its image needs. deploy/compose.yaml drops every capability, denies privilege escalation and mounts a read-only root, and its own header says what it leaves alone: the user comes from the image, and the seccomp profile is Docker’s default. Operators who apply a manifest inherit that posture whole. But an operator who writes one instead inherits none of it, so this is the one enforced boundary the corpus cannot speak for, and an operator writing a manifest copies these fields into it.
Attack classes out of scope
Section titled “Attack classes out of scope”Out of scope means the executor does not defend against the class, and no shipped text may imply that it does.
V8 escapes, and what stands behind the isolate
Section titled “V8 escapes, and what stands behind the isolate”Every isolation layer built out of a language engine eventually meets an attacker it cannot deflect, and V8 is no exception. Its heap sandbox is the most widely deployed software fault isolation mechanism there is. The first systematic fault-injection study of it found 19 security bugs that let an attacker bypass the sandbox: Empirical Security Analysis of Software-based Fault Isolation through Controlled Fault Injection.
This executor assumes exactly that. Here the isolate is a layer and never the last one. The child process and the kernel policy stand behind it. But a tenant who must be defended against a V8 zero-day belongs behind one of the stronger isolation layers listed under Controls the operator owns.
Transient-execution side channels
Section titled “Transient-execution side channels”Spectre-class leakage is mitigated in compilers, in kernels and in silicon, and the kernel’s own catalogue of hardware vulnerabilities shows how much machinery that takes. An executor configures none of those layers. Where distrusting tenants share hardware, keeping them apart is a scheduling decision of the platform.
Page-cache timing across tenants
Section titled “Page-cache timing across tenants”Page-cache timing persists wherever the I/O path exposes shared, host-cacheable file-backed objects. Containers, gVisor, Kata and full virtual machines expose them alike: Isolation Failure From Shared Storage. Direct I/O and dedicated block devices attenuate or eliminate it, so the mitigation is a storage decision and it stands under Controls the operator owns.
Judging the code
Section titled “Judging the code”The executor contains what runs. Scanning, denylists and content filters are not boundaries here, because each of them has been measured failing at the job.
Obfuscation drives static analysis of JavaScript toward total loss: a single technique suppresses most findings, high-severity ones included, and stacked techniques approach complete evasion, per Obfuscating Code Vulnerabilities against Static Analysis in JavaScript Code. Real denylists are reported failing between 69 and 98 percent of the time across the policy-enforcement studies collected in The Balkanization of Execution-Security Research for AI Coding Agents. And a text moderation model scores near zero when asked whether a proposed agent action is in scope: an F1 of 0.12 for the OpenAI Moderation API and 0.01 for Llama Guard 3, on the benchmark of ILION. Those models were built for another task. A scanner is still worth running as a quality signal in the caller’s own pipeline. But no boundary here rests on one.
Controls the operator owns
Section titled “Controls the operator owns”Each control below belongs to the operator, and the executor does not set it for them. Deploying the daemon says how each one is set and what the daemon does with it.
| Control | What it is for |
|---|---|
| A NetworkPolicy around the Pod | Keeping workloads that were never meant to call the daemon off it |
The onConnection hook, or a gateway in front |
Attributing every execution to a caller, and turning the rest away |
| A narrow environment for the daemon process | Keeping CODEMODE_TESTING and anything like it away from the workers |
| Direct I/O and dedicated block devices | Closing the page-cache channel between tenants |
| A kernel policy, gVisor or a microVM under the daemon | Narrowing what an escape from the isolate can do |
| A rebuild cadence for the image | Taking the current V8 of that Node line |
Network around the daemon
Section titled “Network around the daemon”The daemon binds a port and runs code for whoever connects to it. In the standalone shape the Service is reachable inside the cluster, and in the same-Pod shape the port is still open on the Pod address. Transport security ends at the operator’s ingress or mesh. Kubernetes allows every connection to a Pod until a NetworkPolicy selects it, so a cluster where any workload may connect to any Pod is a cluster where any workload may run code here. The same-Pod manifest states that in its own header.
Identity on the handshake
Section titled “Identity on the handshake”Listeners take an onConnection hook that returns an identity or rejects the connection. An HMAC, a token or a gateway header all fit it, and the executor.request span of every execution on that connection records the identity it returned. Beyond that hook the daemon adds no authentication of its own, and the admin app binds to the loopback address by default. Identity is attribution. It scopes nothing, execution handles included, and inside one daemon a valid executionId is authority enough to invoke the handles it names. So an operator whose callers distrust one another runs a daemon per tenant, or authorizes calls at the gateway in front.
Environment the children inherit
Section titled “Environment the children inherit”A worker is a child process, and unless a host names a different environment the children inherit the parent’s. CODEMODE_TESTING opens two branches in the child that let a caller end or spin its worker by sending a literal string as code. This repository’s own suites set it for the processes they spawn, and production never sets it. No other code reads it, so the branches are dead there. But a deployment that sets it, or that forwards a whole ambient environment into the daemon without reading it, hands every caller a way to end or spin one worker at a time. So a host that wants a narrow environment passes one.
Storage
Section titled “Storage”The daemon writes only to its own memory, and the manifests mount no writable path. Where an operator adds shared storage under distrusting tenants, the page-cache channel above arrives with it. That channel is the operator’s to close, with direct I/O and dedicated block devices under whatever the tenants share.
Stronger isolation layers for hostile multitenancy
Section titled “Stronger isolation layers for hostile multitenancy”A kernel-enforced syscall, filesystem and network policy can sit under every child without root and without images. Sandlock measures roughly 5 ms of startup overhead for it. That price sits beside what a container gives on its own: Docker’s default seccomp profile disables around 44 system calls out of more than 300. Beyond it sit gVisor, which puts a userspace kernel between the guest and the host, and microVMs such as Firecracker. Those are the classes that separate cleanly on every architectural axis of AI Code Sandboxes: A Comparative Security Study. That study does not cover the in-process isolate class this executor ships, so it ranks this executor neither above them nor below them. Choosing one is a deployment decision, made against the tenants the deployment actually has, and the executor runs unchanged under any of them.
Engine version and how often it is rebuilt
Section titled “Engine version and how often it is rebuilt”V8 arrives inside Node, so the engine version is the Node version. engines in package.json names the Node range, and the image base names the line: the Debian slim image of it, with isolated-vm compiled from source against the Node that runs. That base names a line, not a digest, so the rebuild is the pin. Rebuilding the image takes that line’s current Node with its current V8, and how often that happens is the operator’s decision. Pin policy is the dominant operator-facing variable in the comparative study above. Engine-side patch latency aggregates to about zero days for coordinated disclosures, while the downstream lag of a product runs from zero days to more than 471, to opaque, to never. This repository ships no engine of its own, so the only lag it can create is the one between rebuilds.