The 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.
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 the prompt that produced it is not a security boundary, per decision 0004. Semi-trusted code inside one caller’s trust domain is the design point. A model may be wrong on its own, or steered wrong through its inputs. 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. Boundaries the executor enforces says which controls exist in this code, and Controls the operator owns says which ones the operator adds around the daemon.
Boundaries the executor enforces
Section titled “Boundaries the executor enforces”Each boundary below names its mechanism and says where that mechanism is proven. Every one a program inside the isolate can reach is also attacked directly. On every pnpm verify, the containment corpus in apps/executor-benchmark runs hostile programs against those boundaries. Its verdict comes from what the host observed, not from what the program reports, under the method of the benchmark. One boundary stands outside the process that corpus runs in: the kernel policy the manifests carry. Reading those manifests proves it.
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 and decision 0004. Two bounds belong to one execution and the isolate carries both: the memory limit the engine enforces on its heap, and the deadline measured by the run clock of the executor protocol. Both hold for one reason. No native frame of this repository runs inside a guest call. The standard library of decision 0008 is all JavaScript, so the isolate executes guest code and the engine’s own builtins. Every allocation lands in 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 holds gigabytes under a limit of megabytes, so the runtime closes all of them, per Runtime deviations. That the closure holds is measured, not assumed. A 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. Otherwise a path that answered something plausible and allocated anyway would read 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 lands, because that work spends the run clock like any other, per the run clock. Each of the three carries bounds of its own besides, 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 settles 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.
Both of the isolate’s bounds are proven on every run of the gate. In apps/executor-benchmark, a property suite shows that a run settles at its bound, and that the memory limit ends the allocation paths a guest reaches inside the isolate.
Process boundary behind the isolate
Section titled “Process boundary behind the isolate”Each worker is an ordinary Node process holding one execution at a time by default, so a native crash ends exactly the culprit. Its caller meets a typed fault, the pool spawns a replacement, and no other execution is touched. That second layer exists because the first one is soft by its own documentation. 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 reaches 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 settles, 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 to reach the isolate again. It is a bearer capability on purpose, because every caller one daemon admits belongs to one trust domain. What follows for callers who distrust each other is under Controls the operator owns.
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 mode applies no URL policy of its own: 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 holds generated proxies whose every call crosses the boundary as data, and the sandbox scope names those proxies and the codec. Every such name is reserved so no provider declaration can shadow them, and the completion envelope is locked against redefinition. Tool authority is decided where the tool runs, in the caller’s process.
The provider declaration is not a boundary. It does not pretend to be one. Guests may name a tool or a provider no descriptor carried, and the call reaches the caller’s dispatch function under that name, per providers and the sandbox scope. A caller that serves whatever name arrives has granted the execution everything it can route. So the dispatch function authorizes against the declaration it passed.
Bounds around the engine
Section titled “Bounds around the engine”Overload is a typed fault at the queue bound, not a growing backlog. A caller that arrives at a full daemon is told to retry 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 is host memory the executor holds and a message it has to deliver, and a transport has a ceiling on one message. Without the bound, a guest ends the worker holding it instead of answering, and it reaches that denial of service in one line. Console output is captured on the host as it is written, so a run the engine killed still answers with what it logged. A server’s response limit caps what a result may carry 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 carries 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”The image runs as the unprivileged node user. Under deploy/k8s, standalone.yaml and same-pod.yaml run it without root, without privilege escalation and with every Linux capability dropped. They also give it a read-only root filesystem and the runtime default seccomp profile, and they run it without a service-account token. The compose file drops every capability, denies privilege escalation and mounts a read-only root. Its own header says what it leaves alone: the user comes from the image, and the seccomp profile is Docker’s default. An operator who applies a manifest inherits that posture whole. But an operator who writes a manifest of their own inherits none of it, and the ones under deploy/k8s are what that posture is made of. So this is the one enforced boundary the corpus cannot speak for.
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. Each class below names the evidence for leaving it there.
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. It never decides what the code means. 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 hold it on their behalf. How each one is set, and what the daemon does with it, is the operator guide.
Network around the daemon
Section titled “Network around the daemon”The daemon binds a port and serves whoever reaches it. In the standalone shape the Service is reachable inside the cluster; 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 reach 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 answers 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 carries 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, so an operator whose callers distrust one another runs a daemon per tenant or authorizes calls at the gateway in front. Inside one daemon a valid executionId is authority enough to invoke the handles it names.
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. One variable matters here. 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. Whichever environment the pool is given is the one its children get, so a host that wants a narrow one passes it.
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. Closing that channel with direct I/O and dedicated block devices is a provisioning decision.
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 stand 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. Under any of these layers the executor runs unchanged, so choosing one is a deployment decision made against the tenants the deployment actually serves.
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. Node’s floor is whatever the workspace declares in its engines field. The image base is the Debian slim image of that same line, and isolated-vm compiles 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.