From 675529bdcb17dbec11877b1efb4cc876996fe614 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Wed, 5 Aug 2026 20:06:51 +0000 Subject: [PATCH 1/2] feat: Add Docker-backed executor with network-none and read-only root runInDocker implements the same RunResult contract as run/runInWorker inside a disposable container: no network, read-only rootfs, dropped caps, and no host env. Named containers are force-removed on abort so guests cannot orphan. --- README.md | 21 +++- package.json | 2 + src/docker.ts | 301 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 5 + test/docker.test.ts | 167 ++++++++++++++++++++++++ 5 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/docker.ts create mode 100644 test/docker.test.ts diff --git a/README.md b/README.md index 2e89e41..9a8e40a 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**. This slice adds the **Docker-backed tier**: `runInDocker(code, opts)` runs the same contract inside a container with `--network=none`, a read-only root filesystem, dropped capabilities, and no host env, so an escape from the V8 realm still cannot reach the host's network, credentials, or writable disk. ## Concepts demonstrated @@ -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. +- **OS-level container isolation.** `runInDocker` uses Linux namespaces and cgroups (`--network=none`, `--read-only` + tmpfs, `--cap-drop=ALL`, `no-new-privileges`, non-root uid) behind the same `RunResult` contract as the vm and worker tiers. +- **Escape-attempt tests as documentation.** Host env, outbound connect, write outside tmpfs, and host-only marker paths pin what the container tier blocks. - **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`. ## The primitive contract @@ -149,6 +151,22 @@ await run("require('path')", { // -> { status: "error", error: ModuleNotAllowedError } ``` +For the strongest isolation tier, `runInDocker` runs the same source in a disposable container with no network, a read-only rootfs, dropped caps, and no host env. Grants must be JSON-serializable. Requires Docker (default image `node:20-alpine`). + +```ts +import { runInDocker, isVerified, dockerSecurityArgs } from "airlock"; + +console.log(dockerSecurityArgs({ maxMemoryMb: 128 })); // --network=none, --read-only, ... + +const result = await runInDocker("rows.reduce((s, r) => s + r.n, 0)", { + timeoutMs: 30_000, + assert: (total) => total === 6, + grant: { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] }, + maxMemoryMb: 128, +}); +if (isVerified(result)) console.log("verified:", result.value); // 6 +``` + ## Develop ```bash @@ -165,3 +183,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/docker.ts`: `runInDocker(code, opts)` runs untrusted source behind the same `RunResult` interface inside Docker with `--network=none`, `--read-only` (+ tmpfs `/tmp`), `--cap-drop=ALL`, `no-new-privileges`, and uid `65534`. Host env is not inherited. Named containers are force-removed on abort/timeout so guests cannot orphan. Host-collected stdout is byte-capped. `dockerSecurityArgs` exposes the posture for audit. Escape-attempt tests cover host env, outbound connect, read-only writes, and host-only marker paths. Live tests skip when the daemon is unavailable. diff --git a/package.json b/package.json index 131bbdb..fab9baa 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "untrusted-code", "agent-safety", "capability-security", + "docker", + "container-isolation", "typescript" ], "license": "MIT", diff --git a/src/docker.ts b/src/docker.ts new file mode 100644 index 0000000..f0880f2 --- /dev/null +++ b/src/docker.ts @@ -0,0 +1,301 @@ +import { randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Assertion, RunResult } from "./contract.js"; +import { checkOutputSize, validateResourceLimits } from "./limits.js"; + +export const DEFAULT_DOCKER_IMAGE = "node:20-alpine"; +export const DOCKER_CONTAINER_NAME_PREFIX = "airlock-"; +export const DEFAULT_MAX_WIRE_BYTES = 1_048_576; +const WIRE_FRAMING_SLACK = 65_536; +const STDERR_DIAG_CAP = 4_096; +const SYNC_TIMEOUT_CODE = "ERR_SCRIPT_EXECUTION_TIMEOUT"; +const OOM_EXIT = 137; + +export interface DockerRunOptions { + timeoutMs: number; + assert: Assertion; + /** JSON-serializable capabilities only. */ + grant?: Readonly>; + maxMemoryMb?: number; + maxOutputBytes?: number; + signal?: AbortSignal; + image?: string; + dockerPath?: string; +} + +export interface DockerSecurityOptions { maxMemoryMb?: number; } + +export function wireByteLimit(maxOutputBytes?: number): number { + return maxOutputBytes === undefined + ? DEFAULT_MAX_WIRE_BYTES + : Math.max(maxOutputBytes + WIRE_FRAMING_SLACK, WIRE_FRAMING_SLACK); +} + +export function uniqueContainerName(): string { + return `${DOCKER_CONTAINER_NAME_PREFIX}${process.pid}-${randomBytes(8).toString("hex")}`; +} + +/** Hardening flags shared by every container run. */ + +export function dockerSecurityArgs(opts: DockerSecurityOptions = {}): string[] { + const args = [ + "--network=none", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--user", "65534:65534", "--env", "HOME=/tmp", + ]; + if (opts.maxMemoryMb !== undefined) { + args.push("--memory", `${opts.maxMemoryMb}m`, "--memory-swap", `${opts.maxMemoryMb}m`); + } + return args; +} + +// Guest is self-contained. settleWithDeadline covers never-settling async: +// cross-realm vm Promises do not pin the event loop on their own. + +const GUEST_SOURCE = [ + "'use strict';", + "const fs=require('node:fs'),vm=require('node:vm');", + "function settleWithDeadline(v,ms){return new Promise((res,rej)=>{const t=setTimeout(()=>{const e=new Error('deadline exceeded');Object.defineProperty(e,'code',{value:'ERR_SCRIPT_EXECUTION_TIMEOUT'});rej(e);},ms);Promise.resolve(v).then(x=>{clearTimeout(t);res(x);},e=>{clearTimeout(t);rej(e);});});}", + "(async()=>{try{const p=JSON.parse(fs.readFileSync('/airlock/payload.json','utf8'));const c=vm.createContext(Object.assign({},p.grant||{}));const s=new vm.Script(p.code,{filename:p.filename||'airlock-docker.js'});const value=await settleWithDeadline(s.runInContext(c,{timeout:p.timeoutMs}),p.timeoutMs);fs.writeSync(1,JSON.stringify({ok:true,value})+'\\n');}catch(error){fs.writeSync(1,JSON.stringify({ok:false,error:{name:error&&error.name,message:error&&error.message,stack:error&&error.stack,code:error&&error.code}})+'\\n');}})();", +].join("\n"); + +type GuestErr = { name?: string; message?: string; stack?: string; code?: string }; +type GuestMessage = { ok: true; value: unknown } | { ok: false; error: GuestErr }; +type SpawnSignal = "timeout" | "abort" | "output-too-large" | null; + +/** + * Run untrusted source in Docker (`--network=none`, `--read-only`, dropped + * caps, no host env) behind the same {@link RunResult} contract as + * {@link run} / {@link runInWorker}. Requires a docker daemon. + */ + +export async function runInDocker( + code: string, + opts: DockerRunOptions, +): Promise> { + const { timeoutMs, assert, grant, maxMemoryMb, maxOutputBytes, signal, + image = DEFAULT_DOCKER_IMAGE, dockerPath = "docker" } = opts; + validateResourceLimits({ + timeoutMs, + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + }); + if (maxMemoryMb !== undefined && (!Number.isInteger(maxMemoryMb) || maxMemoryMb <= 0)) { + throw new RangeError("maxMemoryMb must be a positive integer"); + } + let payloadJson: string; + try { + payloadJson = serializePayload({ + code, + grant: grant ?? {}, + timeoutMs, + filename: "airlock-docker.js", + }); + } catch (error) { + return { status: "error", error }; + } + const workdir = await mkdtemp(join(tmpdir(), "airlock-docker-")); + const containerName = uniqueContainerName(); + const maxWireBytes = wireByteLimit(maxOutputBytes); + const started = performance.now(); + try { + await writeFile(join(workdir, "guest.js"), GUEST_SOURCE, "utf8"); + await writeFile(join(workdir, "payload.json"), payloadJson, "utf8"); + // nobody (65534) cannot read a 0o700 mkdtemp dir. + await chmod(workdir, 0o755); + await chmod(join(workdir, "guest.js"), 0o444); + await chmod(join(workdir, "payload.json"), 0o444); + const spawned = await spawnDocker({ + dockerPath, + args: [ + "run", "--rm", "--name", containerName, + ...dockerSecurityArgs({ ...(maxMemoryMb !== undefined ? { maxMemoryMb } : {}) }), + "--mount", `type=bind,source=${workdir},target=/airlock,readonly`, + image, "node", "/airlock/guest.js", + ], + timeoutMs, signal, containerName, maxWireBytes, + }); + if (spawned.signalled === "timeout") return { status: "timeout", timeoutMs }; + if (spawned.signalled === "abort") return { status: "error", error: signal?.reason }; + if (spawned.signalled === "output-too-large") { + return { + status: "output-too-large", + maxOutputBytes: maxOutputBytes ?? maxWireBytes, + actualBytes: spawned.wireBytes, + }; + } + // Prefer a parseable envelope over exit code. Map 137 → OOM only when a + // memory ceiling was set and the guest produced no harness line. + const message = parseGuestStdout(spawned.stdout); + if (!message) { + if (spawned.exitCode === OOM_EXIT && maxMemoryMb !== undefined) { + return { status: "out-of-memory", maxOldGenerationSizeMb: maxMemoryMb }; + } + const detail = (spawned.stdout || spawned.stderr).slice(0, 500); + return { + status: "error", + error: new Error( + spawned.exitCode === 0 + ? "docker guest produced no parseable result" + : `docker exited with code ${spawned.exitCode}: ${detail}`, + ), + }; + } + if (!message.ok) { + if (message.error.code === SYNC_TIMEOUT_CODE) return { status: "timeout", timeoutMs }; + return { status: "error", error: reviveError(message.error) }; + } + const value = message.value as T; + if (maxOutputBytes !== undefined) { + const size = checkOutputSize(value, maxOutputBytes); + if (size.exceeded) { + return { status: "output-too-large", maxOutputBytes, actualBytes: size.bytes }; + } + } + try { + const passed = await assert(value); + return passed + ? { status: "ok", value, durationMs: performance.now() - started } + : { status: "assertion-failed", value }; + } catch (error) { + return { status: "error", error }; + } + } finally { + await rm(workdir, { recursive: true, force: true }); + } +} + +function spawnDocker(opts: { + dockerPath: string; args: string[]; timeoutMs: number; + signal: AbortSignal | undefined; containerName: string; maxWireBytes: number; +}): Promise<{ stdout: string; stderr: string; exitCode: number | null; signalled: SpawnSignal; wireBytes: number }> { + const { dockerPath, args, timeoutMs, signal, containerName, maxWireBytes } = opts; + return new Promise((resolve) => { + let settled = false; + let stopping = false; + let signalled: SpawnSignal = null; + let wireBytes = 0; + const chunks: Buffer[] = []; + let stderr = ""; + const child = spawn(dockerPath, args, { + stdio: ["ignore", "pipe", "pipe"], + env: dockerCliEnv(), + }); + const finish = (exitCode: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve({ stdout: Buffer.concat(chunks).toString("utf8"), stderr, exitCode, signalled, wireBytes }); + }; + const stop = (reason: Exclude) => { + if (stopping || settled) return; + stopping = true; + signalled = reason; + // Container first so a SIGKILL'd CLI cannot orphan the guest. + forceRemoveContainer(dockerPath, containerName).finally(() => { + try { + child.kill("SIGKILL"); + } catch { + /* gone */ + } + }); + }; + const timer = setTimeout(() => stop("timeout"), timeoutMs); + const onAbort = () => stop("abort"); + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + child.stdout?.on("data", (chunk: Buffer) => { + if (settled || stopping) return; + wireBytes += chunk.length; + if (wireBytes > maxWireBytes) { + stop("output-too-large"); + return; + } + chunks.push(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + if (stderr.length < STDERR_DIAG_CAP) { + stderr = (stderr + chunk.toString("utf8")).slice(0, STDERR_DIAG_CAP); + } + }); + child.on("error", (e) => { + if (stderr.length < STDERR_DIAG_CAP) { + stderr = (stderr + e.message).slice(0, STDERR_DIAG_CAP); + } + finish(1); + }); + child.on("close", (code) => finish(code)); + }); +} + +function forceRemoveContainer(dockerPath: string, name: string): Promise { + return new Promise((resolve) => { + const killer = spawn(dockerPath, ["rm", "-f", name], { + stdio: "ignore", + env: dockerCliEnv(), + }); + killer.on("error", () => resolve()); + killer.on("close", () => resolve()); + }); +} + +function parseGuestStdout(stdout: string): GuestMessage | null { + const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line?.startsWith("{")) continue; + try { + const parsed: unknown = JSON.parse(line); + if ( + typeof parsed === "object" && + parsed !== null && + "ok" in parsed && + typeof (parsed as { ok: unknown }).ok === "boolean" + ) { + return parsed as GuestMessage; + } + } catch { + /* scan */ + } + } + return null; +} + +function reviveError(shape: GuestErr): Error { + const error = new Error(shape.message ?? "docker guest error"); + if (shape.name) error.name = shape.name; + if (shape.stack) error.stack = shape.stack; + if (shape.code) (error as Error & { code?: string }).code = shape.code; + return error; +} + +export async function isDockerAvailable(dockerPath = "docker"): Promise { + return new Promise((resolve) => { + const child = spawn(dockerPath, ["info"], { stdio: "ignore", env: dockerCliEnv() }); + child.on("error", () => resolve(false)); + child.on("close", (code) => resolve(code === 0)); + }); +} + +function dockerCliEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "/usr/bin:/bin" }; + if (process.env.DOCKER_HOST) env.DOCKER_HOST = process.env.DOCKER_HOST; + if (process.env.HOME) env.HOME = process.env.HOME; + return env; +} + +/** Throw on functions/symbols/bigints so grants cannot silently drop fields. */ + +function serializePayload(payload: unknown): string { + return JSON.stringify(payload, (_k, value: unknown) => { + if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") { + throw new TypeError("grant must be JSON-serializable"); + } + return value; + }); +} diff --git a/src/index.ts b/src/index.ts index 0b399c5..20cda5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,11 @@ export { export type { SandboxRunOptions } from "./sandbox.js"; export { runInWorker, freezeRealm, FROZEN_INTRINSICS } from "./worker.js"; export type { WorkerRunOptions } from "./worker.js"; +export { + runInDocker, dockerSecurityArgs, isDockerAvailable, uniqueContainerName, + wireByteLimit, DEFAULT_DOCKER_IMAGE, DEFAULT_MAX_WIRE_BYTES, DOCKER_CONTAINER_NAME_PREFIX, +} from "./docker.js"; +export type { DockerRunOptions, DockerSecurityOptions } from "./docker.js"; export { ModuleNotAllowedError, expandAllowlist, diff --git a/test/docker.test.ts b/test/docker.test.ts new file mode 100644 index 0000000..b7e8fc2 --- /dev/null +++ b/test/docker.test.ts @@ -0,0 +1,167 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + DEFAULT_DOCKER_IMAGE, + DEFAULT_MAX_WIRE_BYTES, + DOCKER_CONTAINER_NAME_PREFIX, + dockerSecurityArgs, + isDockerAvailable, + isVerified, + runInDocker, + uniqueContainerName, + wireByteLimit, +} from "../src/index.js"; + +const dockerReady = await isDockerAvailable(); +const escape = (body: string) => + `this.constructor.constructor(${JSON.stringify(body)})()`; +describe("dockerSecurityArgs", () => { + it("pins network-none, read-only, cap-drop, names, and wire caps", () => { + expect(dockerSecurityArgs()).toEqual( + expect.arrayContaining([ + "--network=none", + "--read-only", + "--cap-drop", + "ALL", + "no-new-privileges", + "65534:65534", + ]), + ); + expect(dockerSecurityArgs({ maxMemoryMb: 64 })).toContain("64m"); + expect(uniqueContainerName().startsWith(DOCKER_CONTAINER_NAME_PREFIX)).toBe(true); + expect(wireByteLimit()).toBe(DEFAULT_MAX_WIRE_BYTES); + expect(wireByteLimit(64)).toBeGreaterThanOrEqual(64); + }); +}); +describe.runIf(dockerReady)("runInDocker", () => { + it("covers contract, limits, abort cleanup, and isolation escapes", async () => { + const ok = await runInDocker("40 + 2", { + timeoutMs: 60_000, + assert: (v) => v === 42, + }); + expect(ok.status).toBe("ok"); + if (isVerified(ok)) expect(ok.value).toBe(42); + expect( + await runInDocker("41", { timeoutMs: 60_000, assert: (v) => v === 42 }), + ).toEqual({ status: "assertion-failed", value: 41 }); + expect( + await runInDocker("rows.length + base", { + timeoutMs: 60_000, + assert: (v) => v === 5, + grant: { rows: [1, 2, 3], base: 2 }, + }), + ).toMatchObject({ status: "ok", value: 5 }); + expect( + await runInDocker("Promise.resolve('hi')", { + timeoutMs: 60_000, + assert: (v) => v === "hi", + }), + ).toMatchObject({ status: "ok", value: "hi" }); + expect( + ( + await runInDocker("1", { + timeoutMs: 5_000, + assert: () => true, + grant: { add: (a: number, b: number) => a + b }, + }) + ).status, + ).toBe("error"); + const threw = await runInDocker("throw new Error('boom')", { + timeoutMs: 60_000, + assert: () => true, + }); + expect(threw.status).toBe("error"); + if (threw.status === "error") expect((threw.error as Error).message).toBe("boom"); + expect( + await runInDocker("while (true) {}", { timeoutMs: 2_000, assert: () => true }), + ).toEqual({ status: "timeout", timeoutMs: 2_000 }); + expect( + await runInDocker("new Promise(() => {})", { timeoutMs: 1_500, assert: () => true }), + ).toEqual({ status: "timeout", timeoutMs: 1_500 }); + const controller = new AbortController(); + const reason = new Error("cancelled"); + const pending = runInDocker("new Promise(() => {})", { + timeoutMs: 120_000, + assert: () => true, + signal: controller.signal, + }); + setTimeout(() => controller.abort(reason), 300); + expect(await pending).toEqual({ status: "error", error: reason }); + await new Promise((r) => setTimeout(r, 400)); + const ps = spawnSync( + "docker", + ["ps", "--filter", `name=${DOCKER_CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], + { encoding: "utf8" }, + ); + expect((ps.stdout ?? "").trim()).toBe(""); + await expect(runInDocker("1", { timeoutMs: 0, assert: () => true })).rejects.toThrow( + RangeError, + ); + expect( + ( + await runInDocker("'x'.repeat(10_000)", { + timeoutMs: 60_000, + assert: () => true, + maxOutputBytes: 64, + }) + ).status, + ).toBe("output-too-large"); + const key = "AIRLOCK_HOST_SECRET"; + process.env[key] = "super-secret-token"; + try { + expect( + ( + await runInDocker(escape(`return process.env.${key}`), { + timeoutMs: 60_000, + assert: (v) => v === undefined || v === "", + }) + ).status, + ).toBe("ok"); + } finally { + delete process.env[key]; + } + const net = await runInDocker( + escape( + "const n=process.getBuiltinModule('net');return new Promise(r=>{const s=n.connect(80,'1.1.1.1',()=>r('connected'));s.on('error',e=>r(e.code||e.message));setTimeout(()=>{s.destroy();r('hung');},2000);});", + ), + { timeoutMs: 60_000, assert: (v) => v !== "connected" }, + ); + expect(net.status).toBe("ok"); + if (isVerified(net)) expect(net.value).not.toBe("connected"); + const write = await runInDocker( + escape( + "const fs=process.getBuiltinModule('fs');try{fs.writeFileSync('/etc/airlock-write-test','x');return 'wrote';}catch(e){return e.code||e.message;}", + ), + { timeoutMs: 60_000, assert: (v) => v !== "wrote" }, + ); + expect(write.status).toBe("ok"); + if (isVerified(write)) expect(["EROFS", "EACCES"]).toContain(write.value); + const markerPath = join(tmpdir(), `airlock-host-only-${randomBytes(8).toString("hex")}`); + writeFileSync(markerPath, "host-only", "utf8"); + try { + const probe = await runInDocker( + escape( + `const fs=process.getBuiltinModule('fs');return fs.existsSync(${JSON.stringify(markerPath)});`, + ), + { timeoutMs: 60_000, assert: (v) => v === false }, + ); + expect(probe).toMatchObject({ status: "ok", value: false }); + } finally { + try { + unlinkSync(markerPath); + } catch { + /* ignore */ + } + } + expect(DEFAULT_DOCKER_IMAGE).toBe("node:20-alpine"); + }, 240_000); +}); +describe.runIf(!dockerReady)("runInDocker (docker unavailable)", () => { + it("skips live cases when the daemon is unreachable", () => { + expect(dockerReady).toBe(false); + }); +}); From c53f844da4de147a5647d1f0a88e90a7cc2e73ae Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Wed, 5 Aug 2026 20:15:32 +0000 Subject: [PATCH 2/2] fix: round-trip Docker return values via v8.serialize JSON.stringify mangled NaN/Infinity/Map/TypedArray while still returning status ok. Guest results now use a framed v8.serialize channel (AIRLOCK1:base64) so structured-clone types match the worker contract; non-cloneable values fail closed. Split docker live tests into contract, fidelity, OOM, and isolation suites; document the transport in the README. --- README.md | 8 ++- src/docker.ts | 44 +++++++++++-- src/index.ts | 1 + test/docker.test.ts | 149 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 190 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9a8e40a..757da58 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,11 @@ await run("require('path')", { // -> { status: "error", error: ModuleNotAllowedError } ``` -For the strongest isolation tier, `runInDocker` runs the same source in a disposable container with no network, a read-only rootfs, dropped caps, and no host env. Grants must be JSON-serializable. Requires Docker (default image `node:20-alpine`). +For the strongest isolation tier, `runInDocker` runs the same source in a disposable container with no network, a read-only rootfs, dropped caps, and no host env. Requires Docker (default image `node:20-alpine`). + +**Wire format.** Grants still enter the guest as JSON (functions/symbols/bigints are refused before spawn). **Return values** leave the guest via `node:v8` `serialize` / host `deserialize` (structured clone), framed on stdout as `AIRLOCK1:`. That preserves `NaN`, `Infinity`, `Map`, `Set`, `Date`, and `TypedArray` the same way `runInWorker` does. Values that are not structured-cloneable fail closed as `{ status: "error" }` rather than a corrupted `ok`. There is no `allowedModules` on this tier: the guest has no `require` binding (escapes that reach the Node realm still run under the container posture only). + +`timeoutMs` is wall-clock from Docker CLI spawn and includes cold image start. `maxMemoryMb` sets the container cgroup memory ceiling; on OOM the result reuses the shared `maxOldGenerationSizeMb` field for `RunResult` parity (it is the cgroup ceiling, not a V8 old-gen limit). ```ts import { runInDocker, isVerified, dockerSecurityArgs } from "airlock"; @@ -183,4 +187,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/docker.ts`: `runInDocker(code, opts)` runs untrusted source behind the same `RunResult` interface inside Docker with `--network=none`, `--read-only` (+ tmpfs `/tmp`), `--cap-drop=ALL`, `no-new-privileges`, and uid `65534`. Host env is not inherited. Named containers are force-removed on abort/timeout so guests cannot orphan. Host-collected stdout is byte-capped. `dockerSecurityArgs` exposes the posture for audit. Escape-attempt tests cover host env, outbound connect, read-only writes, and host-only marker paths. Live tests skip when the daemon is unavailable. +- `src/docker.ts`: `runInDocker(code, opts)` runs untrusted source behind the same `RunResult` interface inside Docker with `--network=none`, `--read-only` (+ tmpfs `/tmp`), `--cap-drop=ALL`, `no-new-privileges`, and uid `65534`. Host env is not inherited. Named containers are force-removed on abort/timeout so guests cannot orphan. Host-collected stdout is byte-capped. Return values use a framed `v8.serialize` channel (`AIRLOCK1:`) so structured-clone types match worker fidelity; non-cloneable values fail closed. `dockerSecurityArgs` exposes the posture for audit. Tests cover the contract, value fidelity (NaN/Map/Uint8Array/Date), cgroup OOM, and isolation escapes. Live tests skip when the daemon is unavailable. diff --git a/src/docker.ts b/src/docker.ts index f0880f2..f8e0a16 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -3,22 +3,30 @@ import { spawn } from "node:child_process"; import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { deserialize } from "node:v8"; import type { Assertion, RunResult } from "./contract.js"; import { checkOutputSize, validateResourceLimits } from "./limits.js"; export const DEFAULT_DOCKER_IMAGE = "node:20-alpine"; export const DOCKER_CONTAINER_NAME_PREFIX = "airlock-"; export const DEFAULT_MAX_WIRE_BYTES = 1_048_576; +/** Framed result line prefix: AIRLOCK1: */ +export const DOCKER_WIRE_PREFIX = "AIRLOCK1:"; const WIRE_FRAMING_SLACK = 65_536; const STDERR_DIAG_CAP = 4_096; const SYNC_TIMEOUT_CODE = "ERR_SCRIPT_EXECUTION_TIMEOUT"; const OOM_EXIT = 137; +const FORCE_RM_TIMEOUT_MS = 5_000; export interface DockerRunOptions { timeoutMs: number; assert: Assertion; /** JSON-serializable capabilities only. */ grant?: Readonly>; + /** + * Container cgroup memory ceiling in MiB (also reported as + * `maxOldGenerationSizeMb` on out-of-memory for RunResult union parity). + */ maxMemoryMb?: number; maxOutputBytes?: number; signal?: AbortSignal; @@ -54,12 +62,16 @@ export function dockerSecurityArgs(opts: DockerSecurityOptions = {}): string[] { // Guest is self-contained. settleWithDeadline covers never-settling async: // cross-realm vm Promises do not pin the event loop on their own. +// Result channel is v8.serialize (structured clone) framed as AIRLOCK1: +// so NaN/Infinity/Map/Date/TypedArray round-trip; non-cloneable values fail closed. const GUEST_SOURCE = [ "'use strict';", - "const fs=require('node:fs'),vm=require('node:vm');", + "const fs=require('node:fs'),vm=require('node:vm'),v8=require('node:v8');", + "function frame(msg){return 'AIRLOCK1:'+v8.serialize(msg).toString('base64')+'\\n';}", + "function emit(msg){try{fs.writeSync(1,frame(msg));}catch(ser){try{fs.writeSync(1,frame({ok:false,error:{name:'TypeError',message:String(ser&&ser.message||ser),code:'ERR_AIRLOCK_VALUE_NOT_CLONEABLE'}}));}catch(_){fs.writeSync(1,'AIRLOCK1:FAIL\\n');}}}", "function settleWithDeadline(v,ms){return new Promise((res,rej)=>{const t=setTimeout(()=>{const e=new Error('deadline exceeded');Object.defineProperty(e,'code',{value:'ERR_SCRIPT_EXECUTION_TIMEOUT'});rej(e);},ms);Promise.resolve(v).then(x=>{clearTimeout(t);res(x);},e=>{clearTimeout(t);rej(e);});});}", - "(async()=>{try{const p=JSON.parse(fs.readFileSync('/airlock/payload.json','utf8'));const c=vm.createContext(Object.assign({},p.grant||{}));const s=new vm.Script(p.code,{filename:p.filename||'airlock-docker.js'});const value=await settleWithDeadline(s.runInContext(c,{timeout:p.timeoutMs}),p.timeoutMs);fs.writeSync(1,JSON.stringify({ok:true,value})+'\\n');}catch(error){fs.writeSync(1,JSON.stringify({ok:false,error:{name:error&&error.name,message:error&&error.message,stack:error&&error.stack,code:error&&error.code}})+'\\n');}})();", + "(async()=>{try{const p=JSON.parse(fs.readFileSync('/airlock/payload.json','utf8'));const c=vm.createContext(Object.assign({},p.grant||{}));const s=new vm.Script(p.code,{filename:p.filename||'airlock-docker.js'});const value=await settleWithDeadline(s.runInContext(c,{timeout:p.timeoutMs}),p.timeoutMs);emit({ok:true,value});}catch(error){emit({ok:false,error:{name:error&&error.name,message:error&&error.message,stack:error&&error.stack,code:error&&error.code}});}})();", ].join("\n"); type GuestErr = { name?: string; message?: string; stack?: string; code?: string }; @@ -70,6 +82,10 @@ type SpawnSignal = "timeout" | "abort" | "output-too-large" | null; * Run untrusted source in Docker (`--network=none`, `--read-only`, dropped * caps, no host env) behind the same {@link RunResult} contract as * {@link run} / {@link runInWorker}. Requires a docker daemon. + * + * Return values cross the host boundary via `v8.serialize` / `v8.deserialize` + * (structured clone), matching worker fidelity for Map/Date/TypedArray/NaN/ + * Infinity. Non-cloneable values fail closed as `status: "error"`. */ export async function runInDocker( @@ -239,8 +255,22 @@ function forceRemoveContainer(dockerPath: string, name: string): Promise { stdio: "ignore", env: dockerCliEnv(), }); - killer.on("error", () => resolve()); - killer.on("close", () => resolve()); + const timer = setTimeout(() => { + try { + killer.kill("SIGKILL"); + } catch { + /* gone */ + } + resolve(); + }, FORCE_RM_TIMEOUT_MS); + killer.on("error", () => { + clearTimeout(timer); + resolve(); + }); + killer.on("close", () => { + clearTimeout(timer); + resolve(); + }); }); } @@ -248,9 +278,11 @@ function parseGuestStdout(stdout: string): GuestMessage | null { const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; - if (!line?.startsWith("{")) continue; + if (!line?.startsWith(DOCKER_WIRE_PREFIX)) continue; + const b64 = line.slice(DOCKER_WIRE_PREFIX.length); + if (!b64 || b64 === "FAIL") continue; try { - const parsed: unknown = JSON.parse(line); + const parsed: unknown = deserialize(Buffer.from(b64, "base64")); if ( typeof parsed === "object" && parsed !== null && diff --git a/src/index.ts b/src/index.ts index 20cda5f..d80eac5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export type { WorkerRunOptions } from "./worker.js"; export { runInDocker, dockerSecurityArgs, isDockerAvailable, uniqueContainerName, wireByteLimit, DEFAULT_DOCKER_IMAGE, DEFAULT_MAX_WIRE_BYTES, DOCKER_CONTAINER_NAME_PREFIX, + DOCKER_WIRE_PREFIX, } from "./docker.js"; export type { DockerRunOptions, DockerSecurityOptions } from "./docker.js"; export { diff --git a/test/docker.test.ts b/test/docker.test.ts index b7e8fc2..afa2cd2 100644 --- a/test/docker.test.ts +++ b/test/docker.test.ts @@ -19,6 +19,7 @@ import { const dockerReady = await isDockerAvailable(); const escape = (body: string) => `this.constructor.constructor(${JSON.stringify(body)})()`; + describe("dockerSecurityArgs", () => { it("pins network-none, read-only, cap-drop, names, and wire caps", () => { expect(dockerSecurityArgs()).toEqual( @@ -37,17 +38,24 @@ describe("dockerSecurityArgs", () => { expect(wireByteLimit(64)).toBeGreaterThanOrEqual(64); }); }); -describe.runIf(dockerReady)("runInDocker", () => { - it("covers contract, limits, abort cleanup, and isolation escapes", async () => { + +describe.runIf(dockerReady)("runInDocker contract", () => { + it("returns verified ok for a simple expression", async () => { const ok = await runInDocker("40 + 2", { timeoutMs: 60_000, assert: (v) => v === 42, }); expect(ok.status).toBe("ok"); if (isVerified(ok)) expect(ok.value).toBe(42); + }, 90_000); + + it("returns assertion-failed with the raw value", async () => { expect( await runInDocker("41", { timeoutMs: 60_000, assert: (v) => v === 42 }), ).toEqual({ status: "assertion-failed", value: 41 }); + }, 90_000); + + it("passes JSON-serializable grants into the guest", async () => { expect( await runInDocker("rows.length + base", { timeoutMs: 60_000, @@ -55,12 +63,18 @@ describe.runIf(dockerReady)("runInDocker", () => { grant: { rows: [1, 2, 3], base: 2 }, }), ).toMatchObject({ status: "ok", value: 5 }); + }, 90_000); + + it("awaits thenables from guest code", async () => { expect( await runInDocker("Promise.resolve('hi')", { timeoutMs: 60_000, assert: (v) => v === "hi", }), ).toMatchObject({ status: "ok", value: "hi" }); + }, 90_000); + + it("rejects non-JSON-serializable grants before docker spawn", async () => { expect( ( await runInDocker("1", { @@ -70,18 +84,27 @@ describe.runIf(dockerReady)("runInDocker", () => { }) ).status, ).toBe("error"); + }); + + it("surfaces thrown guest errors", async () => { const threw = await runInDocker("throw new Error('boom')", { timeoutMs: 60_000, assert: () => true, }); expect(threw.status).toBe("error"); if (threw.status === "error") expect((threw.error as Error).message).toBe("boom"); + }, 90_000); + + it("times out sync spins and never-settling promises", async () => { expect( await runInDocker("while (true) {}", { timeoutMs: 2_000, assert: () => true }), ).toEqual({ status: "timeout", timeoutMs: 2_000 }); expect( await runInDocker("new Promise(() => {})", { timeoutMs: 1_500, assert: () => true }), ).toEqual({ status: "timeout", timeoutMs: 1_500 }); + }, 90_000); + + it("aborts via signal and force-removes the named container", async () => { const controller = new AbortController(); const reason = new Error("cancelled"); const pending = runInDocker("new Promise(() => {})", { @@ -98,9 +121,15 @@ describe.runIf(dockerReady)("runInDocker", () => { { encoding: "utf8" }, ); expect((ps.stdout ?? "").trim()).toBe(""); + }, 90_000); + + it("rejects non-positive timeoutMs before spawn", async () => { await expect(runInDocker("1", { timeoutMs: 0, assert: () => true })).rejects.toThrow( RangeError, ); + }); + + it("refuses oversized output under maxOutputBytes", async () => { expect( ( await runInDocker("'x'.repeat(10_000)", { @@ -110,6 +139,109 @@ describe.runIf(dockerReady)("runInDocker", () => { }) ).status, ).toBe("output-too-large"); + }, 90_000); + + it("preserves DEFAULT_DOCKER_IMAGE pin", () => { + expect(DEFAULT_DOCKER_IMAGE).toBe("node:20-alpine"); + }); +}); + +describe.runIf(dockerReady)("runInDocker value fidelity", () => { + it("round-trips NaN under status ok (not null)", async () => { + const result = await runInDocker("NaN", { + timeoutMs: 60_000, + assert: (v) => Number.isNaN(v), + }); + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(Number.isNaN(result.value)).toBe(true); + expect(result.value).not.toBe(null); + } + }, 90_000); + + it("round-trips Infinity under status ok (not null)", async () => { + const result = await runInDocker("Infinity", { + timeoutMs: 60_000, + assert: (v) => v === Infinity, + }); + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(result.value).toBe(Infinity); + expect(result.value).not.toBe(null); + } + }, 90_000); + + it("round-trips Map entries under status ok", async () => { + const result = await runInDocker>("new Map([['a', 1], ['b', 2]])", { + timeoutMs: 60_000, + assert: (v) => v instanceof Map && v.get("a") === 1 && v.get("b") === 2, + }); + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(result.value).toBeInstanceOf(Map); + expect(result.value.size).toBe(2); + expect(result.value.get("a")).toBe(1); + expect(result.value.get("b")).toBe(2); + } + }, 90_000); + + it("round-trips Uint8Array under status ok", async () => { + const result = await runInDocker("new Uint8Array([1, 2, 3, 4])", { + timeoutMs: 60_000, + assert: (v) => v instanceof Uint8Array && v.length === 4 && v[0] === 1 && v[3] === 4, + }); + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(result.value).toBeInstanceOf(Uint8Array); + expect(Array.from(result.value)).toEqual([1, 2, 3, 4]); + } + }, 90_000); + + it("round-trips Date under status ok as a Date instance", async () => { + const result = await runInDocker("new Date('2020-01-15T12:00:00.000Z')", { + timeoutMs: 60_000, + assert: (v) => v instanceof Date && v.toISOString() === "2020-01-15T12:00:00.000Z", + }); + expect(result.status).toBe("ok"); + if (isVerified(result)) { + expect(result.value).toBeInstanceOf(Date); + expect(result.value.toISOString()).toBe("2020-01-15T12:00:00.000Z"); + } + }, 90_000); + + it("fails closed when the return value is not structured-cloneable", async () => { + const result = await runInDocker("() => 1", { + timeoutMs: 60_000, + assert: () => true, + }); + expect(result.status).toBe("error"); + if (result.status === "error") { + const err = result.error as Error & { code?: string }; + expect(err.code === "ERR_AIRLOCK_VALUE_NOT_CLONEABLE" || /clone|serialize|could not be cloned/i.test(err.message)).toBe( + true, + ); + } + }, 90_000); +}); + +describe.runIf(dockerReady)("runInDocker resource limits", () => { + it("reports out-of-memory when the cgroup ceiling is hit", async () => { + const maxMemoryMb = 32; + const result = await runInDocker( + escape( + "const acc=[];while(true){acc.push(Buffer.alloc(1<<20));}", + ), + { timeoutMs: 60_000, assert: () => true, maxMemoryMb }, + ); + expect(result.status).toBe("out-of-memory"); + if (result.status === "out-of-memory") { + expect(result.maxOldGenerationSizeMb).toBe(maxMemoryMb); + } + }, 90_000); +}); + +describe.runIf(dockerReady)("runInDocker isolation", () => { + it("does not leak host process.env into the guest", async () => { const key = "AIRLOCK_HOST_SECRET"; process.env[key] = "super-secret-token"; try { @@ -124,6 +256,9 @@ describe.runIf(dockerReady)("runInDocker", () => { } finally { delete process.env[key]; } + }, 90_000); + + it("blocks outbound network connects under network-none", async () => { const net = await runInDocker( escape( "const n=process.getBuiltinModule('net');return new Promise(r=>{const s=n.connect(80,'1.1.1.1',()=>r('connected'));s.on('error',e=>r(e.code||e.message));setTimeout(()=>{s.destroy();r('hung');},2000);});", @@ -132,6 +267,9 @@ describe.runIf(dockerReady)("runInDocker", () => { ); expect(net.status).toBe("ok"); if (isVerified(net)) expect(net.value).not.toBe("connected"); + }, 90_000); + + it("blocks writes to the read-only rootfs", async () => { const write = await runInDocker( escape( "const fs=process.getBuiltinModule('fs');try{fs.writeFileSync('/etc/airlock-write-test','x');return 'wrote';}catch(e){return e.code||e.message;}", @@ -140,6 +278,9 @@ describe.runIf(dockerReady)("runInDocker", () => { ); expect(write.status).toBe("ok"); if (isVerified(write)) expect(["EROFS", "EACCES"]).toContain(write.value); + }, 90_000); + + it("cannot see host-only marker paths outside the bind mount", async () => { const markerPath = join(tmpdir(), `airlock-host-only-${randomBytes(8).toString("hex")}`); writeFileSync(markerPath, "host-only", "utf8"); try { @@ -157,9 +298,9 @@ describe.runIf(dockerReady)("runInDocker", () => { /* ignore */ } } - expect(DEFAULT_DOCKER_IMAGE).toBe("node:20-alpine"); - }, 240_000); + }, 90_000); }); + describe.runIf(!dockerReady)("runInDocker (docker unavailable)", () => { it("skips live cases when the daemon is unreachable", () => { expect(dockerReady).toBe(false);