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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.

Expand Down Expand Up @@ -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<number>(
"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
Expand All @@ -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.
10 changes: 8 additions & 2 deletions src/contract.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,7 +14,8 @@ export type RunResult<T> =
| { 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<T> = (signal: AbortSignal) => T | Promise<T>;

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
228 changes: 228 additions & 0 deletions src/worker.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<T> {
timeoutMs: number;
assert: Assertion<T>;
/** Structured-cloneable capabilities only; live functions can't cross the thread boundary. */
grant?: Readonly<Record<string, unknown>>;
/** 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<T>(
code: string,
opts: WorkerRunOptions<T>,
): Promise<RunResult<T>> {
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<RunResult<T>>((resolve) => {
let settled = false;
const finish = (result: RunResult<T>) => {
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;
}
Loading
Loading