/** * The Lua predicate cell — an untrusted-script evaluator for `branch` and `select` nodes. * * @module @nhtio/adk/batteries/orchestration/cells/lua * * **Node-only.** This subpath imports `node:worker_threads` and `node:process` directly and will * not load in a browser or Web Worker bundle — deliberately, unlike the environment-neutral * orchestration barrel, which carries zero `node:*` imports anywhere in its module graph so it * can be imported from isomorphic code. Never re-export it from that barrel. * * The predicate for this cell is a SOURCE STRING (a Lua expression or statement list) read from * the node's `predicate` field. `validate()` refuses a non-string and loads the chunk so a syntax * error surfaces at freeze rather than at run time. `evaluate()` compiles the chunk in text mode * only (precompiled bytecode is refused — the Lua bytecode verifier is not a security boundary), * injects the marshalled context, and interprets the result as a branch verdict * (`{kind:'branch', matched}`) or a select verdict (`{kind:'select', caseLabel}`). * * ## Three enforcement layers * * 1. **Instruction-count hook.** A Lua count hook is installed via `lua_sethook` and fires every * `instructionLimit` VM instructions (default 1,000,000). On each firing it raises a Lua error, * which the VM turns into an interrupted `pcall` — this stops `while true do end` from *inside* * the VM, which no host-side timer can do. The hook is the only thing that can break a tight * Lua loop because Lua runs synchronously on the host thread. * 2. **Allocator cap (enforced by wasmoon).** `engine.global.setMemoryMax(memoryCeilingBytes)` is * a REAL allocator cap: wasmoon installs a custom `lua_Alloc` when the engine is built with * `traceAllocations: true`, and that allocator REFUSES any growing realloc whose end size would * exceed `memoryMax`, returning `NULL` so the VM raises `LUA_ERRMEM` ("not enough memory"). * `traceAllocations: true` is REQUIRED for the cap to exist — without it `setMemoryMax`/ * `getMemoryUsed` throw "Memory allocations is not being traced" — so the engine is always * built with that flag. `getMemoryUsed()` (typed surface: `getMemoryUsed`; there is no * `getMemoryUse`) reports bytes allocated under the same flag, and is kept as a SECONDARY * signal: the count hook polls it and raises a clean, named, model-addressed * `MEMORY_CEILING_EXCEEDED` error naming the ceiling BEFORE the allocator's hard failure can * fire, so the common case produces a friendly named abort rather than a raw "not enough * memory". The poll is secondary, NOT the enforcement: the cap is what actually refuses * allocation; the poll only wins the race when it gets a firing between the overshoot start * and the cap's refusal. An overshoot that beats the poll still hits the hard cap, and the * host catches that too and converts it to the same named failure (see layer 3 of the abort * handling in `evaluate`), so a raw "not enough memory" never propagates. * 3. **Worker-thread watchdog.** The outer bound. A main-thread timer cannot do this job, because * a tight Lua loop starves `setTimeout`, `nextTick`, `queueMicrotask` and `setImmediate` alike * — only a separate event loop keeps time. The watchdog runs in a `worker_threads` Worker and, * on expiry, `process.kill(pid, 'SIGKILL')`s the SHARED process (not `process.exit()`, which * ends only the worker). Five load-bearing details, all required: * - a main-thread timer cannot keep time during a tight loop (above); * - the worker kills the SHARED process via `process.kill(pid, 'SIGKILL')`, never * `worker.terminate()`-or-`process.exit()` semantics, because only killing the host * process actually breaks a synchronous VM loop that owns the main thread; * - `worker.unref()` is called or a successful run keeps the worker alive and the process * never exits; * - the expired-deadline check is SYNCHRONOUS and runs BEFORE the first `await import()` of * wasmoon, so a process that started past its deadline refuses before doing any work; * - one absolute deadline epoch (`Date.now() + timeoutMs`) is shared between the host and the * watchdog so the two cannot disagree about when "expired" is. * * ## Reduced guarantee * * At construction the hook and the allocator cap are probed against a canary. The cap probe is * POSITIVE: it builds a throwaway engine, calls `setMemoryMax` with a DELIBERATELY TINY ceiling, * runs a bounded chunk that allocates a lot, and confirms the allocator actually REFUSES the * allocation (throws). Only when that refusal is observed is layer 2 claimed. If the probe fails * (the WASM module refused `addFunction`, `setMemoryMax` did not gate allocation, `traceAllocations` * produced no stats, etc.), the cell falls back to watchdog-only enforcement and REPORTS the * reduced guarantee through the `guarantee` field rather than claiming a hook/memory bound it * cannot deliver. A successful run under a reduced guarantee is still correct; it is merely less * protected against a runaway script. * * ## Sandbox surface * * The engine is built by ALLOWLIST with `openStandardLibs: false`, so no standard library is * present until explicitly injected. The following are NEVER injected and are absent by * construction: `_G`, `getfenv`, `getmetatable`, `load`, `dofile`, `io`, `os`. No clock and no * randomness are injected unless a future caller extends this cell. A predicate that reaches for * any of these fails at runtime with an "unknown global" error, which the cell surfaces as a * `{kind:'select', caseLabel: null}` (default) or `{kind:'branch', matched: false}` verdict * rather than as a thrown host exception — a predicate is never allowed to crash the run. * * The test that confirms the watchdog actually kills the process on a `while true do end` loop is * NOT run here: it would SIGKILL this process. It is described in the module-level TSDoc on * `createLuaCell` and lives in a separate, opt-in test harness that spawns a child process. */ import type { PredicateEvaluator } from "../../types"; /** * The enforcement guarantee a cell instance is actually delivering, probed at construction. * * `full` means the count hook and the allocator cap both work. `watchdog-only` means one or both * probes failed and only the worker-thread watchdog remains. This is REPORTED, never claimed: a * cell that cannot install a hook or whose `setMemoryMax` does not gate allocation does not * pretend it did. */ export type LuaCellGuarantee = 'full' | 'watchdog-only'; /** * The options accepted by {@link createLuaCell}. All optional; every field has a safe default. */ export interface CreateLuaCellOptions { /** * The number of VM instructions between count-hook firings. Default 1,000,000. Lowering this * tightens the instruction budget AND the secondary memory poll's latency (the poll runs inside * the hook and raises the named abort before the allocator's hard failure), at the cost of more * hook overhead. The hook is what stops `while true do end` from inside the VM. */ readonly instructionLimit?: number; /** * The memory ceiling in bytes, ENFORCED by wasmoon's allocator cap (`setMemoryMax`). Default * 64 MiB. The allocator refuses any growing realloc whose end size would exceed this. A * secondary `getMemoryUsed` poll inside the count hook raises a clean named abort before the * hard failure when it gets a firing first; an overshoot that beats the poll still hits the * hard cap, and the host converts that to the same named failure. */ readonly memoryCeilingBytes?: number; /** * The wall-clock timeout in milliseconds before the worker-thread watchdog SIGKILLs the shared * process. Default 5,000. This is the OUTER bound; the count hook is the inner one. */ readonly timeoutMs?: number; } /** * Inspectable runtime status of a Lua cell instance: which enforcement layers are live and which * were probed away at construction. Exposed for diagnostics and for tests that need to assert the * guarantee without triggering a runaway script. */ export interface LuaCellStatus { /** The enforcement guarantee actually being delivered. */ readonly guarantee: LuaCellGuarantee; /** The instruction budget the count hook enforces, if the hook is live; else `null`. */ readonly instructionLimit: number | null; /** The memory ceiling the allocator cap enforces, if the cap is live; else `null`. */ readonly memoryCeilingBytes: number | null; /** The wall-clock timeout the watchdog enforces. Always live. */ readonly timeoutMs: number; } /** * The type of the cell returned by {@link createLuaCell}. It is a {@link PredicateEvaluator} with * one extra field, `status()`, for inspecting the enforcement guarantee. */ export interface LuaCell extends PredicateEvaluator { /** * Returns the enforcement guarantee this instance is delivering. See {@link LuaCellGuarantee}. * A cell that could not install a count hook or whose allocator cap probe failed reports * `watchdog-only` here rather than claiming a `full` guarantee. */ status(): LuaCellStatus; } /** * Construct a Lua predicate evaluator cell. * * @remarks * **THE WATCHDOG'S LAST RESORT IS `SIGKILL` ON THE HOST PROCESS — read this before wiring the * cell.** wasmoon is WebAssembly, so the Lua VM runs IN-PROCESS on the main thread. The watchdog * is a worker thread, but what it kills is `process.pid`: your process, not an isolated * evaluator. * * That is deliberate and there is no lighter option. A synchronous WASM loop owns the main * thread, so `worker.terminate()` has nothing to terminate and `process.exit()` never runs; only * the OS killing the process breaks it. A timeout that cannot be enforced is not a timeout, and * this cell would rather enforce one violently than advertise one it cannot deliver. * * Be clear about the trade: **a non-terminating Lua predicate takes the whole process down with * it.** The instruction-count hook and the allocator cap normally stop a runaway long before the * deadline — the watchdog is the last resort, not the first — but if the construction canaries * fail those probes, {@link LuaCell.status} reports the reduced guarantee and the watchdog is all * that remains. * * If a process-wide kill is unacceptable in your deployment, do not wire this cell for untrusted * predicates. `createStructuredCell` cannot loop at all. * * @param options - Optional enforcement tuning: instruction budget, memory ceiling, timeout. * @returns A {@link LuaCell} with `id: 'lua'`, ready to be wired into an orchestration battery's * `evaluators`. The cell's `load()` lazily imports `wasmoon` (an optional peer) and maps a * missing peer to `E_ORCH_CELL_UNAVAILABLE`. `validate()` refuses a non-string predicate and * loads the chunk so a syntax error surfaces at freeze. `evaluate()` builds a fresh sandboxed * VM per call, injects the marshalled `ctx.outputs` as a `ctx` global, and interprets the * result as a branch or select verdict. * * The cell probes the count hook and the allocator cap against a canary at construction (the * cap probe positively confirms `setMemoryMax` plus a tiny ceiling actually REFUSES a large * allocation) and reports the actual enforcement guarantee via `status()`. If either probe * fails it falls back to watchdog-only enforcement and REPORTS the reduced guarantee rather * than claiming it. * * **Do not run the runaway-script test in-process.** A test that confirms the watchdog * actually SIGKILLs the process on `while true do end` must spawn a CHILD process and observe * its exit signal; running it inside this process would kill the test runner. That test lives * in a separate, opt-in harness and is never invoked by `evaluate` itself. */ export declare const createLuaCell: (options?: CreateLuaCellOptions) => LuaCell;