import { type WorkflowScriptRunner, type WorkflowPrimitives } from "@sema-agent/core"; /** * Hardened Node `vm` MEMBRANE for executing UNTRUSTED (LLM-authored / user-supplied) JavaScript, ported from * the reference shell-host implementation's workflow sandbox and validated against core's * `assertWorkflowSandboxConformance` battery + a beyond-battery boundary probe set + a 7-lens robustness suite * (s8-sandbox-robustness: ctor/proto · error/stack · async/iter · reflect/proxy/symbol · marshal-bridge · * determinism · closure/cap). Two surfaces share ONE core ({@link runHardened}) so there is a single place to * audit/fix: * - {@link createHardenedVmRunner} — the S8 `WorkflowScriptRunner` (TOC+TOB workflow orchestration). * - {@link runInHardenedVm} — general untrusted-code exec (TOC ephemeral execution sandbox). * * 🔴 Why a plain `vm` is NOT a boundary (and what makes THIS one): * core's `devWorkflowScriptRunner` passes host primitives DIRECTLY into the context, so * `agent.constructor.constructor("return process")()` reaches the HOST `Function` (host realm = code-gen * unrestricted) → boundary crossing. The membrane closes that: * 1. `createContext({__proto__:null}, {codeGeneration:{strings:false,wasm:false}})` — null-proto global, * no eval / new Function / wasm INSIDE the context. * 2. EVERY value the script touches is CONTEXT-LOCAL — each binding is re-wrapped by a function COMPILED * IN THE CONTEXT (its `.constructor.constructor` is the context's inert `Function`); a raw host * function/object/error NEVER reaches the script. * 3. Data crossing INTO the script (args, bridged results) is marshalled context-local via JSON-in-context. * 4. ERRORS from host fns are marshalled to a CONTEXT-LOCAL `Error` (the beyond-battery leak core's * battery misses). * 5. Data crossing INTO a host fn (bridged args) and OUT to the host caller (the result + meta) is * sterilized of the `__proto__`/`constructor`/`prototype` rewriting gadget ({@link scrubProtoDeep}) — * the marshal-bridge boundary-crossing case — and marshalled to plain data (never a live cross-realm object * carrying getter/toJSON/then traps). * * 🔴 SCOPE (v1, clay 2026-06-23): this is BOUNDARY isolation (no host-code readout / unintended host execution / cross-tenant credential * reach / host prototype rewriting) — VALIDATED. It is NOT full RESOURCE isolation: an in-process `vm` * cannot hard-cap heap, so a runaway-allocation script can OOM the shared process (a resource-exhaustion issue, NOT a data-boundary issue). v1 * mitigates with caps (maxAgents + a wall-clock timeout + a per-sync-chunk vm timeout) + monitoring. For HARD * heap/CPU isolation use {@link import("./hardened-vm-worker-runner.js").createWorkerHardenedVmRunner} — the * SAME membrane in a worker_thread (resourceLimits + terminate). Single-tenant callers (e.g. a TOC run-local) * can treat the in-process model as final, like the reference shell-host implementation does. * * 🔴 Known IN-PROCESS cap gap (resource-exhaustion, not boundary): the sync `vm` timeout kills a SYNCHRONOUS infinite loop, and * the wall-clock race kills a script that yields to the MACROTASK queue (or awaits a stuck host fn). A pure * MICROTASK-starvation loop (`while (true) { await null }`) starves the host event loop, so neither the host * timer nor the abort listener fires → it hangs the process. The worker-isolated runner (`terminate()`) * pre-empts that — use it for genuinely-untrusted multi-tenant scripts where resource isolation matters. * * 🔴 Silent-hang class ([ref]§二, clay live incident 2026-07-26): a script whose promise graph can NEVER settle * (an un-awaited `phase(...)` whose body returns its own promise) used to burn the full totalTimeoutMs blind * and then blame the timeout. The quiescence detector in {@link runHardened} fail-fasts it with a diagnosis: * inside the membrane the bridge is the ONLY async host seam, so bridge-quiescent + both queues drained + * scriptPromise pending is a PROOF of deadlock, not a heuristic. Sibling hazard, same incident review: an * un-awaited IN-CONTEXT rejection shares the host isolate's unhandledRejection machinery (Node default = * crash the process) — contained by {@link installScriptRealmRejectionGuard} (realm-keyed: script rejections * are logged, host rejections keep fail-loud). */ export interface HardenedVmLimits { /** Wall-clock cap on the whole run (covers async loops the sync `vm` timeout can't). Default 600_000. */ totalTimeoutMs?: number; /** Per-synchronous-chunk `vm` timeout — bounds a synchronous infinite loop before the first await. Default 5_000. */ syncTimeoutMs?: number; /** Hard cap on `agent()` spawns across a workflow run (runaway-fan-out backstop). Default 1000. */ maxAgents?: number; /** Max concurrent in-flight calls the in-context parallel/pipeline pools allow. Default 12. */ concurrency?: number; /** worker_thread heap cap in MB (only honored by the worker-isolated runner — see hardened-vm-worker-runner.ts). * A script exceeding it OOMs the WORKER (killed), never the shared main process. Default 128. */ maxHeapMb?: number; } /** [ref]①(clay 亲机四 run 三次恰在 600s 整点全灭):workflow 总超时默认与旋钮的**单点**。 * 旧默认 600_000(10 分钟)对真实 workflow(验证 agent 逐条重跑清单/任何带审批等待的车)结构性不够—— * 默认抬到 **1 小时**;部署经 `WORKFLOW_TOTAL_TIMEOUT_MS` 显式配,域 [60_000, 86_400_000](1 分钟..24h), * 坏值**响亮拒**([ref] 立律:旋钮坏值禁静默回默认)。0/负数不是「禁用」:无界 runaway 脚本会吞掉共享 * 副本的事件循环/堆,禁用臂刻意不提供——要更久就把值配大。模块加载时读一次(坏 env = boot fail-loud)。 * 超时终态分型(partial vs failed,[ref]① 后半)涉 wire status 语义,与 core workflow 终态形另批对表。 */ export declare function workflowTotalTimeoutMsDefault(): number; /** * The realm test itself, exported so there is exactly ONE ruler for "did this rejection come from an untrusted * script?" in the process. A promise minted INSIDE a vm context fails `instanceof` against the HOST `Promise` * (different intrinsic per realm) — that is the whole judgement. * * 🔴 Why it is exported ([ref] 二轮重扫, lifecycle-pairing, double-opus CONFIRMED): the guard below is NOT the * only `unhandledRejection` listener in a real boot. `src/boot/shutdown.ts` installs one at boot ([ref]① crash * last words) that exits(1); Node walks EVERY listener for the event, so containment cannot be expressed by * one listener returning early — whoever exits wins regardless of registration order. The containment DECISION * therefore lives in the boot handler, and it must use this same ruler rather than minting a second one. */ export declare function isScriptRealmRejection(promise: unknown): boolean; /** * Summarize an unhandled rejection reason for a log line WITHOUT running anything the (untrusted) script * controls. Both rejection listeners in this process use it. * * 🔴 Why not `String(reason)` / `${reason}` / `reason.message` (codex adversarial-review F1, REPRODUCED): * the reason of a script-realm rejection is a LIVE object from the vm context, and every readout path is * script-controlled code running in the HOST turn with no vm timeout over it: * · `String(x)` / template literals invoke `Symbol.toPrimitive` / `toString` / `valueOf`; * · a property read invokes a getter, and on a Proxy even `getOwnPropertyDescriptor` is a trap. * `Promise.reject({ toString() { throw ... } })` therefore threw OUT of the guard, became an * `uncaughtException` and exited(1) — i.e. an untrusted script could escape containment and kill the process * by choosing its rejection value; the loop variant (`toString(){ for(;;){} }`) wedges the event loop instead. * So: PRIMITIVES only (their coercion cannot run user code), everything else gets a fixed placeholder. The * cost is honest and stated — an operator sees "a script rejection was contained", not its message; the * message channel for a rejection nobody awaited does not exist anyway. */ export declare function describeRejectionReason(reason: unknown): string; export declare function installScriptRealmRejectionGuard(): void; /** * The membrane handed to a surface's `buildGlobals` callback to construct the CONTEXT-LOCAL bindings the * untrusted body will see. NEVER expose a raw host function/object to the script — route it through here. */ export interface HardenedMembrane { /** Wrap a host async fn as a CONTEXT-LOCAL fn: args are sterilized in, the result is marshalled back * context-local, a host throw becomes a context-local Error. This is the only safe way to give the script * a host capability. */ bridge(hostFn: (...args: unknown[]) => unknown): (...a: unknown[]) => Promise; /** Marshal a host DATA value into a sterilized context-local value (for non-function bindings / args). */ dataIn(value: unknown): unknown; /** Compile + evaluate a TRUSTED expression IN the context (e.g. to define in-context orchestration sugar). * The source MUST be developer-authored, never untrusted input. */ ctxEval(trustedSource: string): unknown; } /** * phases=[] ROOT CAUSE + fix — the SCOPED-PHASE GATE. The old in-context `phase` was pure sugar * (log a "◆ phase" line, run the body) that NEVER called the host `primitives.phase`, so core recorded ZERO * phase state: terminal rows had `phases: []` and agents carried no `phase` key (exactly the storage-row * autopsy — agents fine, phase accounting gone). The membrane's JSON bridge cannot carry the script's BODY * function across, so the bridge is split into three JSON-safe host calls, and the body stays IN-CONTEXT: * - `mark(title)` → `primitives.phase(title, undefined)` (core's bare CC-marker path); * - `enter(title)` → opens core's scoped phase with a DEFERRED body (`await gate`), returns a numeric token. * core pushes the phase record + sets `currentPhase` SYNCHRONOUSLY before its first await, so by the time * `enter` returns, `agent()` calls made inside the VM body land with the right phase label; * - `exit(token, ok, err?)` → releases the gate (ok → core closes the phase `completed`; !ok → the deferred * body throws and core closes it `failed`), then awaits core's settle so the close is persisted before the * VM continues. * A finalized-run `phase()` (core throws) surfaces on `enter` via the settled-race; an unknown/double `exit` * is idempotent. The VM wrapper rethrows the body's own error — the gate's synthetic error never reaches the * script. */ export declare function scopedPhaseGate(phase: WorkflowPrimitives["phase"]): { enter(title: string): Promise; exit(token: number, ok: boolean, err?: string): Promise; }; /** Build the orchestration context-local globals (agent/parallel/pipeline/phase/log/budget/args) from the * host {@link WorkflowPrimitives}. agent/log/phase are host-bridged (phase via the three-verb * {@link scopedPhaseGate} — the body function itself never crosses the membrane); parallel/pipeline are * in-context sugar; budget is a frozen null-proto object over host accessors. * Exported so the worker-isolated runner (hardened-vm-worker.ts) reuses the EXACT same surface with * IPC-backed primitives — one definition of the orchestration globals, no drift. */ export declare function orchestrationGlobals(primitives: WorkflowPrimitives, scriptArgs: unknown, limits?: HardenedVmLimits): (m: HardenedMembrane) => Record; /** Build the hardened-vm S8 `WorkflowScriptRunner` (TOC+TOB workflow orchestration). `safeForUntrustedScripts` * is `true` — it has passed the conformance battery + robustness suite; the S8 gate mounts `run_workflow` only for * a runner that asserts this. */ export declare function createHardenedVmRunner(limits?: HardenedVmLimits): WorkflowScriptRunner; /** * General untrusted-code exec on the SAME hardened membrane — the TOC ephemeral execution sandbox. Runs * `code` (an async function body that may `return`) with ONLY the safe built-ins plus whatever host * capabilities the caller bridges via `bindings` (each MUST go through `m.bridge` / `m.dataIn` — never a raw * host reference). Returns the marshalled (plain, sterilized) result. With no `bindings`, the code is pure * computation (no host reach at all). * * @example * const out = await runInHardenedVm({ * code: "return await fetchJson(args.url)", * bindings: (m) => ({ fetchJson: m.bridge((u) => safeFetch(String(u))), args: m.dataIn({ url }) }), * limits: { totalTimeoutMs: 5000 }, * }); */ export declare function runInHardenedVm(opts: { code: string; bindings?: (m: HardenedMembrane) => Record; limits?: HardenedVmLimits; signal?: AbortSignal; }): Promise; //# sourceMappingURL=hardened-vm-runner.d.ts.map