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 deadline 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, here, adds runInWorker(code, opts): the same contract, but the code runs in a worker_threads isolate. That is a separate V8 heap on a separate OS thread, started with an empty process.env and frozen globals. It closes the in-process gap where a constructor walk reaches the host realm, because an escape now lands in the worker's own realm, which carries no host credentials and can be hard-killed. Later slices add a Docker-backed tier for stronger isolation 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. - Total error handling. Thrown errors, blown deadlines, and failed assertions 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. - Hard preemption and resource limits. A synchronous spin, an async task that never settles, and a heap that outgrows
maxOldGenerationSizeMbare all terminal: the first two are killed by terminating the thread on the deadline, and the third is reported asout-of-memoryby V8's resource limits. In-processruncan only abandon a hung async task; the worker actually reclaims the thread. - Layered preemption. A synchronous spin is killed by V8's
timeout; an async task that never settles is aborted by the deadline race.runcomposes both so neither class of runaway can wedge the caller. - Strict TypeScript.
strict,noUncheckedIndexedAccess, andexactOptionalPropertyTypes, noany.
runVerified(task, { timeoutMs, assert, signal? }) -> RunResult
- The value is returned only as
{ status: "ok", value, durationMs }, and only when the task finished beforetimeoutMsandassert(value)returned true. - Every other outcome is an explicit refusal:
timeout,assertion-failed(carries the value for diagnostics, never as trusted), 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.
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,
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,
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, and an optional heap cap. The grant and the returned value cross by structured clone, so pass data rather than live functions.
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,
},
);
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 }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.