From 58bd3c962d84b0430d59143df64999bc5db3b9d8 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Thu, 23 Jul 2026 18:35:07 +0000 Subject: [PATCH] feat: Add wall-clock, heap, and output size resource limits Shared ResourceLimits validation, budgeted output metering with output-too-large, and hard worker.terminate on deadline and abort. --- README.md | 34 +++++-- src/contract.ts | 8 +- src/index.ts | 6 ++ src/limits.ts | 115 +++++++++++++++++++++ src/run.ts | 21 +++- src/sandbox.ts | 13 ++- src/worker.ts | 52 ++++++++-- test/limits.test.ts | 239 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 src/limits.ts create mode 100644 test/limits.test.ts diff --git a/README.md b/README.md index 20bf250..c7c03cc 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,36 @@ Ephemeral, zero-credential, self-verifying execution for untrusted or agent-writ ## What this demonstrates -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. +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, 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. +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. ## Concepts demonstrated - **Verification-gated results.** The output value is reachable only through the `ok` variant 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 `AbortSignal` it 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. +- **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; `run` extends this to source code, which sees zero ambient authority and only the capabilities passed in the `grant` object. - **Zero-credential invariant.** Untrusted source runs in a `node:vm` context that carries none of the host's authority: no `process`, `process.env`, `require`, `fetch`, timers, or `Buffer`. 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 `globalThis` is unreachable and the sandbox's own `Function` compiles in-realm. The known in-process gap, `this.constructor.constructor` reaching the host realm through the borrowed global prototype, is pinned by an escape-attempt test rather than hidden. - **Thread-level isolation with `worker_threads`.** `runInWorker` runs 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 empty `process.env` and cannot see the host's environment. An escape-attempt test walks the same constructor chain and confirms a host secret placed in `process.env` stays out of reach. - **Frozen realm hardening.** Before any untrusted code runs, the worker freezes `globalThis` and 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 `maxOldGenerationSizeMb` are all terminal: the first two are killed by terminating the thread on the deadline, and the third is reported as `out-of-memory` by V8's resource limits. In-process `run` can 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. `run` composes both so neither class of runaway can wedge the caller. +- **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). `run` composes both so neither class of runaway can wedge the caller. - **Strict TypeScript.** `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes`, no `any`. ## The primitive contract ``` -runVerified(task, { timeoutMs, assert, signal? }) -> RunResult +runVerified(task, { timeoutMs, assert, signal?, maxOutputBytes? }) -> RunResult ``` -- The value is returned **only** as `{ status: "ok", value, durationMs }`, and only when the task finished before `timeoutMs` and `assert(value)` returned true. -- Every other outcome is an explicit refusal: `timeout`, `assertion-failed` (carries the value for diagnostics, never as trusted), or `error`. -- The task is handed an `AbortSignal` that fires on the deadline or on the caller's own signal, so well-behaved async work can stop early. +- The value is returned **only** as `{ status: "ok", value, durationMs }`, and only when the task finished before `timeoutMs`, the payload stayed under `maxOutputBytes` when set, and `assert(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`, or `error`. +- The task is handed an `AbortSignal` that 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. @@ -47,6 +49,7 @@ const result = await runVerified( }, { timeoutMs: 2000, + maxOutputBytes: 64 * 1024, assert: (data) => Number.isInteger(data.total) && data.total >= 0, }, ); @@ -67,6 +70,7 @@ const result = await run( "add(rows.length, 1)", { timeoutMs: 50, + maxOutputBytes: 1024, assert: (n) => Number.isInteger(n) && n > 0, grant: { rows: [{ id: 1 }, { id: 2 }], @@ -84,7 +88,7 @@ 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. +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()`. ```ts import { runInWorker, isVerified } from "airlock"; @@ -96,6 +100,7 @@ const result = await runInWorker( assert: (total) => total === 6, grant: { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] }, maxOldGenerationSizeMb: 32, + maxOutputBytes: 4096, }, ); @@ -108,6 +113,14 @@ await runInWorker("const a = []; while (true) a.push(new Array(1e6));", { 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: ... } ``` ## Develop @@ -124,3 +137,4 @@ pnpm run build - Scaffold: pnpm + strict TypeScript, tsup build, vitest, CI, and the `runVerified` primitive contract (deadline + post-condition gating over a discriminated-union result). - `src/sandbox.ts`: `run(code, opts)` executes untrusted source in a zero-credential `node:vm` context (no `process`/`require`/`fetch`/timers), grants only what the caller passes, preempts synchronous spins via V8's timeout, and fails closed with a probed `ZeroCredentialViolation` if ambient authority leaks in. Includes documented escape-attempt tests. - `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. diff --git a/src/contract.ts b/src/contract.ts index 49d9c8e..ce8e1ba 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -3,7 +3,8 @@ export type RunStatus = | "timeout" | "assertion-failed" | "error" - | "out-of-memory"; + | "out-of-memory" + | "output-too-large"; /** * A run only counts as verified when it carries `status: "ok"`. Every other @@ -15,7 +16,8 @@ export type RunResult = | { status: "timeout"; timeoutMs: number } | { status: "assertion-failed"; value: T } | { status: "error"; error: unknown } - | { status: "out-of-memory"; maxOldGenerationSizeMb: number }; + | { status: "out-of-memory"; maxOldGenerationSizeMb: number } + | { status: "output-too-large"; maxOutputBytes: number; actualBytes: number }; export type Task = (signal: AbortSignal) => T | Promise; @@ -27,6 +29,8 @@ export interface VerifiedRunOptions { assert: Assertion; /** Caller-owned cancellation, merged with the internal deadline. */ signal?: AbortSignal; + /** Refuse values whose measured UTF-8 payload exceeds this many bytes. */ + maxOutputBytes?: number; } export function isVerified( diff --git a/src/index.ts b/src/index.ts index b70c8b0..7d7589c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,12 @@ export { export type { SandboxRunOptions } from "./sandbox.js"; export { runInWorker, freezeRealm, FROZEN_INTRINSICS } from "./worker.js"; export type { WorkerRunOptions } from "./worker.js"; +export { + validateResourceLimits, + measureOutputBytes, + checkOutputSize, +} from "./limits.js"; +export type { ResourceLimits, OutputSizeCheck } from "./limits.js"; export type { Assertion, RunResult, diff --git a/src/limits.ts b/src/limits.ts new file mode 100644 index 0000000..60514f1 --- /dev/null +++ b/src/limits.ts @@ -0,0 +1,115 @@ +/** + * Resource ceilings shared by every airlock tier. Wall-clock and heap are + * enforced by the runner (timer + worker.terminate / V8 resourceLimits); + * output size is measured on the host after the value crosses the boundary, + * so a huge return cannot pass the post-condition or land as a trusted result. + */ +export interface ResourceLimits { + timeoutMs: number; + maxOldGenerationSizeMb?: number; + /** UTF-8 payload of the returned value. Exceeding refuses with output-too-large. */ + maxOutputBytes?: number; +} + +export interface OutputSizeCheck { + bytes: number; + exceeded: boolean; +} + +export function validateResourceLimits(limits: ResourceLimits): void { + if (!Number.isFinite(limits.timeoutMs) || limits.timeoutMs <= 0) { + throw new RangeError("timeoutMs must be a positive, finite number"); + } + if (limits.maxOldGenerationSizeMb !== undefined) { + if ( + !Number.isInteger(limits.maxOldGenerationSizeMb) || + limits.maxOldGenerationSizeMb <= 0 + ) { + throw new RangeError("maxOldGenerationSizeMb must be a positive integer"); + } + } + if (limits.maxOutputBytes !== undefined) { + if ( + !Number.isInteger(limits.maxOutputBytes) || + limits.maxOutputBytes < 0 + ) { + throw new RangeError("maxOutputBytes must be a non-negative integer"); + } + } +} + +/** + * Walks a value counting UTF-8 payload bytes until `budget` is exceeded, then + * stops. Cycles contribute 0 after the first visit so a self-referential object + * cannot hang the host meter. + */ +export function measureOutputBytes( + value: unknown, + budget: number = Number.POSITIVE_INFINITY, +): OutputSizeCheck { + const seen = new WeakSet(); + let bytes = 0; + + const add = (n: number): boolean => { + bytes += n; + return bytes > budget; + }; + + const walk = (v: unknown): boolean => { + if (v === null || v === undefined) return false; + switch (typeof v) { + case "boolean": + return add(1); + case "number": + case "bigint": + return add(8); + case "string": + return add(Buffer.byteLength(v, "utf8")); + case "symbol": + case "function": + return false; + case "object": { + if (seen.has(v as object)) return false; + seen.add(v as object); + if (ArrayBuffer.isView(v)) return add(v.byteLength); + if (v instanceof ArrayBuffer) return add(v.byteLength); + if (v instanceof Date) return add(8); + if (v instanceof Map) { + for (const [k, entry] of v) { + if (walk(k) || walk(entry)) return true; + } + return false; + } + if (v instanceof Set) { + for (const entry of v) { + if (walk(entry)) return true; + } + return false; + } + if (Array.isArray(v)) { + for (const entry of v) { + if (walk(entry)) return true; + } + return false; + } + for (const [key, entry] of Object.entries(v as Record)) { + if (add(Buffer.byteLength(key, "utf8"))) return true; + if (walk(entry)) return true; + } + return false; + } + default: + return false; + } + }; + + const exceeded = walk(value); + return { bytes, exceeded }; +} + +export function checkOutputSize( + value: unknown, + maxOutputBytes: number, +): OutputSizeCheck { + return measureOutputBytes(value, maxOutputBytes); +} diff --git a/src/run.ts b/src/run.ts index 6ec9824..4ca7e2f 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,4 +1,5 @@ import type { RunResult, Task, VerifiedRunOptions } from "./contract.js"; +import { checkOutputSize, validateResourceLimits } from "./limits.js"; const DEADLINE = Symbol("deadline"); @@ -16,10 +17,11 @@ export async function runVerified( task: Task, opts: VerifiedRunOptions, ): Promise> { - const { timeoutMs, assert, signal } = opts; - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new RangeError("timeoutMs must be a positive, finite number"); - } + const { timeoutMs, assert, signal, maxOutputBytes } = opts; + validateResourceLimits({ + timeoutMs, + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + }); const controller = new AbortController(); const relayAbort = () => controller.abort(signal?.reason); @@ -49,6 +51,17 @@ export async function runVerified( } const value = outcome as T; + if (maxOutputBytes !== undefined) { + const size = checkOutputSize(value, maxOutputBytes); + if (size.exceeded) { + return { + status: "output-too-large", + maxOutputBytes, + actualBytes: size.bytes, + }; + } + } + const passed = await assert(value); return passed ? { status: "ok", value, durationMs: performance.now() - started } diff --git a/src/sandbox.ts b/src/sandbox.ts index 3144544..9498e39 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -34,6 +34,7 @@ export interface SandboxRunOptions { grant?: Readonly>; signal?: AbortSignal; filename?: string; + maxOutputBytes?: number; } export class ZeroCredentialViolation extends Error { @@ -92,10 +93,7 @@ export async function run( code: string, opts: SandboxRunOptions, ): Promise> { - const { timeoutMs, assert, grant, signal, filename } = opts; - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new RangeError("timeoutMs must be a positive, finite number"); - } + const { timeoutMs, assert, grant, signal, filename, maxOutputBytes } = opts; const context = vm.createContext({ ...(grant ?? {}) }); const leaked = probeAmbientAuthority(context, Object.keys(grant ?? {})); @@ -114,7 +112,12 @@ export async function run( timeout: timeoutMs, breakOnSigint: true, }) as T | Promise, - { timeoutMs, assert, ...(signal ? { signal } : {}) }, + { + timeoutMs, + assert, + ...(signal ? { signal } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + }, ); if (result.status === "error" && isSyncTimeout(result.error)) { diff --git a/src/worker.ts b/src/worker.ts index 3616ebe..8d32465 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,5 +1,6 @@ import { Worker } from "node:worker_threads"; import type { Assertion, RunResult } from "./contract.js"; +import { checkOutputSize, validateResourceLimits } from "./limits.js"; /** * Intrinsics whose prototypes a sandbox escape could otherwise repave to attack @@ -54,6 +55,8 @@ export interface WorkerRunOptions { grant?: Readonly>; /** Hard cap on the isolate's V8 old-space. Exceeding it kills the worker. */ maxOldGenerationSizeMb?: number; + /** Refuse values whose measured UTF-8 payload exceeds this many bytes. */ + maxOutputBytes?: number; signal?: AbortSignal; filename?: string; } @@ -110,10 +113,12 @@ const vm = require('node:vm'); * This is the stronger sibling of the in-process {@link run}. It closes the * pinned in-process gap where `this.constructor.constructor` reaches the host * realm: here an escape from the `vm` context reaches only the WORKER's realm, - * whose `process.env` is empty and whose globals are frozen. The thread is - * hard-killed on the deadline, so a synchronous spin AND an async task that - * never settles are both preempted, not merely abandoned. A caller-supplied - * `maxOldGenerationSizeMb` caps the heap and reports `out-of-memory` when hit. + * whose `process.env` is empty and whose globals are frozen. + * + * Resource ceilings: wall-clock deadline hard-kills the thread via + * `worker.terminate()` (deadline and caller abort both terminate), V8 + * `maxOldGenerationSizeMb` reports `out-of-memory`, and `maxOutputBytes` + * refuses oversized returns before the post-condition runs. * * The tradeoff for real thread isolation: the `grant` and the returned value * cross by structured clone, so live function capabilities can't be handed in @@ -123,11 +128,20 @@ export function runInWorker( code: string, opts: WorkerRunOptions, ): Promise> { - const { timeoutMs, assert, grant, maxOldGenerationSizeMb, signal, filename } = - opts; - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new RangeError("timeoutMs must be a positive, finite number"); - } + const { + timeoutMs, + assert, + grant, + maxOldGenerationSizeMb, + maxOutputBytes, + signal, + filename, + } = opts; + validateResourceLimits({ + timeoutMs, + ...(maxOldGenerationSizeMb !== undefined ? { maxOldGenerationSizeMb } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + }); let worker: Worker; try { @@ -153,6 +167,9 @@ export function runInWorker( 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. const finish = (result: RunResult) => { if (settled) return; settled = true; @@ -179,11 +196,26 @@ export function runInWorker( if (msg.ok) { clearTimeout(timer); const value = msg.value as T; + if (maxOutputBytes !== undefined) { + const size = checkOutputSize(value, maxOutputBytes); + if (size.exceeded) { + finish({ + status: "output-too-large", + maxOutputBytes, + actualBytes: size.bytes, + }); + return; + } + } void Promise.resolve(assert(value)).then( (passed) => finish( passed - ? { status: "ok", value, durationMs: performance.now() - started } + ? { + status: "ok", + value, + durationMs: performance.now() - started, + } : { status: "assertion-failed", value }, ), (error) => finish({ status: "error", error }), diff --git a/test/limits.test.ts b/test/limits.test.ts new file mode 100644 index 0000000..d9bc543 --- /dev/null +++ b/test/limits.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; +import { + checkOutputSize, + isVerified, + measureOutputBytes, + run, + runInWorker, + runVerified, + validateResourceLimits, +} from "../src/index.js"; + +describe("validateResourceLimits", () => { + it("accepts a finite positive timeout", () => { + expect(() => validateResourceLimits({ timeoutMs: 1 })).not.toThrow(); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects a bad timeoutMs: %s", + (timeoutMs) => { + expect(() => validateResourceLimits({ timeoutMs })).toThrow(RangeError); + }, + ); + + it.each([0, -4, 1.5, Number.NaN])( + "rejects a bad maxOldGenerationSizeMb: %s", + (maxOldGenerationSizeMb) => { + expect(() => + validateResourceLimits({ timeoutMs: 10, maxOldGenerationSizeMb }), + ).toThrow(RangeError); + }, + ); + + it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects a bad maxOutputBytes: %s", + (maxOutputBytes) => { + expect(() => + validateResourceLimits({ timeoutMs: 10, maxOutputBytes }), + ).toThrow(RangeError); + }, + ); + + it("allows maxOutputBytes of 0 (only empty payloads pass)", () => { + expect(() => + validateResourceLimits({ timeoutMs: 10, maxOutputBytes: 0 }), + ).not.toThrow(); + }); +}); + +describe("measureOutputBytes", () => { + it("counts empty and nullish values as zero", () => { + expect(measureOutputBytes(null).bytes).toBe(0); + expect(measureOutputBytes(undefined).bytes).toBe(0); + expect(measureOutputBytes("").bytes).toBe(0); + }); + + it("counts UTF-8 string payload, not code units", () => { + // "€" is one code point, three UTF-8 bytes + expect(measureOutputBytes("€").bytes).toBe(3); + expect(measureOutputBytes("hi").bytes).toBe(2); + }); + + it("walks arrays and plain objects", () => { + expect(measureOutputBytes([1, 2]).bytes).toBe(16); + expect(measureOutputBytes({ a: "bb" }).bytes).toBe(3); // key "a" + "bb" + }); + + it("stops once the budget is exceeded", () => { + const size = measureOutputBytes("abcdefghij", 4); + expect(size.exceeded).toBe(true); + expect(size.bytes).toBeGreaterThan(4); + }); + + it("does not hang on cyclic structures", () => { + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + const size = measureOutputBytes(cyclic); + expect(size.exceeded).toBe(false); + expect(size.bytes).toBe(Buffer.byteLength("self", "utf8")); + }); + + it("counts TypedArray and ArrayBuffer by byteLength", () => { + expect(measureOutputBytes(new Uint8Array(32)).bytes).toBe(32); + expect(measureOutputBytes(new ArrayBuffer(16)).bytes).toBe(16); + }); + + it("checkOutputSize mirrors measure with a budget", () => { + expect(checkOutputSize("abcd", 4)).toEqual({ bytes: 4, exceeded: false }); + expect(checkOutputSize("abcde", 4).exceeded).toBe(true); + }); +}); + +describe("maxOutputBytes on runVerified", () => { + it("accepts a value under the cap and still applies the assertion", async () => { + const result = await runVerified(() => "ok", { + timeoutMs: 100, + maxOutputBytes: 16, + assert: (v) => v === "ok", + }); + + expect(result).toMatchObject({ status: "ok", value: "ok" }); + expect(isVerified(result)).toBe(true); + }); + + it("refuses an oversized string without running a passing assertion as ok", async () => { + let asserted = false; + const result = await runVerified(() => "x".repeat(100), { + timeoutMs: 100, + maxOutputBytes: 10, + assert: () => { + asserted = true; + return true; + }, + }); + + expect(result.status).toBe("output-too-large"); + if (result.status === "output-too-large") { + expect(result.maxOutputBytes).toBe(10); + expect(result.actualBytes).toBeGreaterThan(10); + } + expect(asserted).toBe(false); + }); + + it("treats maxOutputBytes of 0 as refusing any non-empty payload", async () => { + const empty = await runVerified(() => null, { + timeoutMs: 100, + maxOutputBytes: 0, + assert: () => true, + }); + expect(empty.status).toBe("ok"); + + const nonEmpty = await runVerified(() => "a", { + timeoutMs: 100, + maxOutputBytes: 0, + assert: () => true, + }); + expect(nonEmpty.status).toBe("output-too-large"); + }); + + it("accepts a value exactly at the boundary", async () => { + const result = await runVerified(() => "abcd", { + timeoutMs: 100, + maxOutputBytes: 4, + assert: (v) => v === "abcd", + }); + expect(result).toMatchObject({ status: "ok", value: "abcd" }); + }); +}); + +describe("maxOutputBytes on run and runInWorker", () => { + it("caps sandbox output before verification", async () => { + const result = await run("'z'.repeat(50)", { + timeoutMs: 100, + maxOutputBytes: 8, + assert: () => true, + }); + expect(result.status).toBe("output-too-large"); + }); + + it("lets a small sandbox value through", async () => { + const result = await run("'hi'", { + timeoutMs: 100, + maxOutputBytes: 8, + assert: (v) => v === "hi", + }); + expect(result).toMatchObject({ status: "ok", value: "hi" }); + }); + + it("caps worker isolate output after structured clone", async () => { + const result = await runInWorker("'w'.repeat(200)", { + timeoutMs: 1000, + maxOutputBytes: 32, + assert: () => true, + }); + expect(result.status).toBe("output-too-large"); + if (result.status === "output-too-large") { + expect(result.actualBytes).toBeGreaterThan(32); + } + }); + + it("rejects invalid maxOutputBytes at the worker boundary", () => { + expect(() => + runInWorker("1", { timeoutMs: 100, maxOutputBytes: -1, assert: () => true }), + ).toThrow(RangeError); + }); +}); + +describe("wall-clock timeout terminates the worker on abort", () => { + it("hard-kills a never-settling isolate within the deadline window", async () => { + const started = performance.now(); + const result = await runInWorker("new Promise(() => {})", { + timeoutMs: 80, + assert: () => true, + }); + const elapsed = performance.now() - started; + + expect(result).toEqual({ status: "timeout", timeoutMs: 80 }); + // terminate() should reclaim the thread promptly after the timer fires + expect(elapsed).toBeLessThan(1500); + }); + + it("terminates the isolate when the caller aborts", async () => { + const controller = new AbortController(); + const pending = runInWorker("new Promise(() => {})", { + timeoutMs: 10_000, + assert: () => true, + signal: controller.signal, + }); + controller.abort(new Error("stop")); + const result = await pending; + expect(result.status).toBe("error"); + if (result.status === "error") { + expect((result.error as Error).message).toBe("stop"); + } + }); +}); + +describe("memory cap on the worker isolate", () => { + it("reports out-of-memory when the heap ceiling is hit", async () => { + const result = await runInWorker( + "const acc = []; while (true) { acc.push(new Array(1_000_000).fill(0)); }", + { timeoutMs: 10_000, assert: () => true, maxOldGenerationSizeMb: 16 }, + ); + + expect(result.status).toBe("out-of-memory"); + if (result.status === "out-of-memory") { + expect(result.maxOldGenerationSizeMb).toBe(16); + } + }, 15_000); + + it("rejects a non-positive heap cap before starting a worker", () => { + expect(() => + runInWorker("1", { + timeoutMs: 100, + maxOldGenerationSizeMb: 0, + assert: () => true, + }), + ).toThrow(RangeError); + }); +});