Package Exports
- @directive-run/sandbox
- @directive-run/sandbox/worker
Readme
@directive-run/sandbox
Execute Directive snippets server-side and return a structured transcript (logs, facts, errors). Used by @directive-run/mcp's run_in_sandbox tool and by directive.run/playground's live DevTools panel.
What it does
Takes a TypeScript snippet (single source string OR a paired library + runner files: [{path, source}] array — same shape playground_link accepts), validates it against an AST allowlist, bundles the multi-file payload via esbuild, and executes the result in a bounded worker_threads sandbox. Returns:
interface SandboxResult {
logs: string[]; // captured console.log/warn/error lines
facts: Record<string, unknown>; // system.facts.$store.toObject() snapshot
errors: string[]; // structured error messages
durationMs: number;
timedOut: boolean;
}The bundler injects an early-capture immediately after createSystem(...), so the post-mortem snapshot survives mid-runner errors — a validation throw inside await system.settle() still hands you the init-state facts.
Sandbox boundary
Three layers:
- AST allowlist validator (
ts-morph). Imports are restricted to a curated@directive-run/*set + relative./*.js:- Allowed:
core,ai,query,react,vue,svelte,solid,lit,el,optimistic,timeline,mutator,knowledge,scaffold,claude-plugin,lint,sources(17 packages — anything an end-user demo realistically composes from; the canonical, drift-proof list isALLOWED_DIRECTIVE_PACKAGESinsrc/validator.ts). - Denied:
cli,mcp,sandbox,vite-plugin-api-proxy(build / CLI / sandbox-meta tooling — no legitimate use inside a sandboxed demo). - Everything else (
node:fs,express,@sizls/*, etc.) is rejected. - Identifier references to FS / network / eval surfaces (
process,require,fetch,fs,child_process,eval,new Function,setTimeout,Buffer, etc.) are rejected as free identifiers. - Property-access bypass chains are rejected (v0.3.0):
globalThis.process,globalThis.fetch,globalThis["X"]bracket access with string literal,.constructoraccess on any value,Function(...)call withoutnew,Reflect.get/has/getOwnPropertyDescriptor(globalThis, "X")smuggle chains. The June 2026 security audit traces these PoCs and how v0.3.0 closes them.
- Allowed:
- esbuild bundler. Virtualizes the multi-file payload into a single ESM string with
@directive-run/*externalized. Throws on imports that can't be resolved against the in-memory file map. worker_threads.WorkerwithresourceLimits(32 MB heap, 16 MB code) and a clamped wall-clock budget ([100ms, 10s], default 5s). The worker is hard-terminated on timeout — no cooperative cancellation needed.
Resource limits are heap-only — workers share the parent process's FS / network access. The allowlist validator is what actually prevents sandbox escape; the worker layer adds OOM / runaway-CPU bounding on top.
API
import { runInSandbox } from "@directive-run/sandbox";
const result = await runInSandbox({
files: [
{ path: "src/counter.ts", source: moduleSource },
{ path: "src/main.ts", source: runnerSource },
],
timeoutMs: 5000,
});
console.log(result.logs); // ["[start] count= 0", "[settled] count= 2"]
console.log(result.facts); // { count: 2 }The single-source shortcut runInSandbox({ source: "..." }) maps onto src/main.ts internally — convenient for already-runnable snippets from get_example / fix_code.
Cancellation
runInSandbox accepts an optional AbortSignal. Wire it to your HTTP request's signal (Next.js, Express, and Hono all expose one) so a client that disconnects mid-flight releases its worker slot immediately. Without this, an abandoned caller still occupies the per-process worker queue until the worker times out (up to 10s) — under sustained load that drives the pool to deadlock.
// Next.js App Router — `request.signal` fires on client disconnect.
export async function POST(request: Request) {
const result = await runInSandbox({
source,
signal: request.signal,
});
return Response.json(result);
}The signal aborts the queue-wait phase AND the running worker — the slot frees the moment the signal trips, not when the wall-clock budget expires.
Operator knobs
setMaxConcurrentWorkers(n) caps the per-process worker pool. Each worker reserves ~32 MB of heap + a thread; without a cap, a burst from many distinct IPs (each within any per-IP rate limit) can each spawn their own worker and OOM the host. Calls beyond the cap queue FIFO; abandoned callers (signal-aborted or dropped promises) deregister cleanly. Defaults to navigator.hardwareConcurrency (falls back to 4); pass Infinity to disable.
import { setMaxConcurrentWorkers } from "@directive-run/sandbox";
// At module init — runs once per cold start.
setMaxConcurrentWorkers(8);sanitizeStack(stack) strips host filesystem paths from a stack trace string. The worker uses it internally before reporting errors back; consumers building custom error-routing (Sentry pipelines, audit-log middleware, structured-log adapters) can use the same helper so their own logs don't re-leak /Users/, /home/, /app/, /var/task/, Windows drive paths, or UNC paths.
import { sanitizeStack } from "@directive-run/sandbox";
logger.error({ msg: "sandbox failed", err: sanitizeStack(err.stack) });See also
@directive-run/mcp— wrapsrunInSandboxas therun_in_sandboxMCP tool, returns the transcript to the AI client alongside aplaygroundUrl.@directive-run/scaffold—generateRunnerproduces the canonical runner shape this sandbox expects to execute (createSystem→start()→ dispatch →settle()→ log).