From c900c0d9cb6b432760629492334caf0de2d59746 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Mon, 3 Aug 2026 17:04:12 +0000 Subject: [PATCH 1/2] feat: Add per-run tmpdir wipe and read-only fixture mounts Give each opted-in sandbox run a private workdir via mkdtemp, mount optional host fixtures as read-only copies, and always dispose the tree when the run settles so side effects cannot leak across runs. --- README.md | 29 +++++- src/ephemeral-fs.ts | 170 ++++++++++++++++++++++++++++++++++ src/index.ts | 10 ++ src/sandbox.ts | 81 +++++++++------- src/worker.ts | 46 +++++++-- test/ephemeral-fs.test.ts | 190 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 486 insertions(+), 40 deletions(-) create mode 100644 src/ephemeral-fs.ts create mode 100644 test/ephemeral-fs.test.ts diff --git a/README.md b/README.md index 2e89e41..26fb998 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 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 copied in as read-only mounts, 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 @@ -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 mounts.** Host files and trees copied under relative keys, then locked read-only (mode bits plus Linux immutable flag when available). +- **Path-escape rejection.** Fixture keys reject `..`, absolute paths, and drive forms 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 @@ -149,6 +153,28 @@ 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 copied from host paths into the workspace as read-only files: + +```ts +import { runInWorker, isVerified, withEphemeralWorkspace } from "airlock"; + +const result = await runInWorker( + `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; mounted fixtures under it are read-only +}); +``` + ## Develop ```bash @@ -165,3 +191,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 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; host originals stay untouched. diff --git a/src/ephemeral-fs.ts b/src/ephemeral-fs.ts new file mode 100644 index 0000000..1d80842 --- /dev/null +++ b/src/ephemeral-fs.ts @@ -0,0 +1,170 @@ +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>; + /** 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; +} + +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.startsWith("../") || + normalized.includes("/../") || + path.posix.isAbsolute(normalized) + ) { + throw new FixturePathError(key, "must not escape the workspace root"); + } +} + +export async function createEphemeralWorkspace( + opts: EphemeralFsOptions = {}, +): Promise { + 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( + opts: EphemeralFsOptions | undefined, + fn: (workspace: EphemeralWorkspace) => Promise, +): Promise { + const workspace = await createEphemeralWorkspace(opts ?? {}); + try { + return await fn(workspace); + } finally { + await workspace.dispose(); + } +} + +async function mountReadOnly(hostPath: string, dest: string): Promise { + const stat = await fs.stat(hostPath); + if (stat.isDirectory()) { + await fs.cp(hostPath, dest, { recursive: true, errorOnExist: true }); + 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 makeTreeReadOnly(dir: string): Promise { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + 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 { + await fs.chmod(filePath, 0o444); + await chattr(filePath, "+i"); +} + +async function chattr(filePath: string, flag: "+i" | "-i"): Promise { + 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 { + try { + await restoreWritable(root); + } catch { + // best-effort before force remove + } + await fs.rm(root, { recursive: true, force: true }); +} + +async function restoreWritable(target: string): Promise { + 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(() => {}); + } +} diff --git a/src/index.ts b/src/index.ts index 0b399c5..2ddbd4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/sandbox.ts b/src/sandbox.ts index d2a54b8..b8f89b1 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -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"; @@ -38,6 +42,8 @@ export interface SandboxRunOptions { * `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; @@ -104,47 +110,60 @@ export async function run( 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 = { ...(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 = { ...(grant ?? {}) }; + if (allowedModules !== undefined) { + bindings.require = buildSandboxRequire(allowedModules); + } + if (workspace) bindings.workdir = workspace.root; - const result = await runVerified( - () => - script.runInContext(context, { - timeout: timeoutMs, - breakOnSigint: true, - }) as T | Promise, - { - 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( + () => + script.runInContext(context, { + timeout: timeoutMs, + breakOnSigint: true, + }) as T | Promise, + { + 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 { diff --git a/src/worker.ts b/src/worker.ts index ea8a109..a578570 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,5 +1,9 @@ import { Worker } from "node:worker_threads"; import type { Assertion, RunResult } from "./contract.js"; +import { + createEphemeralWorkspace, + type EphemeralFsOptions, +} from "./ephemeral-fs.js"; import { checkOutputSize, validateResourceLimits } from "./limits.js"; import { createGatedRequire } from "./modules.js"; @@ -63,6 +67,8 @@ export interface WorkerRunOptions { maxOldGenerationSizeMb?: number; /** Refuse values whose measured UTF-8 payload exceeds this many bytes. */ maxOutputBytes?: number; + /** Per-run private dir as `workdir`, wiped on exit. `true` = empty workspace. */ + ephemeralFs?: true | EphemeralFsOptions; signal?: AbortSignal; filename?: string; } @@ -143,6 +149,23 @@ const createGatedRequire = ${createGatedRequire.toString()}; export function runInWorker( code: string, opts: WorkerRunOptions, +): Promise> { + // Validate before any await so bad limits still throw synchronously. + validateResourceLimits({ + timeoutMs: opts.timeoutMs, + ...(opts.maxOldGenerationSizeMb !== undefined + ? { maxOldGenerationSizeMb: opts.maxOldGenerationSizeMb } + : {}), + ...(opts.maxOutputBytes !== undefined + ? { maxOutputBytes: opts.maxOutputBytes } + : {}), + }); + return runInWorkerWithFs(code, opts); +} + +async function runInWorkerWithFs( + code: string, + opts: WorkerRunOptions, ): Promise> { const { timeoutMs, @@ -151,14 +174,18 @@ export function runInWorker( allowedModules, maxOldGenerationSizeMb, maxOutputBytes, + ephemeralFs, signal, filename, } = opts; - validateResourceLimits({ - timeoutMs, - ...(maxOldGenerationSizeMb !== undefined ? { maxOldGenerationSizeMb } : {}), - ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), - }); + + const workspace = + ephemeralFs === undefined + ? null + : await createEphemeralWorkspace(ephemeralFs === true ? {} : ephemeralFs); + + const grantPayload: Record = { ...(grant ?? {}) }; + if (workspace) grantPayload.workdir = workspace.root; let worker: Worker; try { @@ -167,7 +194,7 @@ export function runInWorker( env: {}, workerData: { code, - grant: grant ?? {}, + grant: grantPayload, timeoutMs, filename: filename ?? "airlock-worker.js", allowedModules: @@ -179,10 +206,12 @@ export function runInWorker( }); } catch (error) { // A non-cloneable grant (e.g. a function) fails at construction. - return Promise.resolve({ status: "error", error }); + if (workspace) await workspace.dispose(); + return { status: "error", error }; } const started = performance.now(); + const ws = workspace; return new Promise>((resolve) => { let settled = false; @@ -195,7 +224,8 @@ export function runInWorker( clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); void worker.terminate(); - resolve(result); + const done = ws ? ws.dispose() : Promise.resolve(); + void done.finally(() => resolve(result)); }; const timer = setTimeout(() => { diff --git a/test/ephemeral-fs.test.ts b/test/ephemeral-fs.test.ts new file mode 100644 index 0000000..266eb90 --- /dev/null +++ b/test/ephemeral-fs.test.ts @@ -0,0 +1,190 @@ +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + FixturePathError, + assertSafeFixtureKey, + createEphemeralWorkspace, + isVerified, + run, + runInWorker, + withEphemeralWorkspace, +} from "../src/index.js"; + +const leftovers: string[] = []; +afterEach(async () => { + for (const dir of leftovers.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } +}); + +async function hostFile(name: string, body: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-fixture-")); + leftovers.push(dir); + const file = path.join(dir, name); + await fs.writeFile(file, body, "utf8"); + return file; +} + +const RO_DENY = expect.stringMatching(/^(EACCES|EPERM)$/); + +describe("assertSafeFixtureKey", () => { + it.each(["", "../x", "..", "/abs", "C:\\win"])("rejects %s", (key) => { + expect(() => assertSafeFixtureKey(key)).toThrow(FixturePathError); + }); + it("accepts nested relative keys", () => { + expect(() => assertSafeFixtureKey("data/in.json")).not.toThrow(); + }); +}); + +describe("createEphemeralWorkspace", () => { + it("creates a writable root and wipes on dispose (idempotent)", async () => { + const ws = await createEphemeralWorkspace({ prefix: "airlock-ut-" }); + leftovers.push(ws.root); + expect(ws.fixturePaths).toEqual([]); + await fs.writeFile(path.join(ws.root, "scratch.txt"), "hi", "utf8"); + await ws.dispose(); + await expect(fs.stat(ws.root)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(ws.dispose()).resolves.toBeUndefined(); + }); + + it("mounts RO file/dir fixtures without mutating host; rejects path escape", async () => { + const seed = await hostFile("seed.txt", "fixture-body"); + const hostDir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-fixture-")); + leftovers.push(hostDir); + await fs.mkdir(path.join(hostDir, "nested"), { recursive: true }); + await fs.writeFile(path.join(hostDir, "nested", "a.txt"), "A", "utf8"); + + const ws = await createEphemeralWorkspace({ + fixtures: { "in/seed.txt": seed, pack: hostDir }, + }); + leftovers.push(ws.root); + const mounted = path.join(ws.root, "in/seed.txt"); + await expect(fs.readFile(mounted, "utf8")).resolves.toBe("fixture-body"); + await expect(fs.writeFile(mounted, "x", "utf8")).rejects.toMatchObject({ + code: RO_DENY, + }); + await expect(fs.readFile(seed, "utf8")).resolves.toBe("fixture-body"); + await expect( + fs.readFile(path.join(ws.root, "pack/nested/a.txt"), "utf8"), + ).resolves.toBe("A"); + await ws.dispose(); + await expect(fs.stat(mounted)).rejects.toMatchObject({ code: "ENOENT" }); + + const before = await fs.readdir(os.tmpdir()); + await expect( + createEphemeralWorkspace({ fixtures: { "../escape.txt": seed } }), + ).rejects.toBeInstanceOf(FixturePathError); + const after = await fs.readdir(os.tmpdir()); + expect( + after.filter((n) => n.startsWith("airlock-") && !before.includes(n)), + ).toEqual([]); + }); + + it("isolates concurrent workspaces", async () => { + const [a, b] = await Promise.all([ + createEphemeralWorkspace({ prefix: "airlock-c-" }), + createEphemeralWorkspace({ prefix: "airlock-c-" }), + ]); + leftovers.push(a.root, b.root); + expect(a.root).not.toBe(b.root); + await fs.writeFile(path.join(a.root, "only-a"), "1", "utf8"); + await expect(fs.stat(path.join(b.root, "only-a"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await Promise.all([a.dispose(), b.dispose()]); + }); +}); + +describe("withEphemeralWorkspace", () => { + it("wipes after success and after throw", async () => { + let successRoot = ""; + await withEphemeralWorkspace({}, async (ws) => { + successRoot = ws.root; + }); + await expect(fs.stat(successRoot)).rejects.toMatchObject({ code: "ENOENT" }); + + let failRoot = ""; + await expect( + withEphemeralWorkspace({}, async (ws) => { + failRoot = ws.root; + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + await expect(fs.stat(failRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("run / runInWorker with ephemeralFs", () => { + it("injects workdir, overrides grant, wipes after run and after worker error", async () => { + const result = await run("workdir", { + timeoutMs: 100, + assert: (v) => typeof v === "string" && v.length > 0, + ephemeralFs: true, + grant: { workdir: "forged" }, + }); + expect(isVerified(result)).toBe(true); + if (isVerified(result)) { + expect(result.value).not.toBe("forged"); + await expect(fs.stat(result.value)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + + const seed = await hostFile("answer.txt", "42"); + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-base-")); + leftovers.push(baseDir); + + const failed = await runInWorker( + `(() => { + const fs = require('node:fs'); + const path = require('node:path'); + fs.writeFileSync(path.join(workdir, 'guest.out'), 'x'); + throw new Error('after-write'); + })()`, + { + timeoutMs: 2000, + assert: () => true, + ephemeralFs: { baseDir, prefix: "run-" }, + allowedModules: ["fs", "path"], + }, + ); + expect(failed.status).toBe("error"); + expect( + (await fs.readdir(baseDir)).filter((n) => n.startsWith("run-")), + ).toEqual([]); + + const ok = await runInWorker<{ body: string; writeCode: string; workdir: string }>( + `(() => { + const fs = require('node:fs'); + const path = require('node:path'); + const p = path.join(workdir, 'data', 'answer.txt'); + const body = fs.readFileSync(p, 'utf8'); + let writeCode = 'ok'; + try { fs.writeFileSync(p, 'nope'); } catch (e) { writeCode = e && e.code; } + return { body, writeCode, workdir }; + })()`, + { + timeoutMs: 2000, + assert: (v) => + v != null && + v.body === "42" && + (v.writeCode === "EACCES" || v.writeCode === "EPERM"), + ephemeralFs: { + baseDir, + prefix: "run-", + fixtures: { "data/answer.txt": seed }, + }, + allowedModules: ["fs", "path"], + }, + ); + expect(ok.status).toBe("ok"); + if (isVerified(ok)) { + await expect(fs.stat(ok.value.workdir)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + await expect(fs.readFile(seed, "utf8")).resolves.toBe("42"); + }); +}); From ab79c7a13374df76580538303a8144b32f67fbfe Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Mon, 3 Aug 2026 17:10:56 +0000 Subject: [PATCH 2/2] fix: refuse symlink fixture trees under ephemeral workdir fs.cp preserved host-pointing symlinks, so guest writes could mutate host files. Reject symlink hosts/trees, post-copy scan, regression tests, and accurate README (snapshots, not live mounts). --- README.md | 14 +++++----- src/ephemeral-fs.ts | 39 ++++++++++++++++++++++++++- src/worker.ts | 12 ++++----- test/ephemeral-fs.test.ts | 56 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 26fb998..e517eaf 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 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 copied in as read-only mounts, 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. +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 @@ -27,8 +27,8 @@ The first slices covered the verification contract, zero-credential `node:vm` ex - **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 mounts.** Host files and trees copied under relative keys, then locked read-only (mode bits plus Linux immutable flag when available). -- **Path-escape rejection.** Fixture keys reject `..`, absolute paths, and drive forms before join. +- **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`. @@ -153,7 +153,9 @@ 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 copied from host paths into the workspace as read-only files: +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"; @@ -171,7 +173,7 @@ 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; mounted fixtures under it are read-only + // ws.root is writable; snapshotted fixtures under it are read-only }); ``` @@ -191,4 +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 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; host originals stay untouched. +- `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`. diff --git a/src/ephemeral-fs.ts b/src/ephemeral-fs.ts index 1d80842..120689d 100644 --- a/src/ephemeral-fs.ts +++ b/src/ephemeral-fs.ts @@ -40,6 +40,8 @@ export function assertSafeFixtureKey(key: string): void { } const normalized = path.posix.normalize(key.replace(/\\/g, "/")); if ( + normalized === "." || + normalized === "" || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../") || @@ -97,9 +99,18 @@ export async function withEphemeralWorkspace( } async function mountReadOnly(hostPath: string, dest: string): Promise { - const stat = await fs.stat(hostPath); + // 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; } @@ -111,9 +122,35 @@ async function mountReadOnly(hostPath: string, dest: string): Promise { await lockReadOnly(dest); } +async function assertNoSymlinksInTree(root: string): Promise { + 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 { 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); diff --git a/src/worker.ts b/src/worker.ts index a578570..d5cc79b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -215,17 +215,17 @@ async function runInWorkerWithFs( return new Promise>((resolve) => { let settled = false; - // terminate() is fire-and-forget: the OS reclaims the thread even if the - // promise races with a late message. finish is the single exit path so - // deadline, abort, OOM, and normal completion all hard-kill the isolate. + // finish is the single exit path: terminate the isolate first, then wipe + // the workdir so dispose cannot race a still-live guest (guaranteed wipe). const finish = (result: RunResult) => { if (settled) return; settled = true; clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); - void worker.terminate(); - const done = ws ? ws.dispose() : Promise.resolve(); - void done.finally(() => resolve(result)); + void worker + .terminate() + .finally(() => (ws ? ws.dispose() : Promise.resolve())) + .finally(() => resolve(result)); }; const timer = setTimeout(() => { diff --git a/test/ephemeral-fs.test.ts b/test/ephemeral-fs.test.ts index 266eb90..7fad13d 100644 --- a/test/ephemeral-fs.test.ts +++ b/test/ephemeral-fs.test.ts @@ -30,9 +30,12 @@ async function hostFile(name: string, body: string): Promise { const RO_DENY = expect.stringMatching(/^(EACCES|EPERM)$/); describe("assertSafeFixtureKey", () => { - it.each(["", "../x", "..", "/abs", "C:\\win"])("rejects %s", (key) => { - expect(() => assertSafeFixtureKey(key)).toThrow(FixturePathError); - }); + it.each(["", "../x", "..", "/abs", "C:\\win", "foo/..", "."])( + "rejects %s", + (key) => { + expect(() => assertSafeFixtureKey(key)).toThrow(FixturePathError); + }, + ); it("accepts nested relative keys", () => { expect(() => assertSafeFixtureKey("data/in.json")).not.toThrow(); }); @@ -95,6 +98,31 @@ describe("createEphemeralWorkspace", () => { }); await Promise.all([a.dispose(), b.dispose()]); }); + + it("refuses directory fixtures that contain host-pointing symlinks", async () => { + const secretDir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-host-")); + leftovers.push(secretDir); + const hostSecret = path.join(secretDir, "owned.txt"); + await fs.writeFile(hostSecret, "HOST-ORIGINAL", "utf8"); + + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-fix-")); + leftovers.push(fixtureDir); + await fs.symlink(hostSecret, path.join(fixtureDir, "link")); + + await expect( + createEphemeralWorkspace({ fixtures: { pack: fixtureDir } }), + ).rejects.toBeInstanceOf(FixturePathError); + + await expect(fs.readFile(hostSecret, "utf8")).resolves.toBe("HOST-ORIGINAL"); + + // Top-level host path that is itself a symlink is also refused. + const linkAsRoot = path.join(secretDir, "dir-link"); + await fs.symlink(fixtureDir, linkAsRoot); + await expect( + createEphemeralWorkspace({ fixtures: { pack: linkAsRoot } }), + ).rejects.toBeInstanceOf(FixturePathError); + await expect(fs.readFile(hostSecret, "utf8")).resolves.toBe("HOST-ORIGINAL"); + }); }); describe("withEphemeralWorkspace", () => { @@ -187,4 +215,26 @@ describe("run / runInWorker with ephemeralFs", () => { } await expect(fs.readFile(seed, "utf8")).resolves.toBe("42"); }); + + it("rejects symlink fixture trees via runInWorker and leaves host untouched", async () => { + const secretDir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-host-")); + leftovers.push(secretDir); + const hostSecret = path.join(secretDir, "owned.txt"); + await fs.writeFile(hostSecret, "HOST-ORIGINAL", "utf8"); + + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "airlock-fix-")); + leftovers.push(fixtureDir); + await fs.symlink(hostSecret, path.join(fixtureDir, "link")); + + await expect( + runInWorker("1", { + timeoutMs: 1000, + assert: () => true, + ephemeralFs: { fixtures: { pack: fixtureDir } }, + allowedModules: ["fs", "path"], + }), + ).rejects.toBeInstanceOf(FixturePathError); + + await expect(fs.readFile(hostSecret, "utf8")).resolves.toBe("HOST-ORIGINAL"); + }); });