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: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ 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.

This first slice is the contract and the in-process runner that enforces it. Later slices harden the sandbox itself: an [`isolated-vm`](https://github.com/laverdet/isolated-vm) tier with no ambient `env` or network and a hard memory cap, a Docker-backed tier for stronger isolation, and a suite of documented escape-attempt tests.
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.

## 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.
- **Capability-security framing.** The task receives only the abort capability it needs; the stronger isolation tiers extend this to zero ambient authority (no `env`, no network, no filesystem).
- **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.
- **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`.

## The primitive contract
Expand Down Expand Up @@ -52,6 +55,32 @@ if (isVerified(result)) {
}
```

To run untrusted **source code** instead of a trusted closure, use `run`. The code executes with no ambient authority, so `process`, `require`, `fetch`, and timers are all undefined inside it. Any capability it needs is passed explicitly through `grant`:

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

const result = await run<number>(
"add(rows.length, 1)",
{
timeoutMs: 50,
assert: (n) => Number.isInteger(n) && n > 0,
grant: {
rows: [{ id: 1 }, { id: 2 }],
add: (a: number, b: number) => a + b,
},
},
);

if (isVerified(result)) {
console.log("verified:", result.value); // 3
}

// a synchronous infinite loop is preempted and reported as a timeout
await run("while (true) {}", { timeoutMs: 25, assert: () => true });
// -> { status: "timeout", timeoutMs: 25 }
```

## Develop

```bash
Expand All @@ -64,3 +93,4 @@ pnpm run build
## What's implemented

- 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.
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export { runVerified } from "./run.js";
export { isVerified } from "./contract.js";
export {
run,
probeAmbientAuthority,
ZeroCredentialViolation,
DENIED_AMBIENT_NAMES,
} from "./sandbox.js";
export type { SandboxRunOptions } from "./sandbox.js";
export type {
Assertion,
RunResult,
Expand Down
132 changes: 132 additions & 0 deletions src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as vm from "node:vm";
import type { Assertion, RunResult } from "./contract.js";
import { runVerified } from "./run.js";

/**
* Ambient authority the host process carries that untrusted code must never
* reach unless the caller hands it in explicitly. This list is the machine
* form of the zero-credential invariant: with an empty grant, every one of
* these must be unbound inside the context.
*/
const AMBIENT_AUTHORITY = [
"process",
"require",
"module",
"global",
"fetch",
"XMLHttpRequest",
"WebSocket",
"Buffer",
"setTimeout",
"setInterval",
"setImmediate",
"queueMicrotask",
"__dirname",
"__filename",
] as const;

export const DENIED_AMBIENT_NAMES: readonly string[] = AMBIENT_AUTHORITY;

export interface SandboxRunOptions<T> {
timeoutMs: number;
assert: Assertion<T>;
/** Capabilities the caller chooses to hand in. This is the only authority the code gets. */
grant?: Readonly<Record<string, unknown>>;
signal?: AbortSignal;
filename?: string;
}

export class ZeroCredentialViolation extends Error {
readonly leaked: readonly string[];
constructor(leaked: readonly string[]) {
super(
`zero-credential invariant violated: ${leaked.join(
", ",
)} reachable without an explicit grant`,
);
this.name = "ZeroCredentialViolation";
this.leaked = leaked;
}
}

const SYNC_TIMEOUT_CODE = "ERR_SCRIPT_EXECUTION_TIMEOUT";

/**
* Returns the ambient names that resolve to something bound inside `context`
* and were not part of the grant. A clean context returns an empty array; a
* non-empty result means authority leaked in and the run must be refused.
*/
export function probeAmbientAuthority(
context: vm.Context,
granted: readonly string[],
): string[] {
const granting = new Set(granted);
const leaked: string[] = [];
for (const name of AMBIENT_AUTHORITY) {
if (granting.has(name)) continue;
if (vm.runInContext(`typeof ${name}`, context) !== "undefined") {
leaked.push(name);
}
}
return leaked;
}

/**
* Run untrusted source in a fresh V8 context with no ambient authority, then
* gate the result through the deadline and post-condition contract. The value
* comes back only as `{ status: "ok" }`, same as {@link runVerified}.
*
* Two independent deadline enforcers compose here: V8's own `timeout` preempts
* a synchronous spin that would otherwise wedge the event loop, and the
* async deadline in `runVerified` aborts a task that hangs on an unresolved
* promise. Neither alone covers both cases.
*
* This in-process tier denies *direct* ambient access (`process`, `require`,
* `fetch`, timers) and fails closed if any leaks in. It is not an escape-proof
* boundary: `this.constructor.constructor` still reaches the host realm's
* `Function` because the context's global borrows the host `Object`. Closing
* that is the job of the isolate and container tiers; see the escape-attempt
* tests for the pinned gap.
*/
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 context = vm.createContext({ ...(grant ?? {}) });
const leaked = probeAmbientAuthority(context, Object.keys(grant ?? {}));
if (leaked.length > 0) throw new ZeroCredentialViolation(leaked);

let script: vm.Script;
try {
script = new vm.Script(code, { filename: filename ?? "airlock-sandbox.js" });
} catch (error) {
return { status: "error", error };
}

const result = await runVerified<T>(
() =>
script.runInContext(context, {
timeout: timeoutMs,
breakOnSigint: true,
}) as T | Promise<T>,
{ timeoutMs, assert, ...(signal ? { signal } : {}) },
);

if (result.status === "error" && isSyncTimeout(result.error)) {
return { status: "timeout", timeoutMs };
}
return result;
}

function isSyncTimeout(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
(error as { code?: unknown }).code === SYNC_TIMEOUT_CODE
);
}
174 changes: 174 additions & 0 deletions test/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import {
DENIED_AMBIENT_NAMES,
ZeroCredentialViolation,
isVerified,
probeAmbientAuthority,
run,
} from "../src/index.js";
import * as vm from "node:vm";

