Skip to content
Open
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
8 changes: 7 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. 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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
79 changes: 79 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
175 changes: 175 additions & 0 deletions src/threats.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading