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
29 changes: 28 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 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 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.
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. The fourth slice hardens the **resource limit** layer: wall-clock deadline with hard terminate on abort, V8 heap cap, and output size caps. This slice adds a **deny-by-default module loader**: untrusted code has no `require` unless the caller opts in with `allowedModules`, and only the listed builtin or package ids resolve. Relative and absolute paths are always refused. Later slices add a Docker-backed tier and a growing suite of documented escape-attempt tests.

## Concepts demonstrated

Expand All @@ -23,6 +23,9 @@ The first slice was the contract and the in-process runner that enforces it. The
- **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.
- **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.
- **Deny-by-default module loading.** `require` is unbound unless the caller sets `allowedModules`. An empty list injects a gate that refuses every specifier; a non-empty list is an exact-match allowlist (with bare/`node:` equivalence), never a prefix grant.
- **Capability allowlists.** Module loading is treated as ambient authority: the host's real `require` is reachable only after the gate admits the id, so unlisted builtins like `fs` stay closed even when a sibling id is granted.
- **Path-specifier refusal.** Relative and absolute paths are dropped from the allowlist and rejected at load time so filesystem resolution cannot re-open host I/O through a crafty entry.
- **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`.

## The primitive contract
Expand Down Expand Up @@ -123,6 +126,29 @@ await runInWorker("'x'.repeat(1_000_000)", {
// -> { status: "output-too-large", maxOutputBytes: 1024, actualBytes: ... }
```

Module loading is off by default. Pass `allowedModules` to inject a gated `require` on either tier. Only exact builtin or package ids resolve; paths never do.

```ts
import { run, isVerified } from "airlock";

// allowed: path (bare or node:path). denied: fs, relative paths, everything else.
const result = await run<string>("require('node:path').join('a', 'b')", {
timeoutMs: 100,
assert: (p) => typeof p === "string",
allowedModules: ["path"],
});

if (isVerified(result)) console.log(result.value);

// empty allowlist still injects require, but every load throws ModuleNotAllowedError
await run("require('path')", {
timeoutMs: 100,
assert: () => true,
allowedModules: [],
});
// -> { status: "error", error: ModuleNotAllowedError }
```

## Develop

