From 2a98449ce2cfcc4335efa52611bf76e97671a64b Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Tue, 21 Jul 2026 17:36:40 +0000 Subject: [PATCH] feat: add worker_threads isolate executor with frozen globals and empty env --- README.md | 32 ++++++- src/contract.ts | 10 +- src/index.ts | 2 + src/worker.ts | 228 ++++++++++++++++++++++++++++++++++++++++++++ test/worker.test.ts | 184 +++++++++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 src/worker.ts create mode 100644 test/worker.test.ts diff --git a/README.md b/README.md index c0aa2e3..20bf250 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Ephemeral, zero-credential, self-verifying execution for untrusted or agent-writ An airlock is the safe way to run code you do not trust: an LLM-generated snippet, a plugin, a user-submitted function. This repo builds that primitive from the ground up in TypeScript. The guarantee is that a caller never reads an output unless the run stayed inside its deadline and its output satisfies a post-condition the caller supplied. Untrusted code is guilty until proven correct, and the type system makes you prove it before you can touch the value. -The first slice was the contract and the in-process runner that enforces it. The second slice, here, adds `run(code, opts)`: it takes untrusted source as a string, executes it in a fresh V8 context with no ambient authority, and gates the output through the same deadline and post-condition contract. Later slices harden the sandbox further: an [`isolated-vm`](https://github.com/laverdet/isolated-vm) tier with a hard memory cap, a Docker-backed tier for stronger isolation, and a growing suite of documented escape-attempt tests. +The first slice was the contract and the in-process runner that enforces it. The second slice added `run(code, opts)`, which executes untrusted source in a fresh `node:vm` context with no ambient authority. The third slice, here, adds `runInWorker(code, opts)`: the same contract, but the code runs in a `worker_threads` isolate. That is a separate V8 heap on a separate OS thread, started with an empty `process.env` and frozen globals. It closes the in-process gap where a constructor walk reaches the host realm, because an escape now lands in the worker's own realm, which carries no host credentials and can be hard-killed. Later slices add a Docker-backed tier for stronger isolation and a growing suite of documented escape-attempt tests. ## Concepts demonstrated @@ -17,6 +17,9 @@ The first slice was the contract and the in-process runner that enforces it. The - **Capability-security framing.** The task receives only the abort capability it needs; `run` extends this to source code, which sees zero ambient authority and only the capabilities passed in the `grant` object. - **Zero-credential invariant.** Untrusted source runs in a `node:vm` context that carries none of the host's authority: no `process`, `process.env`, `require`, `fetch`, timers, or `Buffer`. The invariant is a named denylist, probed at context-build time so a run fails closed if authority ever leaks in. - **Realm isolation and its limits.** The context has its own set of ECMAScript intrinsics, so a host secret on `globalThis` is unreachable and the sandbox's own `Function` compiles in-realm. The known in-process gap, `this.constructor.constructor` reaching the host realm through the borrowed global prototype, is pinned by an escape-attempt test rather than hidden. +- **Thread-level isolation with `worker_threads`.** `runInWorker` runs untrusted source in a dedicated V8 isolate on its own thread. The in-process constructor-walk escape reaches only the worker's realm, which is started with an empty `process.env` and cannot see the host's environment. An escape-attempt test walks the same constructor chain and confirms a host secret placed in `process.env` stays out of reach. +- **Frozen realm hardening.** Before any untrusted code runs, the worker freezes `globalThis` and the core intrinsics and their prototypes, so an escape into the worker realm cannot repave shared state that later runs in the same isolate would rely on. +- **Hard preemption and resource limits.** A synchronous spin, an async task that never settles, and a heap that outgrows `maxOldGenerationSizeMb` are all terminal: the first two are killed by terminating the thread on the deadline, and the third is reported as `out-of-memory` by V8's resource limits. In-process `run` can only abandon a hung async task; the worker actually reclaims the thread. - **Layered preemption.** A synchronous spin is killed by V8's `timeout`; an async task that never settles is aborted by the deadline race. `run` composes both so neither class of runaway can wedge the caller. - **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`. @@ -81,6 +84,32 @@ await run("while (true) {}", { timeoutMs: 25, assert: () => true }); // -> { status: "timeout", timeoutMs: 25 } ``` +For stronger isolation, `runInWorker` runs the same source in a `worker_threads` isolate: a separate V8 heap and thread with an empty `process.env`, frozen globals, and an optional heap cap. The `grant` and the returned value cross by structured clone, so pass data rather than live functions. + +```ts +import { runInWorker, isVerified } from "airlock"; + +const result = await runInWorker( + "rows.reduce((sum, r) => sum + r.n, 0)", + { + timeoutMs: 500, + assert: (total) => total === 6, + grant: { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] }, + maxOldGenerationSizeMb: 32, + }, +); + +if (isVerified(result)) console.log("verified:", result.value); // 6 + +// a runaway allocation is capped and reported instead of taking the host down +await runInWorker("const a = []; while (true) a.push(new Array(1e6));", { + timeoutMs: 10_000, + assert: () => true, + maxOldGenerationSizeMb: 16, +}); +// -> { status: "out-of-memory", maxOldGenerationSizeMb: 16 } +``` + ## Develop ```bash @@ -94,3 +123,4 @@ pnpm run build - Scaffold: pnpm + strict TypeScript, tsup build, vitest, CI, and the `runVerified` primitive contract (deadline + post-condition gating over a discriminated-union result). - `src/sandbox.ts`: `run(code, opts)` executes untrusted source in a zero-credential `node:vm` context (no `process`/`require`/`fetch`/timers), grants only what the caller passes, preempts synchronous spins via V8's timeout, and fails closed with a probed `ZeroCredentialViolation` if ambient authority leaks in. Includes documented escape-attempt tests. +- `src/worker.ts`: `runInWorker(code, opts)` runs untrusted source in a `worker_threads` isolate started with an empty `process.env` and frozen globals, caps the heap with `maxOldGenerationSizeMb` (reported as `out-of-memory`), and hard-kills the thread on the deadline so a sync spin and a never-settling async task are both preempted. An escape-attempt test confirms the constructor walk that reaches the host realm in-process reaches only the credential-free worker realm here. diff --git a/src/contract.ts b/src/contract.ts index 23400d4..49d9c8e 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -1,4 +1,9 @@ -export type RunStatus = "ok" | "timeout" | "assertion-failed" | "error"; +export type RunStatus = + | "ok" + | "timeout" + | "assertion-failed" + | "error" + | "out-of-memory"; /** * A run only counts as verified when it carries `status: "ok"`. Every other @@ -9,7 +14,8 @@ export type RunResult = | { status: "ok"; value: T; durationMs: number } | { status: "timeout"; timeoutMs: number } | { status: "assertion-failed"; value: T } - | { status: "error"; error: unknown }; + | { status: "error"; error: unknown } + | { status: "out-of-memory"; maxOldGenerationSizeMb: number }; export type Task = (signal: AbortSignal) => T | Promise; diff --git a/src/index.ts b/src/index.ts index 2521f1a..b70c8b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ export { DENIED_AMBIENT_NAMES, } from "./sandbox.js"; export type { SandboxRunOptions } from "./sandbox.js"; +export { runInWorker, freezeRealm, FROZEN_INTRINSICS } from "./worker.js"; +export type { WorkerRunOptions } from "./worker.js"; export type { Assertion, RunResult, diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..3616ebe --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,228 @@ +import { Worker } from "node:worker_threads"; +import type { Assertion, RunResult } from "./contract.js"; + +/** + * Intrinsics whose prototypes a sandbox escape could otherwise repave to attack + * later runs sharing the isolate. Frozen in the worker realm before any + * untrusted code runs, so an escape lands in a realm it cannot mutate. + */ +export const FROZEN_INTRINSICS: readonly string[] = [ + "Object", + "Function", + "Array", + "String", + "Number", + "Boolean", + "Symbol", + "BigInt", + "Error", + "Promise", + "RegExp", + "Date", + "Map", + "Set", + "WeakMap", + "WeakSet", + "JSON", + "Math", + "Reflect", +]; + +/** Freeze each named intrinsic, its prototype, and the realm root itself. */ +export function freezeRealm( + root: Record, + names: readonly string[], +): void { + for (const name of names) { + const intrinsic = root[name]; + if ( + typeof intrinsic === "function" || + (typeof intrinsic === "object" && intrinsic !== null) + ) { + Object.freeze(intrinsic); + const proto = (intrinsic as { prototype?: unknown }).prototype; + if (proto) Object.freeze(proto); + } + } + Object.freeze(root); +} + +export interface WorkerRunOptions { + timeoutMs: number; + assert: Assertion; + /** Structured-cloneable capabilities only; live functions can't cross the thread boundary. */ + grant?: Readonly>; + /** Hard cap on the isolate's V8 old-space. Exceeding it kills the worker. */ + maxOldGenerationSizeMb?: number; + signal?: AbortSignal; + filename?: string; +} + +interface WorkerOk { + ok: true; + value: unknown; +} +interface WorkerErr { + ok: false; + error: { name?: string; message?: string; stack?: string; code?: string }; +} +type WorkerMessage = WorkerOk | WorkerErr; + +const SYNC_TIMEOUT_CODE = "ERR_SCRIPT_EXECUTION_TIMEOUT"; +const OOM_CODE = "ERR_WORKER_OUT_OF_MEMORY"; + +// The worker body is a string so a single build artifact ships without a +// separate worker entry file, and so tests exercise the same code as dist. +// freezeRealm is injected by source and applied to the worker's own globals. +const BOOTSTRAP = ` +'use strict'; +const { workerData, parentPort } = require('node:worker_threads'); +const vm = require('node:vm'); + +(${freezeRealm.toString()})(globalThis, ${JSON.stringify(FROZEN_INTRINSICS)}); + +(async () => { + try { + const { code, grant, timeoutMs, filename } = workerData; + const context = vm.createContext({ ...(grant || {}) }); + const script = new vm.Script(code, { filename }); + const value = await script.runInContext(context, { timeout: timeoutMs }); + parentPort.postMessage({ ok: true, value }); + } catch (error) { + parentPort.postMessage({ + ok: false, + error: { + name: error && error.name, + message: error && error.message, + stack: error && error.stack, + code: error && error.code, + }, + }); + } +})(); +`; + +/** + * Run untrusted source in a worker_threads isolate: a separate V8 heap on a + * separate OS thread, started with an empty `process.env` and frozen globals, + * then gated through the deadline and post-condition contract. + * + * This is the stronger sibling of the in-process {@link run}. It closes the + * pinned in-process gap where `this.constructor.constructor` reaches the host + * realm: here an escape from the `vm` context reaches only the WORKER's realm, + * whose `process.env` is empty and whose globals are frozen. The thread is + * hard-killed on the deadline, so a synchronous spin AND an async task that + * never settles are both preempted, not merely abandoned. A caller-supplied + * `maxOldGenerationSizeMb` caps the heap and reports `out-of-memory` when hit. + * + * The tradeoff for real thread isolation: the `grant` and the returned value + * cross by structured clone, so live function capabilities can't be handed in + * and non-cloneable outputs come back as an `error`. + */ +export function runInWorker( + code: string, + opts: WorkerRunOptions, +): Promise> { + const { timeoutMs, assert, grant, maxOldGenerationSizeMb, signal, filename } = + opts; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError("timeoutMs must be a positive, finite number"); + } + + let worker: Worker; + try { + worker = new Worker(BOOTSTRAP, { + eval: true, + env: {}, + workerData: { + code, + grant: grant ?? {}, + timeoutMs, + filename: filename ?? "airlock-worker.js", + }, + ...(maxOldGenerationSizeMb !== undefined + ? { resourceLimits: { maxOldGenerationSizeMb } } + : {}), + }); + } catch (error) { + // A non-cloneable grant (e.g. a function) fails at construction. + return Promise.resolve({ status: "error", error }); + } + + const started = performance.now(); + + return new Promise>((resolve) => { + let settled = false; + const finish = (result: RunResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + void worker.terminate(); + resolve(result); + }; + + const timer = setTimeout(() => { + finish({ status: "timeout", timeoutMs }); + }, timeoutMs); + + const onAbort = () => { + finish({ status: "error", error: signal?.reason }); + }; + if (signal) { + if (signal.aborted) return onAbort(); + signal.addEventListener("abort", onAbort, { once: true }); + } + + worker.on("message", (msg: WorkerMessage) => { + if (settled) return; + if (msg.ok) { + clearTimeout(timer); + const value = msg.value as T; + void Promise.resolve(assert(value)).then( + (passed) => + finish( + passed + ? { status: "ok", value, durationMs: performance.now() - started } + : { status: "assertion-failed", value }, + ), + (error) => finish({ status: "error", error }), + ); + return; + } + if (msg.error.code === SYNC_TIMEOUT_CODE) { + finish({ status: "timeout", timeoutMs }); + return; + } + finish({ status: "error", error: reviveError(msg.error) }); + }); + + worker.on("error", (error: Error & { code?: string }) => { + if (error.code === OOM_CODE) { + finish({ + status: "out-of-memory", + maxOldGenerationSizeMb: maxOldGenerationSizeMb ?? 0, + }); + return; + } + finish({ status: "error", error }); + }); + + worker.on("exit", (code) => { + if (code !== 0) { + finish({ + status: "error", + error: new Error(`worker exited with code ${code}`), + }); + } + }); + }); +} + +function reviveError(shape: WorkerErr["error"]): Error { + const error = new Error(shape.message ?? "worker error"); + if (shape.name) error.name = shape.name; + if (shape.stack) error.stack = shape.stack; + if (shape.code) (error as Error & { code?: string }).code = shape.code; + return error; +} diff --git a/test/worker.test.ts b/test/worker.test.ts new file mode 100644 index 0000000..a3dfa74 --- /dev/null +++ b/test/worker.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + FROZEN_INTRINSICS, + freezeRealm, + isVerified, + runInWorker, +} from "../src/index.js"; + +describe("runInWorker", () => { + it("evaluates untrusted source in the isolate and returns the verified value", async () => { + const result = await runInWorker("40 + 2", { + timeoutMs: 1000, + assert: (v) => v === 42, + }); + + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(result.value).toBe(42); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + } + }); + + it("refuses the value when the post-condition fails", async () => { + const result = await runInWorker("41", { + timeoutMs: 1000, + assert: (v) => v === 42, + }); + + expect(result).toEqual({ status: "assertion-failed", value: 41 }); + expect(isVerified(result)).toBe(false); + }); + + it("awaits a promise the code evaluates to", async () => { + const result = await runInWorker("Promise.resolve('hi')", { + timeoutMs: 1000, + assert: (v) => v === "hi", + }); + + expect(result).toMatchObject({ status: "ok", value: "hi" }); + }); + + it("passes structured-cloneable capabilities through grant", async () => { + const result = await runInWorker("rows.length + base", { + timeoutMs: 1000, + assert: (v) => v === 5, + grant: { rows: [1, 2, 3], base: 2 }, + }); + + expect(result).toMatchObject({ status: "ok", value: 5 }); + }); + + it("returns an error when a grant is not structured-cloneable", async () => { + const result = await runInWorker("add(1, 2)", { + timeoutMs: 1000, + assert: () => true, + grant: { add: (a: number, b: number) => a + b }, + }); + + expect(result.status).toBe("error"); + }); + + it("captures a runtime throw as an error result", async () => { + const result = await runInWorker("throw new Error('boom')", { + timeoutMs: 1000, + assert: () => true, + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect((result.error as Error).message).toBe("boom"); + } + }); + + it("hard-kills a synchronous infinite loop as a timeout", async () => { + const result = await runInWorker("while (true) {}", { + timeoutMs: 100, + assert: () => true, + }); + + expect(result).toEqual({ status: "timeout", timeoutMs: 100 }); + }); + + it("times out on an async task that never settles", async () => { + const result = await runInWorker("new Promise(() => {})", { + timeoutMs: 100, + assert: () => true, + }); + + expect(result).toEqual({ status: "timeout", timeoutMs: 100 }); + }); + + it("aborts when the caller's signal fires", async () => { + const controller = new AbortController(); + const reason = new Error("caller cancelled"); + const pending = runInWorker("new Promise(() => {})", { + timeoutMs: 5000, + assert: () => true, + signal: controller.signal, + }); + controller.abort(reason); + + const result = await pending; + expect(result).toEqual({ status: "error", error: reason }); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects a non-positive or non-finite timeout: %s", + (bad) => { + expect(() => + runInWorker("1", { timeoutMs: bad, assert: () => true }), + ).toThrow(RangeError); + }, + ); + + it("enforces a heap cap and reports out-of-memory", async () => { + const result = await runInWorker( + "const acc = []; while (true) { acc.push(new Array(1_000_000).fill(0)); }", + { timeoutMs: 10_000, assert: () => true, maxOldGenerationSizeMb: 16 }, + ); + + expect(result.status).toBe("out-of-memory"); + if (result.status === "out-of-memory") { + expect(result.maxOldGenerationSizeMb).toBe(16); + } + }, 15_000); +}); + +describe("worker zero-credential invariant", () => { + it.each(["process", "require", "fetch", "Buffer", "setTimeout"])( + "denies ambient access to %s inside the vm context", + async (name) => { + const result = await runInWorker(`typeof ${name}`, { + timeoutMs: 1000, + assert: (v) => v === "undefined", + }); + + expect(result).toMatchObject({ status: "ok", value: "undefined" }); + }, + ); + + it("starts the isolate with an empty process.env, even for a host secret", async () => { + const key = "AIRLOCK_HOST_SECRET"; + process.env[key] = "super-secret-token"; + try { + // Reach the worker realm's process via the constructor walk that escapes + // the vm context, then read env. The host secret must not be there. + const result = await runInWorker( + `this.constructor.constructor("return typeof process.env.${key}")()`, + { timeoutMs: 1000, assert: (v) => v === "undefined" }, + ); + + expect(result).toMatchObject({ status: "ok", value: "undefined" }); + } finally { + delete process.env[key]; + } + }); +}); + +describe("freezeRealm", () => { + it("freezes each named intrinsic, its prototype, and the root", () => { + const proto = {}; + const fake = () => {}; + (fake as { prototype?: unknown }).prototype = proto; + const root: Record = { Object: fake, ignored: 1 }; + + freezeRealm(root, ["Object"]); + + expect(Object.isFrozen(root)).toBe(true); + expect(Object.isFrozen(fake)).toBe(true); + expect(Object.isFrozen(proto)).toBe(true); + }); + + it("skips names absent from the realm without throwing", () => { + const root: Record = {}; + expect(() => freezeRealm(root, ["Nonexistent"])).not.toThrow(); + expect(Object.isFrozen(root)).toBe(true); + }); + + it("covers the core intrinsics used by untrusted code", () => { + expect(FROZEN_INTRINSICS).toContain("Object"); + expect(FROZEN_INTRINSICS).toContain("Function"); + expect(FROZEN_INTRINSICS).toContain("Array"); + }); +});