Inside the Directive Sandbox – Building Safe Code Execution for AI Agents
We needed a TypeScript runtime that could execute code an LLM had just written and return what happened. Not in a "usually works" sense – in a "safe enough to expose on a public Vercel endpoint" sense.
This is how we built it. It's a tour of @directive-run/sandbox, the engine behind the run_in_sandbox MCP tool and the live transcript panel at directive.run/playground. If you're building anything that needs to execute generated code on a server – a CI gate for AI-written examples, a teaching playground, a copilot that grades its own output – this is the architecture that worked for us.
The constraints
We started from these requirements:
- Execute arbitrary TypeScript that imports any package in the
@directive-run/*ecosystem. - Capture observed behavior –
console.logoutput, the post-settle()facts snapshot, the value of every declared derivation, structured errors. - Run safely on Vercel – meaning a single Next.js API route can dispatch the work to a worker without bringing down the function instance.
- Defend against the obvious escapes –
process.exit(),fs.writeFileSync(), network calls to internal IPs, the entire kit-bag of "sandbox escape viaglobalThis.X" patterns. - Cold-start in under a second – this is interactive use, not batch jobs.
There are a lot of "TypeScript sandbox" libraries out there. Most of them are either way too permissive (a wrapper around vm.runInNewContext that calls itself safe) or way too restrictive (a pure-function evaluator that can't even import a module). We needed the middle ground: real ESM imports, real await, real Node APIs for what we choose to expose – and a hard line at everything else.
Three layers of defense
The architecture is a pipeline:
input
↓
AST allowlist validator ← rejects on the syntax layer
↓
esbuild bundler ← virtualizes the multi-file payload
↓
worker_threads.Worker ← bounded execution + capture
↓
SandboxResult
Each layer does one thing and does it well. The next layer assumes the previous one did its job.
Layer 1: AST allowlist validator
The first layer is a ts-morph AST walker that rejects unsafe syntax before it ever reaches a bundler.
The allowlist covers imports – every file in the payload must import from @directive-run/{core,ai,query,react,vue,svelte,solid,lit,el,optimistic,timeline,mutator,knowledge,scaffold,claude-plugin,lint} or a relative ./*.js path within the payload. Anything else (node:fs, express, @anywhere-else/*) is rejected with a structured error.
It also covers identifier references – free identifiers like process, require, fetch, eval, Buffer, setTimeout, XMLHttpRequest are denied as references. A snippet that writes process.exit(0) or await fetch("...") gets rejected before bundling starts.
The interesting part is what the validator does about property-access patterns. The naive denylist – "reject identifier process" – misses every chain that reaches process indirectly:
// All of these get to `process` without typing it as a free identifier:
globalThis.process.exit(0);
globalThis["process"].exit(0);
Reflect.get(globalThis, "process").exit(0);
({}).constructor.constructor("return process")();
So the validator does extra passes:
- Property access on a known global is checked.
globalThis.process,globalThis.fetch,globalThis.Buffer– any property access whose.namematches the deny-list is rejected. .constructoraccess on any value is denied. That closes the({}).constructor.constructor("...")()Function-constructor smuggle chain. There's no legitimate Directive use of.constructor.globalThis["X"]with a string literal is rejected – even when X is in the allowlist. There's no reason to reach allowlisted names via bracket syntax; the only reason a snippet would write that is to bypass the dot-access check.Reflect.get(globalThis, "X")and friends are rejected when X is a denied name.Function(...)as a call expression is denied (in addition tonew Function(...)). The Function constructor is a string-to-code escape hatch.
The validator's regression suite has a test for each of these chains. If you can think of one that's missing, that's a real bug – file it on the issue tracker.
Layer 2: esbuild bundler with absolute file URLs
Once the source passes validation, it gets virtualized into a single ESM string via esbuild. Every file in the payload is registered with an in-memory plugin; relative imports resolve against the in-memory map. The bundler handles TypeScript syntax, top-level await (the runner uses await system.settle()), and ESM linking in one pass.
The trick is what we do with @directive-run/* imports. The bundler's onResolve hook intercepts every @directive-run/* specifier and rewrites it to an absolute file:// URL of the host's resolved node_modules path:
function resolveDirectivePackageToFileUrl(specifier: string): string | null {
const require = createRequire(import.meta.url);
const resolved = require.resolve(specifier);
return pathToFileURL(resolved).href;
}
Why? Because the worker imports the bundle from /tmp/.../bundle.mjs, and Node's ESM loader can't resolve bare specifiers from /tmp – there's no node_modules chain to walk up to. By rewriting import { createSystem } from "@directive-run/core" to import { createSystem } from "file:///path/to/node_modules/@directive-run/core/dist/index.js" at bundle time, the worker can import the bundle from anywhere on disk.
This is the move that makes the sandbox work on Vercel, AWS Lambda, Cloud Run, and any other deploy target with a read-only filesystem outside /tmp.
Layer 3: worker_threads with resourceLimits
The bundle gets written to a fresh temp directory and imported in a worker_threads.Worker with bounded resources:
const worker = new Worker(workerPath, {
resourceLimits: {
maxOldGenerationSizeMb: 32,
maxYoungGenerationSizeMb: 8,
codeRangeSizeMb: 16,
},
stderr: false,
});
The host races the worker's message event against a 5-second wall-clock timer (clamped to [100ms, 10s]). On timeout, worker.terminate() hard-kills the worker. On error, the worker's error listener catches and surfaces the message. On success, the worker posts back the captured transcript.
Workers are not pooled. Each call spins a fresh worker, runs the bundle once, and tears down. That's the simplest possible isolation model – there's no carry-over state between calls, no leaked timers, no console patches that survive across requests. Cold-start cost is about 5 ms per worker, which is negligible compared to the 50–200 ms a typical Directive demo spends in system.settle().
The temp directory cleanup runs in finally, so even an unhandled rejection or a host-side throw doesn't leak disk.
Defense in depth: the outbound fetch wrapper
worker_threads resource limits are V8 heap only. They don't restrict filesystem or network access – the worker shares the parent's network stack. A snippet that imported @directive-run/query and constructed a query against http://169.254.169.254 (the AWS/GCP/Azure metadata endpoint) would hit the IMDS service from inside the worker.
The validator can't catch this. @directive-run/query is in the allowlist; its internal fetch calls live in the package's own module body, which the validator never sees.
So the worker installs an outbound fetch wrapper before the bundle imports anything:
export function installFetchWrapper(): void {
const original = globalThis.fetch ?? (() => {
throw new Error("fetch is not available");
});
globalThis.fetch = async (input, init) => {
const url = typeof input === "string"
? input
: input instanceof URL ? input.toString() : input.url;
const decision = checkSandboxFetchUrl(url);
if (!decision.allow) {
throw new Error(`sandbox: outbound fetch blocked – ${decision.reason}`);
}
return original(input, init);
};
}
The checkSandboxFetchUrl function rejects:
- Loopback:
127.0.0.0/8,::1,localhost,*.local,*.internal. - Link-local:
169.254.0.0/16– includes AWS / GCP / Azure metadata endpoints. - RFC-1918 private:
10/8,172.16-31/12,192.168/16. - Multicast and reserved:
224.0.0.0/4,240.0.0.0/4. - Carrier-grade NAT:
100.64.0.0/10. - IPv4-mapped IPv6 in both literal form (
::ffff:169.254.169.254) AND hex form (::ffff:a9fe:a9fe) – Node's URL parser normalizes between them. - Non-HTTP(S) protocols:
file:,ftp:,data:,javascript:.
Public addresses pass through. The wrapper has unit tests for every range; if you can craft a private-IP encoding that gets past it, that's a security bug – please file it.
Capturing observed behavior
Three things come back in the SandboxResult:
interface SandboxResult {
logs: string[];
facts: Record<string, unknown>;
derived: Record<string, unknown>;
errors: string[];
durationMs: number;
timedOut: boolean;
}
Capturing logs
The worker patches console.log, console.info, console.warn, console.error, console.debug to push their stringified arguments into a buffer:
console.log = (...args) => {
buffer.push("[log] " + args.map(serializeArg).join(" "));
};
The interesting part is serializeArg. Naive JSON.stringify doesn't work on Directive's facts – system.facts is a Proxy over an internal FactsStore, and JSON.stringify(proxy) returns "{}". So the serializer detects the proxy via the $store.toObject() and $snapshot() escape hatches that the proxy DOES expose:
function serializeArg(arg: unknown): string {
if (typeof arg === "string") return arg;
if (arg && typeof arg === "object") {
if (arg.$store?.toObject) {
return JSON.stringify(arg.$store.toObject());
}
if (typeof arg.$snapshot === "function") {
return JSON.stringify(arg.$snapshot());
}
}
try { return JSON.stringify(arg); }
catch { return String(arg); }
}
Now console.log("[start]", system.facts) produces [log] [start] {"count":0} instead of [log] [start] {}.
Capturing facts
After the bundle finishes (or throws), the worker reads from a side-channel global. The bundler injects an "early capture" assignment immediately after the runner's createSystem(...) call:
// Bundler-injected, runs immediately after createSystem returns:
(globalThis).__directiveSandbox_system__ = system;
The worker reads system.facts.$store.toObject() after the bundle completes. The early capture means even when the runner throws mid-execution (e.g., an invalid event payload trips a runtime error inside await system.settle()), the worker still has a system reference and can snapshot whatever state existed at the point of the throw. The transcript reports the error AND the post-mortem facts – which is much more useful than reporting just the error.
Capturing derivations
Directive derivations live in a separate registry from facts. system.derive is a Proxy with a get trap but no ownKeys trap – we can read system.derive.foo if we know the name, but we can't enumerate.
So the host pre-extracts derivation key names from the source files before bundling, using a brace-balanced scanner that handles both multi-line and compact forms:
// multi-line:
derive: {
isReady: (facts) => facts.status === "ready",
total: (facts) => facts.items.length,
},
// compact (the case our regex caught after the naive line scanner missed it):
derive: { isReady: (facts) => facts.status === "ready" },
The extracted keys are passed to the worker via the input message; the worker iterates and reads system.derive[key] for each, packing the values into result.derived. Now a module whose primary product is a derivation (the typical status, isReady, total pattern) reports the computed value, not just the underlying facts.
Deployment patterns that work
The sandbox is exposed two ways. Both lessons generalize.
As an MCP tool
@directive-run/mcp registers run_in_sandbox as a tool. AI clients (Claude Desktop, Cursor, Cline) call it via the Model Context Protocol over stdio or SSE.
The MCP wrapper layer adds input bounds (200 KB payload, 10 files max, regex-validated file paths) and serializes the response with structured error codes the LLM can branch on. The response includes a playgroundUrl built from the same input, so the LLM can hand the user a click-through to edit in StackBlitz if the transcript is interesting.
As a Next.js API route
The directive-docs Next.js app exposes /api/sandbox as a POST endpoint. The route runs in Node.js runtime (not Edge – the sandbox needs worker_threads), wraps runInSandbox, and returns the result as JSON.
Two things make this work on Vercel:
outputFileTracingIncludes. The sandbox loads its worker via dynamic createRequire().resolve("@directive-run/sandbox/worker"). Next.js's build-time import tracer can't see that, so the worker file doesn't get included in the function bundle by default. The fix is one entry in next.config.mjs:
outputFileTracingIncludes: {
"/api/sandbox": [
"../../node_modules/.pnpm/@directive-run+sandbox@*/node_modules/@directive-run/sandbox/dist/worker.js",
"./node_modules/@directive-run/sandbox/dist/worker.js",
],
}
serverExternalPackages. esbuild and ts-morph ship .d.ts files at runtime that webpack tries to parse as JavaScript and chokes on. Externalizing them keeps the function bundle pulling them straight from node_modules at runtime, the same way the local dev server does.
serverExternalPackages: ["@directive-run/sandbox", "esbuild", "ts-morph"],
The route itself is short – validate the body, check the Origin allowlist, check the per-IP rate limit, dispatch to runInSandbox, return the result:
export async function POST(request: Request): Promise<NextResponse> {
const origin = checkOrigin(request);
if (!origin.allow) return NextResponse.json({ error: origin.reason }, { status: 403 });
const ip = resolveClientIp(request);
const rate = checkRateLimit(ip);
if (!rate.allow) return NextResponse.json({ error: rate.reason }, { status: 429 });
try {
const body = validateBody(await request.json());
const result = await runInSandbox(body);
return NextResponse.json(result);
} finally {
releaseInFlight(ip);
}
}
The per-IP rate limit (10 requests / 60 seconds, 3 concurrent in-flight max) is an in-memory Map for v1. It's stateless across Vercel function instances, which means a single attacker landing on N instances can get N times the budget – not ideal at scale, but enough to defeat the trivial single-attacker DoS case. Upstash KV is the upgrade path when traffic justifies the dependency.
Cost model
For one realistic Directive snippet (a counter module with three event dispatches and a derivation), end-to-end timing on a Vercel Node 20 function:
- Cold start (first call after a function instance boot) – 300–800 ms, dominated by
ts-morphandesbuildloading lazily on first import. - Warm path – about 50–70 ms per call: validator ~10 ms, bundler ~20 ms, worker spawn ~5 ms, settle ~10 ms, transcript serialization ~5 ms.
- Memory – workers are not pooled, so memory is bounded by
resourceLimits(32 MB heap) per call and reclaimed immediately on terminate.
If you're driving high-QPS traffic, the package isn't the right fit. It's tuned for interactive use (an LLM round-trip, a playground click) and CI gates (a batch of docs examples). For sustained 100+ QPS, you'd want a different architecture – probably a worker pool with carefully-cleared globals between calls, plus a request queue.
The npm package
@directive-run/sandbox is on npm. The public surface is one function:
import { runInSandbox } from "@directive-run/sandbox";
const result = await runInSandbox({
files: [
{ path: "src/counter.ts", source: moduleCode },
{ path: "src/main.ts", source: runnerCode },
],
timeoutMs: 5000,
});
For already-runnable snippets (the kind get_example or fix_code returns), pass { source: "..." } instead; it's mapped onto src/main.ts internally.
Install:
pnpm add @directive-run/sandbox
esbuild and ts-morph are optional peer dependencies – the package treats them as optionalDependencies so an install in an environment that already vendors them doesn't double-pay. If your runtime doesn't have them, install alongside:
pnpm add @directive-run/sandbox esbuild ts-morph
When NOT to use this
Three cases where you should NOT reach for @directive-run/sandbox:
You trust the input. If you're running code you wrote, just run it. The sandbox adds 50–100 ms of overhead and bounds you can't escape. For your own code,
node script.tsis faster and unrestricted.You need real network access. The fetch wrapper denies private ranges by design. If your snippet legitimately needs to call internal services, this isn't the right tool. Wrap the snippet in a privileged code path or run it in a container with its own network policy.
You're driving sustained high-QPS traffic. The unpooled worker model is great for interactive use and CI – it's the wrong shape for a queue worker handling 100 req/sec. For that workload, fork the package, add pooling with careful between-call cleanup, and live with the resulting complexity.
For everything else – AI assistants that need to grade their own code, hosted playgrounds, teaching tools, CI gates that validate docs examples actually settle, copilots that show users what their code did – the sandbox is the shape we landed on after a lot of iteration. It works.
Try it
- Web playground: directive.run/playground – paste a snippet, click Run.
- MCP tool: Install
@directive-run/mcpin Claude Desktop, Cursor, or any MCP client. - npm package:
@directive-run/sandbox. - Source: github.com/directive-run/directive – the whole monorepo, MIT/Apache dual-licensed.
If you build something on top of this – a CI gate, a teaching tool, a hosted playground, your own copilot integration – we'd love to see it. File an issue on the repo or message us; we read everything.
Related reading:
- Sandbox documentation – API reference + threat model.
- AI That Actually Runs Your Code – why we built this in the first place.
- Playground – the live UI.
Directive is free and open source. If this was useful, consider supporting the project.

