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 slices covered the verification contract, zero-credential `node:vm` execution, `worker_threads` isolation, resource ceilings, and a deny-by-default module allowlist. This slice adds **ephemeral filesystem isolation**: every opted-in run gets a private tmpdir injected as `workdir`, optional host fixtures **snapshotted** in as read-only copies (symlink trees refused), and a guaranteed wipe when the run ends (success, error, timeout, or throw). Later slices add a Docker-backed tier and a growing suite of documented escape-attempt tests.

## Concepts demonstrated

Expand All @@ -26,6 +26,10 @@ 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.
- **Ephemeral workspaces.** Per-run private directories via `mkdtemp`, injected as `workdir`, wiped on every exit path (try/finally / RAII-style lifecycle).
- **Read-only fixture snapshots.** Host files and trees are **copied** under relative keys (not live mounts), then locked read-only (mode bits plus Linux immutable flag when available). Symlink-bearing host paths and trees are **rejected** (`FixturePathError`) so a link cannot re-open a host file under `workdir`.
- **Path-escape rejection.** Fixture keys reject `..`, absolute paths, drive forms, and keys that normalize onto `.` (workspace root) before join.
- **Cross-run isolation.** Concurrent runs get distinct tmpdirs; writes under one `workdir` never appear under another.
- **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`.

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

Filesystem access is off the host by default. Opt in with `ephemeralFs` for a private tmpdir (injected as `workdir`) that is always wiped when the run finishes. Optional fixtures are **content snapshots**: regular files and symlink-free trees are copied into the workspace, then made read-only. Host originals are not re-opened under `workdir`. A host path that is a symlink, or a directory tree that contains any symlink, is refused with `FixturePathError` (default `fs.cp` would preserve links and let guest writes mutate the host).

Read-only is advisory for same-uid guests without the immutable bit: mode bits alone can be `chmod`'d away. On Linux, airlock best-effort sets `chattr +i` when the filesystem allows it; elsewhere (and when `chattr` is unavailable) RO is mode bits only, not a privilege jail.

```ts
import { runInWorker, isVerified, withEphemeralWorkspace } from "airlock";

const result = await runInWorker<string>(
`require('node:fs').readFileSync(require('node:path').join(workdir, 'seed.txt'), 'utf8')`,
{
timeoutMs: 2000,
assert: (v) => v === "hello",
ephemeralFs: { fixtures: { "seed.txt": "/host/path/to/seed.txt" } },
allowedModules: ["fs", "path"],
},
);
if (isVerified(result)) console.log(result.value);

// standalone lifecycle outside a sandbox run; wiped when the callback settles
await withEphemeralWorkspace({ fixtures: { "in.json": "/host/fixture.json" } }, async (ws) => {
// ws.root is writable; snapshotted fixtures under it are read-only
});
```

## 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/ephemeral-fs.ts`: per-run tmpdir wiped on exit, optional read-only fixture **snapshots** (content copy, not live mount). `createEphemeralWorkspace` / `withEphemeralWorkspace` for standalone use; `ephemeralFs` on `run` and `runInWorker` injects `workdir` and always disposes after the result settles (including error and timeout paths). Fixture keys cannot escape the root. Symlink host paths and symlink-bearing trees are rejected so host files cannot re-open under `workdir`.
207 changes: 207 additions & 0 deletions src/ephemeral-fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

export interface EphemeralFsOptions {
/** Relative workspace path -> host file or directory, mounted read-only. */
fixtures?: Readonly<Record<string, string>>;
/** Prefix for `mkdtemp`. Default `airlock-`. */
prefix?: string;
/** Parent of the per-run directory. Default `os.tmpdir()`. */
baseDir?: string;
}

export interface EphemeralWorkspace {
readonly root: string;
readonly fixturePaths: readonly string[];
dispose(): Promise<void>;
}

export class FixturePathError extends Error {
readonly fixtureKey: string;
constructor(fixtureKey: string, reason: string) {
super(`invalid fixture path "${fixtureKey}": ${reason}`);
this.name = "FixturePathError";
this.fixtureKey = fixtureKey;
}
}

