Ephemeral, zero-credential, self-verifying execution for untrusted or agent-written code.
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. This slice hardens the resource limit layer: wall-clock deadline with hard terminate on abort, V8 heap cap, and output size caps, so a runaway cannot wedge the host on time, memory, or a multi-megabyte return value. Later slices add a Docker-backed tier and a growing suite of documented escape-attempt tests.
- Verification-gated results. The output value is reachable only through the
okvariant of a discriminated union, so an unverified run is unrepresentable at the call site. - Post-condition contracts. A run is trusted when a caller-supplied assertion holds over its output, a design-by-contract style check applied to untrusted code.
- Deadline enforcement with cooperative cancellation. An internal timer races the task and aborts the
AbortSignalit runs under, composed with any caller-owned signal. - Hard preemption via worker termination. On the isolate tier, the wall-clock deadline and caller abort both call
worker.terminate(), reclaiming the OS thread instead of abandoning a hung task. - Resource isolation and ceilings. Three independent budgets gate every run: wall-clock time, V8 old-generation heap (
maxOldGenerationSizeMb), and measured UTF-8 output size (maxOutputBytes). Each maps to a distinct result status (timeout,out-of-memory,output-too-large). - Budgeted output metering. Output size is walked with an early-exit budget so a cyclic or enormous structure cannot hang the host meter, and oversized values never reach the post-condition as trusted results.
- Total error handling. Thrown errors, blown deadlines, failed assertions, OOM, and oversized output are all values in the result union rather than exceptions, so no failure mode escapes as a rejection.
- Capability-security framing. The task receives only the abort capability it needs;
runextends this to source code, which sees zero ambient authority and only the capabilities passed in thegrantobject. - Zero-credential invariant. Untrusted source runs in a
node:vmcontext that carries none of the host's authority: noprocess,process.env,require,fetch, timers, orBuffer. The invariant is a named denylist, probed at context-build time so a run fails closed if authority ever leaks in. - Realm isolation and its limits. The context has its own set of ECMAScript intrinsics, so a host secret on
globalThisis unreachable and the sandbox's ownFunctioncompiles in-realm. The known in-process gap,this.constructor.constructorreaching the host realm through the borrowed global prototype, is pinned by an escape-attempt test rather than hidden. - Thread-level isolation with
worker_threads.runInWorkerruns untrusted source in a dedicated V8 isolate on its own thread. The in-process constructor-walk escape reaches only the worker's realm, which is started with an emptyprocess.envand cannot see the host's environment. An escape-attempt test walks the same constructor chain and confirms a host secret placed inprocess.envstays out of reach. - Frozen realm hardening. Before any untrusted code runs, the worker freezes
globalThisand the core intrinsics and their prototypes, so an escape into the worker realm cannot repave shared state that later runs in the same isolate would rely on. - Layered preemption. A synchronous spin is killed by V8's
timeout; an async task that never settles is aborted by the deadline race (and terminated on the worker tier).runcomposes both so neither class of runaway can wedge the caller. - Strict TypeScript.
strict,noUncheckedIndexedAccess, andexactOptionalPropertyTypes, noany.
runVerified(task, { timeoutMs, assert, signal?, maxOutputBytes? }) -> RunResult
- The value is returned only as
{ status: "ok", value, durationMs }, and only when the task finished beforetimeoutMs, the payload stayed undermaxOutputByteswhen set, andassert(value)returned true. - Every other outcome is an explicit refusal:
timeout,assertion-failed(carries the value for diagnostics, never as trusted),output-too-large,out-of-memory, orerror. - The task is handed an
AbortSignalthat fires on the deadline or on the caller's own signal, so well-behaved async work can stop early. On the worker tier that same abort path also terminates the isolate.
The in-process tier cannot preempt code that blocks the event loop with a synchronous spin; that is what the isolate and container tiers are for. This tier defines the contract those tiers implement.
import { runVerified, isVerified } from "airlock";
const result = await runVerified(
async (signal) => {
const res = await fetch("https://example.com/data.json", { signal });
return (await res.json()) as { total: number };
},
{
timeoutMs: 2000,
maxOutputBytes: 64 * 1024,
assert: (data) => Number.isInteger(data.total) && data.total >= 0,
},
);
if (isVerified(result)) {
console.log("trusted output:", result.value.total);
} else {
console.warn("refused:", result.status);
}To run untrusted source code instead of a trusted closure, use run. The code executes with no ambient authority, so process, require, fetch, and timers are all undefined inside it. Any capability it needs is passed explicitly through grant:
import { run, isVerified } from "airlock";
const result = await run<number>(
"add(rows.length, 1)",
{
timeoutMs: 50,
maxOutputBytes: 1024,
assert: (n) => Number.isInteger(n) && n > 0,
grant: {
rows: [{ id: 1 }, { id: 2 }],
add: (a: number, b: number) => a + b,
},
},
);
if (isVerified(result)) {
console.log("verified:", result.value); // 3
}
// a synchronous infinite loop is preempted and reported as a timeout
await run("while (true) {}", { timeoutMs: 25, assert: () => true });
// -> { status: "timeout", timeoutMs: 25 }For stronger isolation, runInWorker runs the same source in a worker_threads isolate: a separate V8 heap and thread with an empty process.env, frozen globals, heap cap, and output size cap. The grant and the returned value cross by structured clone, so pass data rather than live functions. Deadline and caller abort both call worker.terminate().
import { runInWorker, isVerified } from "airlock";
const result = await runInWorker<number>(
"rows.reduce((sum, r) => sum + r.n, 0)",
{
timeoutMs: 500,
assert: (total) => total === 6,
grant: { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] },
maxOldGenerationSizeMb: 32,
maxOutputBytes: 4096,
},
);
if (isVerified(result)) console.log("verified:", result.value); // 6
// a runaway allocation is capped and reported instead of taking the host down
await runInWorker("const a = []; while (true) a.push(new Array(1e6));", {
timeoutMs: 10_000,
assert: () => true,
maxOldGenerationSizeMb: 16,
});
// -> { status: "out-of-memory", maxOldGenerationSizeMb: 16 }
// an oversized return is refused before the post-condition runs
await runInWorker("'x'.repeat(1_000_000)", {
timeoutMs: 1000,
assert: () => true,
maxOutputBytes: 1024,
});
// -> { status: "output-too-large", maxOutputBytes: 1024, actualBytes: ... }pnpm install
pnpm run typecheck
pnpm test
pnpm run build- Scaffold: pnpm + strict TypeScript, tsup build, vitest, CI, and the
runVerifiedprimitive contract (deadline + post-condition gating over a discriminated-union result). src/sandbox.ts:run(code, opts)executes untrusted source in a zero-credentialnode:vmcontext (noprocess/require/fetch/timers), grants only what the caller passes, preempts synchronous spins via V8's timeout, and fails closed with a probedZeroCredentialViolationif ambient authority leaks in. Includes documented escape-attempt tests.src/worker.ts:runInWorker(code, opts)runs untrusted source in aworker_threadsisolate started with an emptyprocess.envand frozen globals, caps the heap withmaxOldGenerationSizeMb(reported asout-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, callsworker.terminate()on both deadline and caller abort. Heap cap via V8resourceLimits. Output size caps (maxOutputBytes) measure UTF-8 payload with a budgeted walk (cycle-safe, early-exit) and refuse withoutput-too-largebefore the post-condition runs.