From dd5341eeb76307c744d0f699f226087f5bc0b1e5 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Tue, 11 Aug 2026 20:59:29 +0000 Subject: [PATCH] feat: Add escape-attempt threat catalog and security model docs Pin infinite loop, fork bomb, fs, and env exfil containment on both executor tiers, plus residual constructor-walk risks for honesty. --- README.md | 8 +- docs/security.md | 79 +++++++++++++++++++ src/index.ts | 14 ++++ src/threats.ts | 175 +++++++++++++++++++++++++++++++++++++++++++ test/threats.test.ts | 145 +++++++++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 docs/security.md create mode 100644 src/threats.ts create mode 100644 test/threats.test.ts diff --git a/README.md b/README.md index 2e89e41..16e0c23 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 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. 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. +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. The fifth 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. This slice pins the **threat model**: a catalog of escape attempts (infinite loop, fork bomb, filesystem and env exfil) that must stay contained on both tiers, plus residual constructor-walk risks documented in `docs/security.md`. ## Concepts demonstrated @@ -26,6 +26,9 @@ The first slice was the contract and the in-process runner that enforces it. The - **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. +- **Adversarial threat catalog.** Named attack classes (resource exhaustion, process spawn, filesystem exfil, environment exfil) are stored as data with per-tier expected outcomes, then executed as regression tests so containment cannot regress silently. +- **Defense in depth.** Resource ceilings, zero-credential contexts, module allowlists, and isolate termination stack as independent layers; a break in one layer is still caught by another when the attack stays inside the vm boundary. +- **Honest residual-risk pins.** Constructor-walk escapes that reach the host or worker realm are tested as known gaps (host env on sandbox, realm `fs` / `child_process` on worker) rather than claimed fixed, so the security story stays accurate. - **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`. ## The primitive contract @@ -149,6 +152,8 @@ await run("require('path')", { // -> { status: "error", error: ModuleNotAllowedError } ``` +Threat scenarios live in `src/threats.ts` and run on both tiers (`pnpm test`). Security model, guarantees, and residual constructor-walk risks: [`docs/security.md`](docs/security.md). + ## Develop ```bash @@ -165,3 +170,4 @@ pnpm run build - `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`. +- Escape-attempt tests (infinite loop, fork bomb, fs/env exfil) all contained + `docs/security.md`: `src/threats.ts` catalogs adversarial payloads with per-tier expected outcomes; `test/threats.test.ts` runs them on sandbox and worker, checks concurrent loops leave the host healthy, and pins residual constructor-walk risks (host env on sandbox, realm `fs`/`child_process` on worker). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..bbada62 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,79 @@ +# Security model + +Airlock runs untrusted or agent-written JavaScript under a capability boundary and only hands the caller a value when a post-condition passes. This document is the threat model for the TypeScript tiers that ship today: the in-process `run` sandbox and the `runInWorker` isolate. + +## Trust boundaries + +| Tier | API | Boundary | Kills runaways with | +|---|---|---|---| +| Contract | `runVerified` | Trusted closures only | Cooperative `AbortSignal` deadline | +| Sandbox | `run` | Fresh `node:vm` context, no ambient bindings | V8 script `timeout` + async deadline | +| Isolate | `runInWorker` | Separate OS thread, empty `process.env`, frozen intrinsics | `worker.terminate()` + V8 heap cap | + +A stronger Docker tier (network-none, read-only root, dropped caps) is the intended home for process and filesystem hard walls after a realm escape. It is not claimed by the tests below until that executor lands. + +## Guarantees (default boundary) + +Inside the `vm` context, with no constructor-walk escape and no explicit `grant` / `allowedModules` expansion: + +1. **No ambient credentials.** `process`, `require`, `fetch`, timers, and `Buffer` are unbound unless the caller grants them. +2. **Infinite loops are contained.** A synchronous spin hits the script timeout (and, on the worker tier, thread terminate). A never-settling promise hits the wall-clock deadline. +3. **Process spawn is denied.** `child_process` and `cluster` are unreachable without an allowlist entry; omitting `allowedModules` leaves `require` undefined. +4. **Filesystem exfil is denied.** `fs` loads and relative/absolute path specifiers are refused by the gated loader. +5. **Environment exfil is denied.** `process` is unbound, so `process.env` cannot be read from guest code. +6. **Verified output only.** Callers receive a trusted value only as `{ status: "ok" }` after the assertion passes and resource ceilings hold. + +These properties are regression-tested by the threat catalog in `src/threats.ts` and `test/threats.test.ts`. + +## Attack classes under test + +| Class | Example payload | Expected containment | +|---|---|---| +| Infinite loop (sync) | `while (true) {}` | `timeout` | +| Infinite loop (async) | `new Promise(() => {})` | `timeout` | +| Fork bomb | `require('child_process')` with `allowedModules: []` | `ModuleNotAllowedError` | +| Fork bomb | `typeof require` with no allowlist | `"undefined"` | +| FS exfil | `require('fs').readFileSync(...)` | `ModuleNotAllowedError` | +| FS exfil | `require('./secrets.json')` even if `fs` is allowed | `ModuleNotAllowedError` | +| Env exfil | `typeof process` / `process.env.*` | `"undefined"` / `ReferenceError` | + +Run them with the rest of the suite: + +```bash +pnpm test +``` + +## Residual risks (documented, not claimed fixed) + +`node:vm` contexts borrow the host realm's global prototype chain. Guest code can walk `this.constructor.constructor` and obtain the host (or worker-realm) `Function`. That escape is intentional to pin, not hide. + +| Residual | Tier | What an attacker gets | Mitigation path | +|---|---|---|---| +| Host `process.env` | sandbox | Full host environment after constructor walk | Prefer `runInWorker`; never put secrets in the process that hosts `run` for hostile code | +| Empty but present `process` | worker | Realm `process` with `env: {}` after walk | Env canary stays undefined; still not a full process sandbox | +| Realm `require('fs')` | worker | Host filesystem via worker-realm require | Docker tier: read-only root + no mounts | +| Realm `require('child_process')` | worker | Process spawn after walk | Docker tier: no new privileges, dropped caps, pid limits | + +`RESIDUAL_RISKS` in `src/threats.ts` and the matching tests assert these observations so a future hardening change cannot silently flip them without review. + +## Hardening layers (defense in depth) + +``` +caller assertion + ↑ +resource ceilings (time, heap, output bytes) + ↑ +deny-by-default module allowlist + ↑ +zero-credential vm context + ↑ +worker isolate (empty env, frozen intrinsics, terminate) + ↑ +container tier (planned): network-none, read-only root, user namespaces +``` + +Each layer catches a different failure class. Resource limits stop wedging the host when the guest is still inside the context. Capability denial stops direct I/O and spawn. The isolate stops env inheritance. The container tier is what closes residual process and filesystem access after a realm escape. + +## Reporting + +If you find a path that breaks a **guarantee** above without using a listed residual technique, open an issue with a minimal payload and the tier (`run` vs `runInWorker`) it affects. diff --git a/src/index.ts b/src/index.ts index 0b399c5..c37d1f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,20 @@ export { checkOutputSize, } from "./limits.js"; export type { ResourceLimits, OutputSizeCheck } from "./limits.js"; +export { + THREAT_SCENARIOS, + RESIDUAL_RISKS, + ENV_CANARY, + ENV_CANARY_VALUE, + threatsByClass, +} from "./threats.js"; +export type { + ThreatClass, + ThreatScenario, + ResidualRisk, + ExecutorTier, + ContainmentExpectation, +} from "./threats.js"; export type { Assertion, RunResult, diff --git a/src/threats.ts b/src/threats.ts new file mode 100644 index 0000000..bb09f4f --- /dev/null +++ b/src/threats.ts @@ -0,0 +1,175 @@ +// Adversarial payloads + expected containment per tier; residuals are pins, not claims. + +export type ThreatClass = + | "infinite-loop" + | "fork-bomb" + | "fs-exfil" + | "env-exfil"; + +export type ExecutorTier = "sandbox" | "worker"; + +export type ContainmentExpectation = + | { kind: "timeout" } + | { kind: "error"; nameIncludes?: string; messageIncludes?: string } + | { kind: "ok"; value: unknown }; + +export interface ThreatScenario { + id: string; + class: ThreatClass; + code: string; + tiers: readonly ExecutorTier[]; + timeoutMs: number; + expect: ContainmentExpectation; + allowedModules?: readonly string[]; +} + +export interface ResidualRisk { + id: string; + class: ThreatClass; + code: string; + tier: ExecutorTier; + /** Observed residual after constructor walk; not claimed contained. */ + residual: string; + expectValue: unknown; +} + +export const ENV_CANARY = "AIRLOCK_THREAT_CANARY"; +export const ENV_CANARY_VALUE = "exfil-target-do-not-leak"; + +const BOTH = ["sandbox", "worker"] as const; +const DENY = { + kind: "error" as const, + nameIncludes: "ModuleNotAllowedError", +}; + +export const THREAT_SCENARIOS: readonly ThreatScenario[] = [ + { + id: "loop-sync", + class: "infinite-loop", + code: "while (true) {}", + tiers: BOTH, + timeoutMs: 50, + expect: { kind: "timeout" }, + }, + { + id: "loop-async", + class: "infinite-loop", + code: "new Promise(() => {})", + tiers: BOTH, + timeoutMs: 50, + expect: { kind: "timeout" }, + }, + { + id: "fork-require-child-process", + class: "fork-bomb", + code: "require('child_process')", + tiers: BOTH, + timeoutMs: 200, + allowedModules: [], + expect: { ...DENY, messageIncludes: "child_process" }, + }, + { + id: "fork-require-absent", + class: "fork-bomb", + code: "typeof require", + tiers: BOTH, + timeoutMs: 200, + expect: { kind: "ok", value: "undefined" }, + }, + { + id: "fork-cluster", + class: "fork-bomb", + code: "require('node:cluster')", + tiers: BOTH, + timeoutMs: 200, + allowedModules: [], + expect: { ...DENY, messageIncludes: "cluster" }, + }, + { + id: "fs-require-read", + class: "fs-exfil", + code: "require('fs').readFileSync('/etc/passwd', 'utf8')", + tiers: BOTH, + timeoutMs: 200, + allowedModules: [], + expect: { ...DENY, messageIncludes: "fs" }, + }, + { + id: "fs-path-specifier", + class: "fs-exfil", + code: "require('./secrets.json')", + tiers: BOTH, + timeoutMs: 200, + allowedModules: ["fs"], + expect: { ...DENY, messageIncludes: "./secrets.json" }, + }, + { + id: "fs-absolute-path", + class: "fs-exfil", + code: "require('/etc/passwd')", + tiers: BOTH, + timeoutMs: 200, + allowedModules: ["fs"], + expect: { ...DENY, messageIncludes: "/etc/passwd" }, + }, + { + id: "env-process-absent", + class: "env-exfil", + code: "typeof process", + tiers: BOTH, + timeoutMs: 200, + expect: { kind: "ok", value: "undefined" }, + }, + { + id: "env-direct-read", + class: "env-exfil", + code: `process.env.${ENV_CANARY}`, + tiers: BOTH, + timeoutMs: 200, + expect: { kind: "error", nameIncludes: "ReferenceError" }, + }, +]; + +const walk = (body: string) => + `this.constructor.constructor(${JSON.stringify(body)})()`; + +export const RESIDUAL_RISKS: readonly ResidualRisk[] = [ + { + id: "residual-sandbox-env", + class: "env-exfil", + code: walk(`return process.env.${ENV_CANARY}`), + tier: "sandbox", + residual: "host process.env readable after walk", + expectValue: ENV_CANARY_VALUE, + }, + { + id: "residual-worker-env-empty", + class: "env-exfil", + code: walk(`return process.env.${ENV_CANARY}`), + tier: "worker", + residual: "process reachable; env empty so canary is undefined", + expectValue: undefined, + }, + { + id: "residual-worker-fs", + class: "fs-exfil", + code: walk("return typeof require('fs').readFileSync"), + tier: "worker", + residual: "worker realm require loads host builtins", + expectValue: "function", + }, + { + id: "residual-worker-spawn", + class: "fork-bomb", + code: walk("return typeof require('child_process').spawn"), + tier: "worker", + residual: "spawn reachable only after constructor walk", + expectValue: "function", + }, +]; + +export function threatsByClass( + threatClass: ThreatClass, +): readonly ThreatScenario[] { + return THREAT_SCENARIOS.filter((s) => s.class === threatClass); +} diff --git a/test/threats.test.ts b/test/threats.test.ts new file mode 100644 index 0000000..937edef --- /dev/null +++ b/test/threats.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ENV_CANARY, + ENV_CANARY_VALUE, + RESIDUAL_RISKS, + THREAT_SCENARIOS, + type ContainmentExpectation, + type ExecutorTier, + type ThreatScenario, + threatsByClass, +} from "../src/threats.js"; +import { run, runInWorker, type RunResult } from "../src/index.js"; + +async function execute( + tier: ExecutorTier, + scenario: ThreatScenario, +): Promise> { + const opts = { + timeoutMs: scenario.timeoutMs, + assert: () => true as boolean, + ...(scenario.allowedModules !== undefined + ? { allowedModules: scenario.allowedModules } + : {}), + }; + return tier === "sandbox" + ? run(scenario.code, opts) + : runInWorker(scenario.code, opts); +} + +function errField(error: unknown, key: "name" | "message"): string { + if (typeof error === "object" && error !== null && key in error) { + return String((error as Record)[key]); + } + return key === "message" ? String(error) : ""; +} + +function assertContained( + result: RunResult, + shape: ContainmentExpectation, + label: string, + timeoutMs: number, +): void { + if (shape.kind === "timeout") { + expect(result, label).toEqual({ status: "timeout", timeoutMs }); + return; + } + if (shape.kind === "ok") { + expect(result.status, label).toBe("ok"); + if (result.status === "ok") expect(result.value, label).toEqual(shape.value); + return; + } + expect(result.status, label).toBe("error"); + if (result.status !== "error") return; + if (shape.nameIncludes) { + expect(errField(result.error, "name"), label).toContain(shape.nameIncludes); + } + if (shape.messageIncludes) { + expect(errField(result.error, "message"), label).toContain( + shape.messageIncludes, + ); + } +} + +describe("threat catalog coverage", () => { + it("covers every required attack class on at least one scenario", () => { + const classes = new Set(THREAT_SCENARIOS.map((s) => s.class)); + expect(classes).toEqual( + new Set(["infinite-loop", "fork-bomb", "fs-exfil", "env-exfil"]), + ); + for (const c of classes) { + expect(threatsByClass(c).length).toBeGreaterThan(0); + } + }); +}); + +describe("contained escape attempts", () => { + beforeEach(() => { + process.env[ENV_CANARY] = ENV_CANARY_VALUE; + }); + afterEach(() => { + delete process.env[ENV_CANARY]; + }); + + for (const scenario of THREAT_SCENARIOS) { + for (const tier of scenario.tiers) { + it(`${scenario.class}: ${scenario.id} on ${tier}`, async () => { + const result = await execute(tier, scenario); + assertContained( + result, + scenario.expect, + `${scenario.id}@${tier}`, + scenario.timeoutMs, + ); + }); + } + } + + it("host stays responsive after concurrent infinite loops", async () => { + const started = performance.now(); + const results = await Promise.all([ + run("while (true) {}", { timeoutMs: 40, assert: () => true }), + runInWorker("while (true) {}", { timeoutMs: 40, assert: () => true }), + run("while (true) {}", { timeoutMs: 40, assert: () => true }), + ]); + for (const r of results) expect(r.status).toBe("timeout"); + const healthy = await run("1 + 1", { + timeoutMs: 200, + assert: (n) => n === 2, + }); + expect(healthy).toMatchObject({ status: "ok", value: 2 }); + expect(performance.now() - started).toBeLessThan(5_000); + }); + + it("env canary is planted on the host during the suite", () => { + expect(process.env[ENV_CANARY]).toBe(ENV_CANARY_VALUE); + }); +}); + +describe("documented residual risks after constructor walk", () => { + beforeEach(() => { + process.env[ENV_CANARY] = ENV_CANARY_VALUE; + }); + afterEach(() => { + delete process.env[ENV_CANARY]; + }); + + it.each(RESIDUAL_RISKS)( + "pins $id ($class on $tier)", + async (risk) => { + const exec = risk.tier === "sandbox" ? run : runInWorker; + const result = await exec(risk.code, { + timeoutMs: risk.tier === "worker" ? 1000 : 200, + assert: () => true, + }); + expect(result).toMatchObject({ status: "ok", value: risk.expectValue }); + }, + ); + + it("residuals cover env, fs, and fork classes", () => { + const classes = new Set(RESIDUAL_RISKS.map((r) => r.class)); + expect(classes.has("env-exfil")).toBe(true); + expect(classes.has("fs-exfil")).toBe(true); + expect(classes.has("fork-bomb")).toBe(true); + }); +});