/** Reject absolute paths, `..`, and drive forms so joins cannot escape root. */
export function assertSafeFixtureKey(key: string): void {
if (typeof key !== "string" || key.length === 0) {
throw new FixturePathError(String(key), "must be a non-empty relative path");
}
if (path.isAbsolute(key) || /^[A-Za-z]:[\\/]/.test(key)) {
throw new FixturePathError(key, "must be relative");
}
const normalized = path.posix.normalize(key.replace(/\\/g, "/"));
if (
normalized === "." ||
normalized === "" ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized.includes("/../") ||
path.posix.isAbsolute(normalized)
) {
throw new FixturePathError(key, "must not escape the workspace root");
}
}

export async function createEphemeralWorkspace(
opts: EphemeralFsOptions = {},
): Promise<EphemeralWorkspace> {
const baseDir = opts.baseDir ?? os.tmpdir();
const prefix = opts.prefix ?? "airlock-";
await fs.mkdir(baseDir, { recursive: true });
const root = await fs.mkdtemp(path.join(baseDir, prefix));

const fixturePaths: string[] = [];
try {
for (const [rel, hostPath] of Object.entries(opts.fixtures ?? {})) {
assertSafeFixtureKey(rel);
if (typeof hostPath !== "string" || hostPath.length === 0) {
throw new FixturePathError(rel, "host path must be a non-empty string");
}
await mountReadOnly(hostPath, path.join(root, rel));
fixturePaths.push(rel.replace(/\\/g, "/"));
}
} catch (error) {
await wipeTree(root);
throw error;
}

let disposed = false;
return {
root,
fixturePaths,
async dispose() {
if (disposed) return;
disposed = true;
await wipeTree(root);
},
};
}

export async function withEphemeralWorkspace<T>(
opts: EphemeralFsOptions | undefined,
fn: (workspace: EphemeralWorkspace) => Promise<T>,
): Promise<T> {
const workspace = await createEphemeralWorkspace(opts ?? {});
try {
return await fn(workspace);
} finally {
await workspace.dispose();
}
}

async function mountReadOnly(hostPath: string, dest: string): Promise<void> {
// WHY lstat: fs.cp keeps symlinks; a host-pointing link under workdir is a write escape.
const stat = await fs.lstat(hostPath);
if (stat.isSymbolicLink()) {
throw new FixturePathError(
hostPath,
"must not be a symbolic link (host paths must not re-open under workdir)",
);
}
if (stat.isDirectory()) {
await assertNoSymlinksInTree(hostPath);
await fs.cp(hostPath, dest, { recursive: true, errorOnExist: true });
await assertNoSymlinksInTree(dest);
await makeTreeReadOnly(dest);
return;
}
if (!stat.isFile()) {
throw new FixturePathError(hostPath, "host path must be a file or directory");
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(hostPath, dest);
await lockReadOnly(dest);
}

async function assertNoSymlinksInTree(root: string): Promise<void> {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const name of await fs.readdir(current)) {
const full = path.join(current, name);
const st = await fs.lstat(full);
if (st.isSymbolicLink()) {
throw new FixturePathError(
full,
"fixture trees must not contain symbolic links (would re-open host paths under workdir)",
);
}
if (st.isDirectory()) {
stack.push(full);
}
}
}
}

async function makeTreeReadOnly(dir: string): Promise<void> {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
throw new FixturePathError(
full,
"symlink escaped into workspace after fixture copy",
);
}
if (entry.isDirectory()) {
await makeTreeReadOnly(full);
await fs.chmod(full, 0o555);
} else if (entry.isFile()) {
await lockReadOnly(full);
}
}
await fs.chmod(dir, 0o555);
}

// Mode bits alone do not stop root; chattr +i closes that hole on Linux.
async function lockReadOnly(filePath: string): Promise<void> {
await fs.chmod(filePath, 0o444);
await chattr(filePath, "+i");
}

async function chattr(filePath: string, flag: "+i" | "-i"): Promise<void> {
if (process.platform !== "linux") return;
try {
await execFileAsync("chattr", [flag, filePath], { timeout: 5_000 });
} catch {
// unsupported FS: mode bits still apply for non-root guests
}
}

