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
31 changes: 30 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. Module loading is deny-by-default via `allowedModules`. This slice adds the **`airlock run <file>` CLI** and a programmatic `runFile` / `runSource` API that return a structured JSON result, so agents and shell pipelines can consume every outcome without special-casing thrown `Error` values.

## Concepts demonstrated

Expand All @@ -26,6 +26,8 @@ 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.
- **Structured result DTOs + exit codes.** `RunResult` maps to a JSON envelope (`JsonRunResult`) with Error DTOs; exit `0` only for `ok`, `1` for sandbox refusals, `2` for CLI/IO failures.
- **CLI as a process-boundary adapter.** Argv and file I/O stay outside the sandbox; the untrusted payload is the file body, while `--assert` is a host-side expression over the returned value.
- **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`.

## The primitive contract
Expand Down Expand Up @@ -149,6 +151,32 @@ await run("require('path')", {
// -> { status: "error", error: ModuleNotAllowedError }
```

### CLI and structured JSON

`airlock run <file>` prints **one JSON result** to stdout for every run invocation (including failed post-conditions and assert compile failures). Exit `0` only for `status: "ok"`, `1` for sandbox refusals and assert/runtime errors, `2` only for usage parse failures and file IO (`io-error`).

```bash
pnpm run build
echo '21 * 2' > /tmp/snippet.js
node dist/cli.js run /tmp/snippet.js --timeout 200 --assert 'value === 42'
# {"status":"ok","value":42,"durationMs":...}
```

```ts
import { runFile } from "airlock";
const result = await runFile("./snippet.js", {
timeoutMs: 200,
assertExpr: "value === 42",
});
if (result.status === "ok") console.log(result.value);
```

Flags: `--tier sandbox|worker`, `--grant`, `--allow-module`, `--max-output-bytes`, `--max-old-gen-mb`.

**Host-privileged assert.** `--assert` / `assertExpr` is compiled with `new Function` in the **host** realm, not inside the sandbox. The expression can reach `process` and other host globals. Treat it as trusted operator input, same trust level as the caller process. Untrusted code is only the file body.

Invalid or empty assert expressions do not reject: they return `{ status: "error", error: ... }` (CLI exit `1`) so agents always parse one JSON envelope.

## Develop

```bash
Expand All @@ -165,3 +193,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`.
- `src/cli.ts` + `src/run-file.ts`: `airlock run <file>` CLI and programmatic `runFile` / `runSource` API with structured JSON results, exit-code mapping, host-side `--assert` expressions, and tier/grant/module flags.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"import": "./dist/index.js"
}
},
"bin": {
"airlock": "./dist/cli.js"
},
"files": [
"dist"
],
Expand All @@ -29,6 +32,7 @@
"untrusted-code",
"agent-safety",
"capability-security",
"cli",
"typescript"
],
"license": "MIT",
Expand Down
159 changes: 159 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { pathToFileURL } from "node:url";
import {
exitCodeFor,
runFile,
stringifyJsonResult,
type ExecutorTier,
type JsonRunResult,
} from "./run-file.js";

export interface CliArgs {
file: string;
timeoutMs: number;
assertExpr?: string;
grantJson?: string;
allowedModules: string[];
maxOutputBytes?: number;
maxOldGenerationSizeMb?: number;
tier: ExecutorTier;
}

export type ParseResult =
| { ok: true; args: CliArgs }
| { ok: false; message: string; showHelp?: boolean };

const USAGE = `Usage: airlock run <file> [options]
--timeout <ms> --assert <expr> --tier sandbox|worker
--grant <json> --allow-module <id> --max-output-bytes <n>
--max-old-gen-mb <n> -h, --help
Exit: 0 ok, 1 refusal (incl. assert compile/runtime), 2 usage/io.
--assert is host-privileged (new Function in the host realm).
`;

function fail(message: string, showHelp?: boolean): ParseResult {
return showHelp ? { ok: false, message, showHelp } : { ok: false, message };
}

export function parseArgv(argv: string[]): ParseResult {
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
return fail(USAGE, true);
}
if (argv[0] !== "run") return fail(`unknown command: ${argv[0]}\n\n${USAGE}`);

