Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,36 @@ Ephemeral, zero-credential, self-verifying execution for untrusted or agent-writ

## What this demonstrates

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.
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 resource ceilings 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 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.
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 added `runInWorker(code, opts)`: the same contract on a `worker_threads` isolate with frozen globals and an empty env. This slice hardens the **resource limit** layer: wall-clock deadline with hard terminate on abort, V8 heap cap, and output size caps, so a runaway cannot wedge the host on time, memory, or a multi-megabyte return value. Later slices add a Docker-backed tier and a growing suite of documented escape-attempt tests.

## Concepts demonstrated

- **Verification-gated results.** The output value is reachable only through the `ok` variant of a discriminated union, so an unverified run is unrepresentable at the call site.
- **Post-condition contracts.** A run is trusted when a caller-supplied assertion holds over its output, a design-by-contract style check applied to untrusted code.
- **Deadline enforcement with cooperative cancellation.** An internal timer races the task and aborts the `AbortSignal` it runs under, composed with any caller-owned signal.
- **Total error handling.** Thrown errors, blown deadlines, and failed assertions are all values in the result union rather than exceptions, so no failure mode escapes as a rejection.
- **Hard preemption via worker termination.** On the isolate tier, the wall-clock deadline and caller abort both call `worker.terminate()`, reclaiming the OS thread instead of abandoning a hung task.
- **Resource isolation and ceilings.** Three independent budgets gate every run: wall-clock time, V8 old-generation heap (`maxOldGenerationSizeMb`), and measured UTF-8 output size (`maxOutputBytes`). Each maps to a distinct result status (`timeout`, `out-of-memory`, `output-too-large`).
- **Budgeted output metering.** Output size is walked with an early-exit budget so a cyclic or enormous structure cannot hang the host meter, and oversized values never reach the post-condition as trusted results.
- **Total error handling.** Thrown errors, blown deadlines, failed assertions, OOM, and oversized output are all values in the result union rather than exceptions, so no failure mode escapes as a rejection.
- **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.
- **Layered preemption.** A synchronous spin is killed by V8's `timeout`; an async task that never settles is aborted by the deadline race (and terminated on the worker tier). `run` composes both so neither class of runaway can wedge the caller.
- **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`.

## The primitive contract

```
runVerified(task, { timeoutMs, assert, signal? }) -> RunResult
runVerified(task, { timeoutMs, assert, signal?, maxOutputBytes? }) -> RunResult
```

- The value is returned **only** as `{ status: "ok", value, durationMs }`, and only when the task finished before `timeoutMs` and `assert(value)` returned true.
- Every other outcome is an explicit refusal: `timeout`, `assertion-failed` (carries the value for diagnostics, never as trusted), or `error`.
- The task is handed an `AbortSignal` that fires on the deadline or on the caller's own signal, so well-behaved async work can stop early.
- The value is returned **only** as `{ status: "ok", value, durationMs }`, and only when the task finished before `timeoutMs`, the payload stayed under `maxOutputBytes` when set, and `assert(value)` returned true.
- Every other outcome is an explicit refusal: `timeout`, `assertion-failed` (carries the value for diagnostics, never as trusted), `output-too-large`, `out-of-memory`, or `error`.
- The task is handed an `AbortSignal` that fires on the deadline or on the caller's own signal, so well-behaved async work can stop early. On the worker tier that same abort path also terminates the isolate.

The in-process tier cannot preempt code that blocks the event loop with a synchronous spin; that is what the isolate and container tiers are for. This tier defines the contract those tiers implement.

Expand All @@ -47,6 +49,7 @@ const result = await runVerified(
},
{
timeoutMs: 2000,
maxOutputBytes: 64 * 1024,
assert: (data) => Number.isInteger(data.total) && data.total >= 0,
},
);
Expand All @@ -67,6 +70,7 @@ const result = await run<number>(
"add(rows.length, 1)",
{
timeoutMs: 50,
maxOutputBytes: 1024,
assert: (n) => Number.isInteger(n) && n > 0,
grant: {
rows: [{ id: 1 }, { id: 2 }],
Expand All @@ -84,7 +88,7 @@ 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.
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, heap cap, and output size cap. The `grant` and the returned value cross by structured clone, so pass data rather than live functions. Deadline and caller abort both call `worker.terminate()`.

```ts
import { runInWorker, isVerified } from "airlock";
Expand All @@ -96,6 +100,7 @@ const result = await runInWorker<number>(
assert: (total) => total === 6,
grant: { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] },
maxOldGenerationSizeMb: 32,
maxOutputBytes: 4096,
},
);

Expand All @@ -108,6 +113,14 @@ await runInWorker("const a = []; while (true) a.push(new Array(1e6));", {
maxOldGenerationSizeMb: 16,
});
// -> { status: "out-of-memory", maxOldGenerationSizeMb: 16 }

// an oversized return is refused before the post-condition runs
await runInWorker("'x'.repeat(1_000_000)", {
timeoutMs: 1000,
assert: () => true,
maxOutputBytes: 1024,
});
// -> { status: "output-too-large", maxOutputBytes: 1024, actualBytes: ... }
```

## Develop
Expand All @@ -124,3 +137,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.
- `src/limits.ts`: shared resource ceilings for every tier. Wall-clock timeout aborts the task signal and, on the worker tier, calls `worker.terminate()` on both deadline and caller abort. Heap cap via V8 `resourceLimits`. Output size caps (`maxOutputBytes`) measure UTF-8 payload with a budgeted walk (cycle-safe, early-exit) and refuse with `output-too-large` before the post-condition runs.
8 changes: 6 additions & 2 deletions src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ export type RunStatus =
| "timeout"
| "assertion-failed"
| "error"
| "out-of-memory";
| "out-of-memory"
| "output-too-large";

/**
* A run only counts as verified when it carries `status: "ok"`. Every other
Expand All @@ -15,7 +16,8 @@ export type RunResult<T> =
| { status: "timeout"; timeoutMs: number }
| { status: "assertion-failed"; value: T }
| { status: "error"; error: unknown }
| { status: "out-of-memory"; maxOldGenerationSizeMb: number };
| { status: "out-of-memory"; maxOldGenerationSizeMb: number }
| { status: "output-too-large"; maxOutputBytes: number; actualBytes: number };

export type Task<T> = (signal: AbortSignal) => T | Promise<T>;

Expand All @@ -27,6 +29,8 @@ export interface VerifiedRunOptions<T> {
assert: Assertion<T>;
/** Caller-owned cancellation, merged with the internal deadline. */
signal?: AbortSignal;
/** Refuse values whose measured UTF-8 payload exceeds this many bytes. */
maxOutputBytes?: number;
}

export function isVerified<T>(
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export {
export type { SandboxRunOptions } from "./sandbox.js";
export { runInWorker, freezeRealm, FROZEN_INTRINSICS } from "./worker.js";
export type { WorkerRunOptions } from "./worker.js";
export {
validateResourceLimits,
measureOutputBytes,
checkOutputSize,
} from "./limits.js";
export type { ResourceLimits, OutputSizeCheck } from "./limits.js";
export type {
Assertion,
RunResult,
Expand Down
115 changes: 115 additions & 0 deletions src/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Resource ceilings shared by every airlock tier. Wall-clock and heap are
* enforced by the runner (timer + worker.terminate / V8 resourceLimits);
* output size is measured on the host after the value crosses the boundary,
* so a huge return cannot pass the post-condition or land as a trusted result.
*/
export interface ResourceLimits {
timeoutMs: number;
maxOldGenerationSizeMb?: number;
/** UTF-8 payload of the returned value. Exceeding refuses with output-too-large. */
maxOutputBytes?: number;
}

export interface OutputSizeCheck {
bytes: number;
exceeded: boolean;
}

export function validateResourceLimits(limits: ResourceLimits): void {
if (!Number.isFinite(limits.timeoutMs) || limits.timeoutMs <= 0) {
throw new RangeError("timeoutMs must be a positive, finite number");
}
if (limits.maxOldGenerationSizeMb !== undefined) {
if (
!Number.isInteger(limits.maxOldGenerationSizeMb) ||
limits.maxOldGenerationSizeMb <= 0
) {
throw new RangeError("maxOldGenerationSizeMb must be a positive integer");
}
}
if (limits.maxOutputBytes !== undefined) {
if (
!Number.isInteger(limits.maxOutputBytes) ||
limits.maxOutputBytes < 0
) {
throw new RangeError("maxOutputBytes must be a non-negative integer");
}
}
}

/**
* Walks a value counting UTF-8 payload bytes until `budget` is exceeded, then
* stops. Cycles contribute 0 after the first visit so a self-referential object
* cannot hang the host meter.
*/
export function measureOutputBytes(
value: unknown,
budget: number = Number.POSITIVE_INFINITY,
): OutputSizeCheck {
const seen = new WeakSet<object>();
let bytes = 0;

const add = (n: number): boolean => {
bytes += n;
return bytes > budget;
};

const walk = (v: unknown): boolean => {
if (v === null || v === undefined) return false;
switch (typeof v) {
case "boolean":
return add(1);
case "number":
case "bigint":
return add(8);
case "string":
return add(Buffer.byteLength(v, "utf8"));
case "symbol":
case "function":
return false;
case "object": {
if (seen.has(v as object)) return false;
seen.add(v as object);
if (ArrayBuffer.isView(v)) return add(v.byteLength);
if (v instanceof ArrayBuffer) return add(v.byteLength);
if (v instanceof Date) return add(8);
if (v instanceof Map) {
for (const [k, entry] of v) {
if (walk(k) || walk(entry)) return true;
}
return false;
}
if (v instanceof Set) {
for (const entry of v) {
if (walk(entry)) return true;
}
return false;
}
if (Array.isArray(v)) {
for (const entry of v) {
if (walk(entry)) return true;
}
return false;
}
for (const [key, entry] of Object.entries(v as Record<string, unknown>)) {
if (add(Buffer.byteLength(key, "utf8"))) return true;
if (walk(entry)) return true;
}
return false;
}
default:
return false;
}
};

const exceeded = walk(value);
return { bytes, exceeded };
}

export function checkOutputSize(
value: unknown,
maxOutputBytes: number,
): OutputSizeCheck {
return measureOutputBytes(value, maxOutputBytes);
}
21 changes: 17 additions & 4 deletions src/run.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RunResult, Task, VerifiedRunOptions } from "./contract.js";
import { checkOutputSize, validateResourceLimits } from "./limits.js";

const DEADLINE = Symbol("deadline");

Expand All @@ -16,10 +17,11 @@ export async function runVerified<T>(
task: Task<T>,
opts: VerifiedRunOptions<T>,
): Promise<RunResult<T>> {
const { timeoutMs, assert, signal } = opts;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError("timeoutMs must be a positive, finite number");
}
const { timeoutMs, assert, signal, maxOutputBytes } = opts;
validateResourceLimits({
timeoutMs,
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
});

const controller = new AbortController();
const relayAbort = () => controller.abort(signal?.reason);
Expand Down Expand Up @@ -49,6 +51,17 @@ export async function runVerified<T>(
}

const value = outcome as T;
if (maxOutputBytes !== undefined) {
const size = checkOutputSize(value, maxOutputBytes);
if (size.exceeded) {
return {
status: "output-too-large",
maxOutputBytes,
actualBytes: size.bytes,
};
}
}

const passed = await assert(value);
return passed
? { status: "ok", value, durationMs: performance.now() - started }
Expand Down
13 changes: 8 additions & 5 deletions src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface SandboxRunOptions<T> {
grant?: Readonly<Record<string, unknown>>;
signal?: AbortSignal;
filename?: string;
maxOutputBytes?: number;
}

export class ZeroCredentialViolation extends Error {
Expand Down Expand Up @@ -92,10 +93,7 @@ export async function run<T>(
code: string,
opts: SandboxRunOptions<T>,
): Promise<RunResult<T>> {
const { timeoutMs, assert, grant, signal, filename } = opts;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError("timeoutMs must be a positive, finite number");
}
const { timeoutMs, assert, grant, signal, filename, maxOutputBytes } = opts;

const context = vm.createContext({ ...(grant ?? {}) });
const leaked = probeAmbientAuthority(context, Object.keys(grant ?? {}));
Expand All @@ -114,7 +112,12 @@ export async function run<T>(
timeout: timeoutMs,
breakOnSigint: true,
}) as T | Promise<T>,
{ timeoutMs, assert, ...(signal ? { signal } : {}) },
{
timeoutMs,
assert,
...(signal ? { signal } : {}),
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
},
);

if (result.status === "error" && isSyncTimeout(result.error)) {
Expand Down
Loading
Loading