async function wipeTree(root: string): Promise<void> {
try {
await restoreWritable(root);
} catch {
// best-effort before force remove
}
await fs.rm(root, { recursive: true, force: true });
}

async function restoreWritable(target: string): Promise<void> {
let stat;
try {
stat = await fs.lstat(target);
} catch {
return;
}
if (stat.isSymbolicLink()) return;
if (stat.isDirectory()) {
await fs.chmod(target, 0o700).catch(() => {});
for (const name of await fs.readdir(target)) {
await restoreWritable(path.join(target, name));
}
return;
}
if (stat.isFile()) {
await chattr(target, "-i");
await fs.chmod(target, 0o600).catch(() => {});
}
}
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export {
checkOutputSize,
} from "./limits.js";
export type { ResourceLimits, OutputSizeCheck } from "./limits.js";
export {
createEphemeralWorkspace,
withEphemeralWorkspace,
assertSafeFixtureKey,
FixturePathError,
} from "./ephemeral-fs.js";
export type {
EphemeralFsOptions,
EphemeralWorkspace,
} from "./ephemeral-fs.js";
export type {
Assertion,
RunResult,
Expand Down
81 changes: 50 additions & 31 deletions src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as vm from "node:vm";
import type { Assertion, RunResult } from "./contract.js";
import {
createEphemeralWorkspace,
type EphemeralFsOptions,
} from "./ephemeral-fs.js";
import { buildSandboxRequire } from "./modules.js";
import { runVerified } from "./run.js";

Expand Down Expand Up @@ -38,6 +42,8 @@ export interface SandboxRunOptions<T> {
* `require` at all; `[]` injects a require that denies every specifier.
*/
allowedModules?: readonly string[];
/** Per-run private dir as `workdir`, wiped on exit. `true` = empty workspace. */
ephemeralFs?: true | EphemeralFsOptions;
signal?: AbortSignal;
filename?: string;
maxOutputBytes?: number;
Expand Down Expand Up @@ -104,47 +110,60 @@ export async function run<T>(
assert,
grant,
allowedModules,
ephemeralFs,
signal,
filename,
maxOutputBytes,
} = opts;

// allowedModules always wins over a grant-supplied require so a caller cannot
// accidentally re-open full host require while intending an allowlist.
const bindings: Record<string, unknown> = { ...(grant ?? {}) };
if (allowedModules !== undefined) {
bindings.require = buildSandboxRequire(allowedModules);
}

const context = vm.createContext(bindings);
const leaked = probeAmbientAuthority(context, Object.keys(bindings));
if (leaked.length > 0) throw new ZeroCredentialViolation(leaked);
const workspace =
ephemeralFs === undefined
? null
: await createEphemeralWorkspace(ephemeralFs === true ? {} : ephemeralFs);

let script: vm.Script;
try {
script = new vm.Script(code, { filename: filename ?? "airlock-sandbox.js" });
} catch (error) {
return { status: "error", error };
}
// allowedModules always wins over a grant-supplied require so a caller cannot
// accidentally re-open full host require while intending an allowlist.
const bindings: Record<string, unknown> = { ...(grant ?? {}) };
if (allowedModules !== undefined) {
bindings.require = buildSandboxRequire(allowedModules);
}
if (workspace) bindings.workdir = workspace.root;

const result = await runVerified<T>(
() =>
script.runInContext(context, {
timeout: timeoutMs,
breakOnSigint: true,
}) as T | Promise<T>,
{
timeoutMs,
assert,
...(signal ? { signal } : {}),
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
},
);
const context = vm.createContext(bindings);
const leaked = probeAmbientAuthority(context, Object.keys(bindings));
if (leaked.length > 0) throw new ZeroCredentialViolation(leaked);

if (result.status === "error" && isSyncTimeout(result.error)) {
return { status: "timeout", timeoutMs };
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 } : {}),
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
},
);

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

function isSyncTimeout(error: unknown): boolean {
Expand Down
Loading
Loading