```bash
Expand All @@ -138,3 +164,4 @@ pnpm run build
- `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.
- `src/modules.ts`: deny-by-default module loader with an explicit `allowedModules` allowlist. Omitted means no `require`; `[]` or a list injects `createGatedRequire` over the host/worker require. Exact match only (bare and `node:` equivalent), path specifiers always refused, and the gate wins over a grant-supplied `require`. Wired into both `run` and `runInWorker`.
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export {
export type { SandboxRunOptions } from "./sandbox.js";
export { runInWorker, freezeRealm, FROZEN_INTRINSICS } from "./worker.js";
export type { WorkerRunOptions } from "./worker.js";
export {
ModuleNotAllowedError,
expandAllowlist,
isPathSpecifier,
isModuleAllowed,
createGatedRequire,
buildSandboxRequire,
} from "./modules.js";
export {
validateResourceLimits,
measureOutputBytes,
Expand Down
112 changes: 112 additions & 0 deletions src/modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createRequire } from "node:module";

export class ModuleNotAllowedError extends Error {
readonly specifier: string;

constructor(specifier: string) {
super(`module not allowed: ${specifier}`);
this.name = "ModuleNotAllowedError";
this.specifier = specifier;
}
}

/**
* Allowlist stores both bare and node: forms so listing either is enough.
* Path-like specifiers are never admitted: allowlists are for package/builtin
* ids, not filesystem resolution that would re-open ambient I/O.
*/
export function expandAllowlist(modules: readonly string[]): ReadonlySet<string> {
const allowed = new Set<string>();
for (const id of modules) {
if (typeof id !== "string" || id.length === 0) continue;
if (isPathSpecifier(id)) continue;
allowed.add(id);
if (id.startsWith("node:")) allowed.add(id.slice(5));
else allowed.add(`node:${id}`);
}
return allowed;
}

export function isPathSpecifier(id: string): boolean {
return (
id.startsWith(".") ||
id.startsWith("/") ||
id.startsWith("\\") ||
/^[A-Za-z]:[\\/]/.test(id)
);
}

export function isModuleAllowed(
specifier: string,
allowedModules: readonly string[],
): boolean {
if (typeof specifier !== "string" || specifier.length === 0) return false;
if (isPathSpecifier(specifier)) return false;
const allowed = expandAllowlist(allowedModules);
if (allowed.has(specifier)) return true;
const bare = specifier.startsWith("node:") ? specifier.slice(5) : specifier;
return allowed.has(bare) || allowed.has(`node:${bare}`);
}

/**
* Self-contained so the worker bootstrap can inject it via Function#toString.
* hostRequire is the only capability that can actually load; this gate only
* decides whether that capability is invoked.
*/
export function createGatedRequire(
allowedModules: readonly string[],
hostRequire: (id: string) => unknown,
): (id: string) => unknown {
const allowed = new Set<string>();
for (const id of allowedModules) {
if (typeof id !== "string" || id.length === 0) continue;
if (
id.startsWith(".") ||
id.startsWith("/") ||
id.startsWith("\\") ||
/^[A-Za-z]:[\\/]/.test(id)
) {
continue;
}
allowed.add(id);
if (id.startsWith("node:")) allowed.add(id.slice(5));
else allowed.add(`node:${id}`);
}

return function gatedRequire(id: string): unknown {
const deny = (specifier: string): never => {
// defineProperty (not assignment): freezeRealm freezes Error.prototype, so
// `err.name = ...` throws TypeError in the worker isolate.
const err = new Error(`module not allowed: ${specifier}`);
Object.defineProperty(err, "name", {
Comment thread
ThomasHartDev marked this conversation as resolved.
value: "ModuleNotAllowedError",
configurable: true,
});
throw err;
};

if (typeof id !== "string" || id.length === 0) {
deny(String(id));
}
if (
id.startsWith(".") ||
id.startsWith("/") ||
id.startsWith("\\") ||
/^[A-Za-z]:[\\/]/.test(id)
) {
deny(id);
}
const bare = id.startsWith("node:") ? id.slice(5) : id;
if (!allowed.has(id) && !allowed.has(bare) && !allowed.has(`node:${bare}`)) {
deny(id);
}
return hostRequire(id);
};
}

export function buildSandboxRequire(
allowedModules: readonly string[],
): (id: string) => unknown {
const hostRequire = createRequire(import.meta.url);
return createGatedRequire(allowedModules, (id) => hostRequire(id));
}
27 changes: 24 additions & 3 deletions src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vm from "node:vm";
import type { Assertion, RunResult } from "./contract.js";
import { buildSandboxRequire } from "./modules.js";
import { runVerified } from "./run.js";

/**
Expand Down Expand Up @@ -32,6 +33,11 @@ export interface SandboxRunOptions<T> {
assert: Assertion<T>;
/** Capabilities the caller chooses to hand in. This is the only authority the code gets. */
grant?: Readonly<Record<string, unknown>>;
/**
* Builtin/package ids the sandbox may load via `require`. Omitted means no
* `require` at all; `[]` injects a require that denies every specifier.
*/
allowedModules?: readonly string[];
signal?: AbortSignal;
filename?: string;
maxOutputBytes?: number;
Expand Down Expand Up @@ -93,10 +99,25 @@ export async function run<T>(
code: string,
opts: SandboxRunOptions<T>,
): Promise<RunResult<T>> {
const { timeoutMs, assert, grant, signal, filename, maxOutputBytes } = opts;
const {
timeoutMs,
assert,
grant,
allowedModules,
signal,
filename,
maxOutputBytes,
} = opts;

const context = vm.createContext({ ...(grant ?? {}) });
const leaked = probeAmbientAuthority(context, Object.keys(grant ?? {}));
// allowedModules always wins over a grant-supplied require so a caller cannot
Comment thread
ThomasHartDev marked this conversation as resolved.
// accidentally re-open full host require while intending an allowlist.
const bindings: Record<string, unknown> = { ...(grant ?? {}) };
if (allowedModules !== undefined) {
bindings.require = buildSandboxRequire(allowedModules);
}

const context = vm.createContext(bindings);
const leaked = probeAmbientAuthority(context, Object.keys(bindings));
if (leaked.length > 0) throw new ZeroCredentialViolation(leaked);

let script: vm.Script;
Expand Down
25 changes: 22 additions & 3 deletions src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Worker } from "node:worker_threads";
import type { Assertion, RunResult } from "./contract.js";
import { checkOutputSize, validateResourceLimits } from "./limits.js";
import { createGatedRequire } from "./modules.js";

/**
* Intrinsics whose prototypes a sandbox escape could otherwise repave to attack
Expand Down Expand Up @@ -53,6 +54,11 @@ export interface WorkerRunOptions<T> {
assert: Assertion<T>;
/** Structured-cloneable capabilities only; live functions can't cross the thread boundary. */
grant?: Readonly<Record<string, unknown>>;
/**
* Builtin/package ids the isolate may load via `require`. Omitted means no
* `require`; `[]` injects a require that denies every specifier.
*/
allowedModules?: readonly string[];
/** Hard cap on the isolate's V8 old-space. Exceeding it kills the worker. */
maxOldGenerationSizeMb?: number;
/** Refuse values whose measured UTF-8 payload exceeds this many bytes. */
Expand All @@ -76,18 +82,28 @@ 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.
// freezeRealm + createGatedRequire are injected by source and applied inside.
const BOOTSTRAP = `
'use strict';
const { workerData, parentPort } = require('node:worker_threads');
const vm = require('node:vm');
const { createRequire } = require('node:module');
const path = require('node:path');

(${freezeRealm.toString()})(globalThis, ${JSON.stringify(FROZEN_INTRINSICS)});
const createGatedRequire = ${createGatedRequire.toString()};

(async () => {
try {
const { code, grant, timeoutMs, filename } = workerData;
const context = vm.createContext({ ...(grant || {}) });
const { code, grant, timeoutMs, filename, allowedModules } = workerData;
const bindings = { ...(grant || {}) };
// eval workers have no real __filename; anchor createRequire on cwd so
// builtins resolve and relative paths still hit the path-specifier deny.
if (Array.isArray(allowedModules)) {
const hostRequire = createRequire(path.join(process.cwd(), 'airlock-worker.js'));
bindings.require = createGatedRequire(allowedModules, hostRequire);
}
const context = vm.createContext(bindings);
const script = new vm.Script(code, { filename });
const value = await script.runInContext(context, { timeout: timeoutMs });
parentPort.postMessage({ ok: true, value });
Expand Down Expand Up @@ -132,6 +148,7 @@ export function runInWorker<T>(
timeoutMs,
assert,
grant,
allowedModules,
maxOldGenerationSizeMb,
maxOutputBytes,
signal,
Expand All @@ -153,6 +170,8 @@ export function runInWorker<T>(
grant: grant ?? {},
timeoutMs,
filename: filename ?? "airlock-worker.js",
allowedModules:
allowedModules === undefined ? undefined : [...allowedModules],
},
...(maxOldGenerationSizeMb !== undefined
? { resourceLimits: { maxOldGenerationSizeMb } }
Expand Down
Loading
Loading