From 059077546ed77e9523935ea55057e79ed715d5d2 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Sun, 19 Jul 2026 22:20:44 +0000 Subject: [PATCH] feat: add run(code, opts) sandbox with zero-credential invariant --- README.md | 34 ++++++++- src/index.ts | 7 ++ src/sandbox.ts | 132 ++++++++++++++++++++++++++++++++ test/sandbox.test.ts | 174 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 src/sandbox.ts create mode 100644 test/sandbox.test.ts diff --git a/README.md b/README.md index be07ddb..c0aa2e3 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. -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 @@ -14,7 +14,10 @@ This first slice is the contract and the in-process runner that enforces it. Lat - **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 @@ -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( + "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 @@ -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. diff --git a/src/index.ts b/src/index.ts index 0958c02..2521f1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/sandbox.ts b/src/sandbox.ts new file mode 100644 index 0000000..3144544 --- /dev/null +++ b/src/sandbox.ts @@ -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 { + timeoutMs: number; + assert: Assertion; + /** Capabilities the caller chooses to hand in. This is the only authority the code gets. */ + grant?: Readonly>; + 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( + code: string, + opts: SandboxRunOptions, +): Promise> { + 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( + () => + script.runInContext(context, { + timeout: timeoutMs, + breakOnSigint: true, + }) as T | Promise, + { 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 + ); +} diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts new file mode 100644 index 0000000..01b6978 --- /dev/null +++ b/test/sandbox.test.ts @@ -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("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("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("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(`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)[key] = "super-secret-token"; + try { + const result = await run(`typeof globalThis.${key}`, { + timeoutMs: 100, + assert: (v) => v === "undefined", + }); + expect(result).toMatchObject({ status: "ok", value: "undefined" }); + } finally { + delete (globalThis as Record)[key]; + } + }); + + it("grants only the capabilities the caller hands in", async () => { + let called = 0; + const result = await run("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( + `({}).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( + `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" }); + }); +});