describe("run", () => {
it("evaluates untrusted source and returns the verified value", async () => {
const result = await run<number>("40 + 2", {
timeoutMs: 100,
assert: (v) => v === 42,
});

expect(result.status).toBe("ok");
if (isVerified(result)) expect(result.value).toBe(42);
});

it("refuses the value when the post-condition fails", async () => {
const result = await run<number>("41", {
timeoutMs: 100,
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 run<string>("Promise.resolve('hi')", {
timeoutMs: 100,
assert: (v) => v === "hi",
});

expect(result).toMatchObject({ status: "ok", value: "hi" });
});

it("captures a runtime throw as an error result", async () => {
const result = await run("throw new Error('boom')", {
timeoutMs: 100,
assert: () => true,
});

expect(result.status).toBe("error");
if (result.status === "error") {
expect((result.error as Error).message).toBe("boom");
}
});

it("returns an error result for a syntax error instead of throwing", async () => {
const result = await run("this is not valid js (", {
timeoutMs: 100,
assert: () => true,
});

expect(result.status).toBe("error");
});

it("preempts a synchronous infinite loop as a timeout", async () => {
const result = await run("while (true) {}", {
timeoutMs: 25,
assert: () => true,
});

expect(result).toEqual({ status: "timeout", timeoutMs: 25 });
});

it("times out on an async task that never settles", async () => {
const result = await run("new Promise(() => {})", {
timeoutMs: 25,
assert: () => true,
});

expect(result).toEqual({ status: "timeout", timeoutMs: 25 });
});

it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"rejects a non-positive or non-finite timeout: %s",
async (bad) => {
await expect(
run("1", { timeoutMs: bad, assert: () => true }),
).rejects.toBeInstanceOf(RangeError);
},
);
});

describe("zero-credential invariant", () => {
it.each(DENIED_AMBIENT_NAMES)(
"denies ambient access to %s by default",
async (name) => {
const result = await run<string>(`typeof ${name}`, {
timeoutMs: 100,
assert: (v) => v === "undefined",
});

expect(result).toMatchObject({ status: "ok", value: "undefined" });
},
);

it("cannot read a secret placed on the host global object", async () => {
const key = "__airlock_test_secret__";
(globalThis as Record<string, unknown>)[key] = "super-secret-token";
try {
const result = await run<string>(`typeof globalThis.${key}`, {
timeoutMs: 100,
assert: (v) => v === "undefined",
});
expect(result).toMatchObject({ status: "ok", value: "undefined" });
} finally {
delete (globalThis as Record<string, unknown>)[key];
}
});

it("grants only the capabilities the caller hands in", async () => {
let called = 0;
const result = await run<number>("greet(2)", {
timeoutMs: 100,
assert: (v) => v === 4,
grant: {
greet: (n: number) => {
called += 1;
return n * 2;
},
},
});

expect(result).toMatchObject({ status: "ok", value: 4 });
expect(called).toBe(1);
});

it("probeAmbientAuthority reports leaked authority and fails closed", () => {
const clean = vm.createContext({});
expect(probeAmbientAuthority(clean, [])).toEqual([]);

const dirty = vm.createContext({ process });
const leaked = probeAmbientAuthority(dirty, []);
expect(leaked).toContain("process");
// an explicit grant of the same name is authority the caller chose, not a leak
expect(probeAmbientAuthority(dirty, ["process"])).not.toContain("process");

const violation = new ZeroCredentialViolation(leaked);
expect(violation).toBeInstanceOf(Error);
expect(violation.leaked).toEqual(leaked);
expect(violation.message).toContain("process");
});
});

describe("documented escape attempts", () => {
it("blocks the sandbox-realm Function constructor from reaching the host", async () => {
const result = await run<string>(
`({}).constructor.constructor("return typeof process")()`,
{ timeoutMs: 100, assert: () => true },
);

// The per-context Object's Function compiles in the sandbox realm, so the
// host `process` stays out of reach.
expect(result).toMatchObject({ status: "ok", value: "undefined" });
});

it("pins the known in-process gap: the global proto chain reaches the host realm", async () => {
const result = await run<string>(
`this.constructor.constructor("return typeof process")()`,
{ timeoutMs: 100, assert: () => true },
);

// `this.constructor` borrows the HOST `Object`, so its Function escapes the
// context. node:vm cannot close this in-process; the isolate and container
// tiers do. This test documents the boundary rather than pretending it holds.
expect(result).toMatchObject({ status: "ok", value: "object" });
});
});
Loading