const args: CliArgs = {
file: "",
timeoutMs: 5_000,
allowedModules: [],
tier: "sandbox",
};
let file: string | undefined;

for (let i = 1; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "-h" || arg === "--help") return fail(USAGE, true);
if (!arg.startsWith("-")) {
if (file !== undefined) return fail(`unexpected argument: ${arg}\n\n${USAGE}`);
file = arg;
continue;
}
const value = argv[i + 1];
if (value === undefined || value.startsWith("-")) {
return fail(`${arg} requires a value`);
}
i++;
if (arg === "--timeout" || arg === "--max-output-bytes" || arg === "--max-old-gen-mb") {
if (!/^\d+$/.test(value)) return fail(`${arg} must be a non-negative integer`);
const n = Number(value);
if (!Number.isSafeInteger(n)) return fail(`${arg} is out of range`);
if (arg === "--timeout") args.timeoutMs = n;
else if (arg === "--max-output-bytes") args.maxOutputBytes = n;
else args.maxOldGenerationSizeMb = n;
continue;
}
if (arg === "--assert") { args.assertExpr = value; continue; }
if (arg === "--grant") { args.grantJson = value; continue; }
if (arg === "--allow-module") { args.allowedModules.push(value); continue; }
if (arg === "--tier") {
if (value !== "sandbox" && value !== "worker") {
return fail(`--tier must be "sandbox" or "worker"`);
}
args.tier = value;
continue;
}
return fail(`unknown option: ${arg}\n\n${USAGE}`);
}

if (file === undefined) return fail(`missing <file>\n\n${USAGE}`);
args.file = file;
return { ok: true, args };
}

function parseGrant(
raw: string,
): { ok: true; grant: Record<string, unknown> } | { ok: false; message: string } {
try {
const parsed: unknown = JSON.parse(raw);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, message: "--grant must be a JSON object" };
}
return { ok: true, grant: parsed as Record<string, unknown> };
} catch (error) {
return {
ok: false,
message: `--grant: ${error instanceof Error ? error.message : "invalid JSON"}`,
};
}
}

export interface CliIo {
stdout: { write(chunk: string): void };
stderr: { write(chunk: string): void };
}

export async function main(argv: string[], io: CliIo = process): Promise<number> {
const parsed = parseArgv(argv);
if (!parsed.ok) {
io.stderr.write(parsed.message.endsWith("\n") ? parsed.message : `${parsed.message}\n`);
return parsed.showHelp ? 0 : 2;
}

const { args } = parsed;
let grant: Record<string, unknown> | undefined;
if (args.grantJson !== undefined) {
const g = parseGrant(args.grantJson);
if (!g.ok) {
io.stderr.write(`${g.message}\n`);
return 2;
}
grant = g.grant;
}

const result = await runFile(args.file, {
timeoutMs: args.timeoutMs,
tier: args.tier,
...(args.assertExpr !== undefined ? { assertExpr: args.assertExpr } : {}),
...(grant ? { grant } : {}),
...(args.allowedModules.length > 0 ? { allowedModules: args.allowedModules } : {}),
...(args.maxOutputBytes !== undefined ? { maxOutputBytes: args.maxOutputBytes } : {}),
...(args.maxOldGenerationSizeMb !== undefined
? { maxOldGenerationSizeMb: args.maxOldGenerationSizeMb }
: {}),
});

io.stdout.write(`${stringifyJsonResult(result)}\n`);
return exitCodeFor(result);
}

const entry = process.argv[1];
if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
void main(process.argv.slice(2))
.then((code) => {
process.exitCode = code;
})
.catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 2;
});
}
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ export {
checkOutputSize,
} from "./limits.js";
export type { ResourceLimits, OutputSizeCheck } from "./limits.js";
export {
serializeError,
toJsonResult,
exitCodeFor,
stringifyJsonResult,
compileAssertExpr,
runSource,
runFile,
} from "./run-file.js";
export type {
JsonError,
JsonRunResult,
ExecutorTier,
RunFileOptions,
} from "./run-file.js";
export type {
Assertion,
RunResult,
Expand Down
Loading
Loading