@markii/lua 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ import { LuaFactory, LuaLibraries } from 'wasmoon';
2
+ /**
3
+ * Builds the *empty* Lua environment (spec §10: "Scripts run in an empty
4
+ * Lua environment: no `os`, no `io`, no `require`, no globals except the
5
+ * capability functions the host injects").
6
+ *
7
+ * Strategy: `openStandardLibs: false` means wasmoon never calls
8
+ * `luaL_openlibs` at all — `os`, `io`, `package`, `debug`, and `coroutine`
9
+ * are never linked into this Lua state's globals table in the first place.
10
+ * This is strictly stronger than "load everything, then delete the bad
11
+ * parts": there is nothing to delete because the C library that would have
12
+ * installed them was never invoked, so there is no back door (metatable,
13
+ * `_ENV`, or otherwise) that recovers them — recovering a name that was
14
+ * never assigned into the (single, shared) globals table is not possible in
15
+ * Lua regardless of what handle a script obtains on that table.
16
+ *
17
+ * We then hand-load exactly four libraries via `Global.loadLibrary` (the
18
+ * per-library `luaopen_*` wasmoon exposes) and immediately run a trusted
19
+ * "scrub" prelude that nils out the specific names, within those four
20
+ * libraries, that are still dangerous. Every inclusion/exclusion below is
21
+ * commented with why.
22
+ */
23
+ /**
24
+ * Libraries linked into the sandbox, and why:
25
+ * - Base (`_G`) — required: this is where `pcall`/`xpcall`/`error`/
26
+ * `assert`/`type`/`tostring`/`tonumber`/`pairs`/`ipairs`/`next`/`select`
27
+ * live in stock Lua 5.4. There's no way to get these without opening
28
+ * Base, so Base is opened and then aggressively pruned (see below) —
29
+ * everything Base brings that ISN'T on the whitelist gets nil'd out by
30
+ * the scrub prelude.
31
+ * - String — string manipulation is core to "fetch JSON, format a label"
32
+ * scripts; pruned of `string.dump` only (see SCRUB_PRELUDE).
33
+ * - Table — `table.insert/remove/concat/sort/pack/unpack/move`: no
34
+ * member of this library reads/writes anything outside the table passed
35
+ * to it, so the whole library is safe as-is; nothing pruned.
36
+ * - Math — pure numeric functions (including `math.random`/
37
+ * `math.randomseed` — a script influencing its own PRNG seed cannot
38
+ * reach anything outside its own arithmetic); nothing pruned.
39
+ *
40
+ * NOT linked, and why (spec §10 threat model: the script is hostile):
41
+ * - Coroutine — `coroutine.*` would let a script create additional Lua
42
+ * threads it controls directly. We already run the whole script on a
43
+ * dedicated child thread and drive `resume`/`yield` ourselves (for the
44
+ * capability await bridge, `./capabilities`, and the instruction-count
45
+ * hook, `./limits`); handing the script its own `coroutine.create` is
46
+ * unnecessary for the documented host API and only adds surface area
47
+ * (e.g. spawning coroutines to dodge the hook's per-thread installation
48
+ * — see the `./limits` doc comment on why hooks are per-thread).
49
+ * - IO, OS — filesystem/process/env/clock access. The bundle-scoped
50
+ * filesystem (§11) is the ONLY filesystem a script gets, via the
51
+ * injected `bundle.*` capability table (`./capabilities`), never real
52
+ * `io`.
53
+ * - Package — `package.loadlib`/`package.cpath` is exactly "load a native
54
+ * C module", meaningless (and dangerous, if it somehow resolved) in a
55
+ * WASM sandbox with no real filesystem; `require` itself is intentionally
56
+ * not wired to it either (see `./require`).
57
+ * - Debug — `debug.getmetatable`/`debug.getupvalue`/`debug.sethook`
58
+ * are a complete metatable/upvalue/hook bypass of every other
59
+ * restriction in this file. Never loaded.
60
+ * - UTF8 — not on the documented host API surface; omitted by default
61
+ * deny (a script working with byte strings and `string.*` covers the
62
+ * documented use cases). Add it if a real need shows up — it carries no
63
+ * special risk (pure string library), it just wasn't asked for.
64
+ */
65
+ const LIBRARIES = [
66
+ LuaLibraries.Base,
67
+ LuaLibraries.String,
68
+ LuaLibraries.Table,
69
+ LuaLibraries.Math,
70
+ ];
71
+ /**
72
+ * Runs once per fresh engine, before any capability table or user code is
73
+ * injected. Mutates the real `string` table object in place (`string.dump
74
+ * = nil`) rather than replacing the `string` global — the metatable that
75
+ * makes `("x"):upper()` work points at this same table object, so deleting
76
+ * the field this way removes both `string.dump(...)` AND `("x"):dump()` in
77
+ * one move, and can't be un-done by anything a script can reach (no
78
+ * `getmetatable` is exposed — see below — so a script cannot even inspect,
79
+ * let alone rewrite, that metatable).
80
+ *
81
+ * Base-library names removed, each independently a documented sandbox
82
+ * escape or ambient-authority primitive:
83
+ * - `load`, `loadstring`, `loadfile`, `dofile` — compile/run arbitrary
84
+ * source or bytecode text at runtime; the entire point of §10 is that
85
+ * the ONLY code that ever runs is the one chunk the host handed in.
86
+ * `lua_load` also accepts precompiled bytecode with no source-text
87
+ * validation — bytecode is not sandboxed the way source is (it can
88
+ * encode out-of-range opcodes that crash or exploit the VM), which is
89
+ * also why `string.dump` (bytecode *production*) is removed below.
90
+ * - `collectgarbage` — its `"count"` argument is a harmless memory query
91
+ * but other arguments (`"stop"`, `"generational"`, `"incremental"`) let
92
+ * a script retune the collector as a denial-of-service knob against the
93
+ * memory cap installed in `./sandbox`; removed wholesale rather than
94
+ * allow-listing a sub-mode we don't need.
95
+ * - `rawget`, `rawset`, `rawequal`, `rawlen` — bypass `__index`/
96
+ * `__newindex`/`__eq`/`__len` metamethods. Nothing in this sandbox
97
+ * currently relies on metamethod interception for security (we don't
98
+ * proxy the capability tables — see `./capabilities`), but keeping raw
99
+ * accessors off by default costs the sandbox nothing and closes off
100
+ * that category of future bug entirely: default-deny.
101
+ * - `getmetatable`, `setmetatable` — `getmetatable("")` is the standard
102
+ * way to reach the shared string metatable and, with `setmetatable`,
103
+ * rewrite it — potentially restoring a removed method or corrupting
104
+ * `__index` for every string literal for the rest of the run. Removing
105
+ * both closes this off completely (there is then no Lua-reachable way
106
+ * to obtain any metatable at all, since `debug.getmetatable` is also
107
+ * unavailable — `debug` is never loaded).
108
+ * - `print`, `warn` — write to the process's real stdout/stderr. Not a
109
+ * sandbox-escape by itself, but not on the documented host API surface
110
+ * (scripts communicate by *returning a value*, per spec §8 — "Scripts
111
+ * return values; they never write into the document body") and a
112
+ * console/stderr channel is an unnecessary side channel; default-deny.
113
+ * - `_G`, `_VERSION` — `_G` is merely the *name* Base binds to the real
114
+ * globals table for convenience; removing the name does not remove the
115
+ * table (every chunk's `_ENV` upvalue still refers to it — that's
116
+ * unavoidable in Lua and is exactly why every prune in this file is done
117
+ * by MUTATING the real table/globals rather than rebinding a name to
118
+ * something else). What matters is that the *table itself* never had
119
+ * `os`/`io`/`debug`/`package`/`load`/etc. as members to begin with, so
120
+ * reaching it via `_ENV` recovers nothing. `_VERSION` is a harmless
121
+ * info leak with no use on the documented API; removed for
122
+ * default-deny consistency.
123
+ *
124
+ * Explicitly KEPT from Base (each is on the DoD-mandated whitelist):
125
+ * `tonumber`, `tostring`, `type`, `ipairs`, `pairs`, `next`, `select`,
126
+ * `error`, `assert`, `pcall`, `xpcall`. NOTE: `xpcall` is kept in name only —
127
+ * the C-level `xpcall` Base actually provides is REPLACED by a pure-Lua
128
+ * reimplementation (`XPCALL_REIMPLEMENTATION` below, run as part of this
129
+ * prelude) that closes a host-deadlock the C version has under this
130
+ * sandbox's limits hook; see that constant's doc comment for the full
131
+ * mechanism and empirical evidence.
132
+ *
133
+ * `unpack`/`table.unpack`: Lua 5.4 (this build, no 5.1-compat flag) never
134
+ * defines a *global* `unpack` — only `table.unpack` exists in stock 5.4,
135
+ * confirmed empirically (a fresh engine with Base+Table loaded has no
136
+ * global `unpack`). We do not synthesize one; `table.unpack` is exposed as
137
+ * part of the (fully kept) Table library and is the documented spelling.
138
+ */
139
+ /**
140
+ * Replaces the C-level \`xpcall\` with a pure-Lua reimplementation built on
141
+ * \`pcall\`.
142
+ *
143
+ * ## Why: the C \`xpcall\` deadlocks the host under the limits hook
144
+ *
145
+ * \`./limits\`' instruction/wall-clock interrupt works by having the JS
146
+ * count-hook call \`thread.lua.lua_error(...)\` — a longjmp — from inside the
147
+ * hook. That is an ordinary Lua error, so \`pcall\` and \`xpcall\` both catch
148
+ * it (this is exactly what makes the "script's own pcall can't swallow the
149
+ * interrupt" guarantee in \`./limits\` need the out-of-band JS flag in the
150
+ * first place). With \`pcall\` that is harmless: no user code runs in
151
+ * response, the call just returns \`false, err\` and the JS-side breach flag
152
+ * still forces the run to a hard failure. But the C \`xpcall\` INVOKES THE
153
+ * USER'S MESSAGE HANDLER as part of unwinding, while the VM is still inside
154
+ * the C-level xpcall error-handling frame. If that handler also runs long
155
+ * enough for the hook to fire again (trivially true once the hook has
156
+ * already tightened to \`count = 1\` after the first breach — see
157
+ * \`./limits\`), the second \`lua_error\` longjmps AGAIN, nested inside the
158
+ * xpcall error-handler's setjmp/Asyncify state, and wasmoon deadlocks:
159
+ * \`thread.run()\` never resolves or rejects, the JS event loop is fully
160
+ * blocked (not spinning — an idle wait), and nothing external, including an
161
+ * unrelated \`setTimeout\`, ever fires again. Verified empirically against
162
+ * wasmoon 1.16.0 in a disposable child-process harness (own OS-level SIGKILL
163
+ * watchdog, run outside this repo's test suite so a still-hanging case could
164
+ * never block CI): \`return xpcall(f, f)\` and three structural variants
165
+ * (looping in a \`while\` around the xpcall call, wrapping the xpcall call in
166
+ * an outer \`pcall\`, and a looping message handler with a non-looping body)
167
+ * all hang the host process indefinitely under the stock C \`xpcall\`, while
168
+ * \`pcall\`-only equivalents (including the pcall-wrapped-retry-loop "worst
169
+ * case" documented in \`./limits\`) correctly terminate as limit failures.
170
+ *
171
+ * ## The fix
172
+ *
173
+ * Reimplementing \`xpcall\` in Lua on top of \`pcall\` means the message
174
+ * handler runs at ORDINARY Lua call depth (an ordinary function call from
175
+ * inside this prelude's own \`xpcall\`, itself invoked through \`pcall\`),
176
+ * never inside the C xpcall error-unwind frame — so a hook-triggered
177
+ * longjmp during the handler propagates exactly like it does for the
178
+ * already-safe \`pcall\` cases, instead of deadlocking. Verified empirically
179
+ * in the same disposable harness: with this reimplementation installed, all
180
+ * four former-hang cases now terminate (well under a second) as
181
+ * \`{ok:false, error:{kind:'limit', limit:'instructions'}}\`, and legitimate
182
+ * \`xpcall(f, handler)\` usage (handler receiving the error object on
183
+ * failure; all return values passed through unchanged on success) is
184
+ * unaffected — see \`limits.deadlock.test.ts\`.
185
+ *
186
+ * ## Accepted semantics change (safe in this sandbox)
187
+ *
188
+ * The handler now runs AFTER the stack has unwound (an ordinary \`pcall\`
189
+ * return), not WHILE it is still live, so it cannot walk a live traceback
190
+ * the way \`debug.traceback\` would from inside a real C \`xpcall\` handler.
191
+ * This loses nothing real here: \`debug\` is never loaded in this sandbox
192
+ * (see the library table below), so no script could have used that
193
+ * capability anyway. \`table.pack\`/\`table.unpack\` preserve variadic
194
+ * arguments to \`f\` and every return value on the success path, matching
195
+ * stock \`xpcall\`'s multi-return contract.
196
+ */
197
+ const XPCALL_REIMPLEMENTATION = `
198
+ do
199
+ local _pcall = pcall
200
+ xpcall = function(f, msgh, ...)
201
+ local r = table.pack(_pcall(f, ...))
202
+ if r[1] then return table.unpack(r, 1, r.n) end
203
+ return false, msgh(r[2])
204
+ end
205
+ end
206
+ `;
207
+ const SCRUB_PRELUDE = `
208
+ load = nil
209
+ loadstring = nil
210
+ loadfile = nil
211
+ dofile = nil
212
+ collectgarbage = nil
213
+ rawget = nil
214
+ rawset = nil
215
+ rawequal = nil
216
+ rawlen = nil
217
+ getmetatable = nil
218
+ setmetatable = nil
219
+ print = nil
220
+ warn = nil
221
+ _G = nil
222
+ _VERSION = nil
223
+ string.dump = nil
224
+ ${XPCALL_REIMPLEMENTATION}
225
+ `;
226
+ /** The Base-library names this sandbox intentionally keeps reachable. */
227
+ export const ALLOWED_GLOBALS = [
228
+ 'tonumber',
229
+ 'tostring',
230
+ 'type',
231
+ 'ipairs',
232
+ 'pairs',
233
+ 'next',
234
+ 'select',
235
+ 'error',
236
+ 'assert',
237
+ 'pcall',
238
+ 'xpcall',
239
+ 'string',
240
+ 'table',
241
+ 'math',
242
+ ];
243
+ /** Names this sandbox verifies are absent after scrubbing (see the adversarial test suite). */
244
+ export const DENIED_GLOBALS = [
245
+ 'os',
246
+ 'io',
247
+ 'require',
248
+ 'dofile',
249
+ 'loadfile',
250
+ 'load',
251
+ 'loadstring',
252
+ 'debug',
253
+ 'package',
254
+ 'coroutine',
255
+ 'collectgarbage',
256
+ 'rawget',
257
+ 'rawset',
258
+ 'rawequal',
259
+ 'rawlen',
260
+ 'getmetatable',
261
+ 'setmetatable',
262
+ 'print',
263
+ 'warn',
264
+ '_G',
265
+ '_VERSION',
266
+ ];
267
+ /**
268
+ * Creates a fresh wasmoon engine with the curated, empty environment
269
+ * described above. `traceAllocations: true` is required for the memory cap
270
+ * (`./limits` / `./sandbox` call `engine.global.setMemoryMax`) — without it
271
+ * wasmoon uses the plain, uncapped allocator.
272
+ */
273
+ export async function createEmptyLuaEngine(options) {
274
+ const factory = new LuaFactory(options?.wasmUri);
275
+ const engine = await factory.createEngine({
276
+ openStandardLibs: false,
277
+ traceAllocations: true,
278
+ });
279
+ for (const library of LIBRARIES) {
280
+ engine.global.loadLibrary(library);
281
+ }
282
+ await engine.doString(SCRUB_PRELUDE);
283
+ return engine;
284
+ }
@@ -0,0 +1,15 @@
1
+ export type { ScriptFailure, ScriptLimitKind, ScriptMarshalReason, } from './errors.js';
2
+ export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
3
+ export type { CreateEmptyLuaEngineOptions } from './globals.js';
4
+ export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
5
+ export type { LimitHandle, ScriptLimits } from './limits.js';
6
+ export { DEFAULT_LIMITS, installLimits } from './limits.js';
7
+ export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
8
+ export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
9
+ export type { MarshalLimits } from './marshal.js';
10
+ export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
11
+ export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
12
+ export type { RunScriptOptions, RunScriptResult } from './sandbox.js';
13
+ export { runScript } from './sandbox.js';
14
+ export type { LuaExecutorConfig } from './executor.js';
15
+ export { createLuaExecutor } from './executor.js';
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ // @markii/lua: the sandboxed Lua 5.4 (wasmoon) execution primitive backing
2
+ // spec §8 (scripting), §10 (capability security), and §11 (bundle-scoped
3
+ // filesystem). No React, no @markii/core, no @markii/react — see CLAUDE.md's
4
+ // import rule and the ESLint guard in the root config. May depend on
5
+ // @markii/bundle for the `ScriptView` capability type only.
6
+ export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
7
+ export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
8
+ export { DEFAULT_LIMITS, installLimits } from './limits.js';
9
+ export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
10
+ export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
11
+ export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
12
+ export { runScript } from './sandbox.js';
13
+ export { createLuaExecutor } from './executor.js';
@@ -0,0 +1,146 @@
1
+ import { type LuaThread } from 'wasmoon';
2
+ import type { ScriptLimitKind } from './errors.js';
3
+ /** Resource limits for one `runScript` call. All configurable; defaults are conservative. */
4
+ export interface ScriptLimits {
5
+ /** Lua VM instructions before the run is killed. Default 100,000,000. */
6
+ maxInstructions: number;
7
+ /** Wall-clock milliseconds before the run is killed. Default 5,000. */
8
+ wallClockMs: number;
9
+ /** Bytes the Lua allocator may hand out before allocations start failing. Default 32 MiB. */
10
+ maxMemoryBytes: number;
11
+ /**
12
+ * VM instructions between hook firings (the `count` argument to
13
+ * `lua_sethook`'s `LUA_MASKCOUNT`). Smaller = finer-grained wall-clock
14
+ * checks and lower overshoot past `maxInstructions`, at the cost of more
15
+ * hook-call overhead per instruction executed. Default 10,000.
16
+ */
17
+ hookIntervalInstructions: number;
18
+ }
19
+ export declare const DEFAULT_LIMITS: ScriptLimits;
20
+ export interface LimitHandle {
21
+ /** True once the instruction/wall-clock hook has fired at least once. Authoritative: check this after every run, regardless of the run's apparent outcome (see the module doc comment). */
22
+ isBreached(): boolean;
23
+ breachKind(): ScriptLimitKind | undefined;
24
+ /** Removes the WASM function pointer and clears the hook. Must be called exactly once, in the run's `finally`. */
25
+ dispose(): void;
26
+ }
27
+ /**
28
+ * Installs the instruction-count + wall-clock interrupt on `thread` (a
29
+ * dedicated child thread from `engine.global.newThread()` — see the "hooks
30
+ * are per-thread" note below) and returns a handle to read/tear it down.
31
+ *
32
+ * ## Why this exists instead of wasmoon's own `Thread.setTimeout`/`run({
33
+ * timeout })`
34
+ *
35
+ * wasmoon ships a built-in mechanism (`Thread.setTimeout`, used internally
36
+ * by `run({ timeout })`): it installs a `lua_sethook` count hook that, once
37
+ * `Date.now() > deadline`, pushes a `LuaTimeoutError` and calls
38
+ * `lua_error`. That is a completely ordinary Lua error from the VM's point
39
+ * of view — indistinguishable from a script calling `error("x")` itself —
40
+ * which means it is fully catchable by `pcall`:
41
+ *
42
+ * ```lua
43
+ * pcall(function() while true do end end) -- catches the timeout, keeps going
44
+ * ```
45
+ *
46
+ * This is exactly the case the task calls out as "the crucial one": the
47
+ * interrupt must abort the WHOLE RUN, not be swallowed by the script's own
48
+ * `pcall`. Verified empirically against wasmoon 1.16.0 — a script that
49
+ * `pcall`s an infinite loop and then keeps running afterward completes
50
+ * "successfully" under wasmoon's own `setTimeout`/`run({timeout})`
51
+ * mechanism; the timeout is silently absorbed.
52
+ *
53
+ * ## The fix: an out-of-band JS flag, plus shrinking the hook interval to 1
54
+ *
55
+ * This hook does two things wasmoon's built-in one doesn't:
56
+ *
57
+ * 1. It sets a plain JS closure variable (`breached`) the instant the limit
58
+ * is first exceeded. This flag lives entirely on the JS side — no Lua
59
+ * value, no metatable, no `pcall` can ever see or clear it. `sandbox.ts`
60
+ * checks this flag UNCONDITIONALLY after every run and overrides the
61
+ * result to a hard `ScriptLimitError` if it's set, regardless of what
62
+ * the Lua-level call returned or whether a `pcall` inside the script
63
+ * reported "success". This is the actual enforcement point — not the
64
+ * Lua-level error the hook also raises (see next point).
65
+ * 2. On the FIRST breach, it re-installs the hook with `count = 1` (fires
66
+ * on literally every subsequent VM instruction, not just every
67
+ * `hookIntervalInstructions`). Combined with the hook continuing to
68
+ * call `lua_error` every time it fires, this makes forward progress
69
+ * after a breach exceedingly hard: even a script that wraps the
70
+ * offending loop in its own `pcall` and immediately retries in an outer
71
+ * loop gets re-interrupted after (usually) a single VM instruction, so
72
+ * the error re-escalates and reaches the top of the call stack (a
73
+ * frame with no enclosing `pcall`) almost immediately. Verified
74
+ * empirically: `local n=0; while true do pcall(function() while true do
75
+ * end end); n=n+1 end` — the worst case in the adversarial suite,
76
+ * combining an inner `pcall`-wrapped infinite loop with an outer
77
+ * infinite retry loop — still terminates (as a hard failure, `n` never
78
+ * returned) in under 100ms with `maxInstructions = 5,000,000`.
79
+ *
80
+ * Point 2 is a strong practical deterrent but is NOT a formal proof of
81
+ * "zero possible forward progress after breach" — a script could in
82
+ * principle structure code so that literally every single VM instruction
83
+ * after the breach happens inside its own fresh `pcall` frame (each
84
+ * `pcall` call itself is several instructions, some of which execute
85
+ * outside any protection while the closure/call frame is being set up).
86
+ * Point 1 is what actually closes the loophole: even in that
87
+ * (implausible, and in practice never observed) worst case, the run's
88
+ * *reported outcome* is still forced to a limit failure by the JS-side
89
+ * flag, which the script has no way to reach or clear. **Solid** claim:
90
+ * the reported result of a breached run is always a limit failure, never
91
+ * a script-controlled "success" — but this was verified against `pcall`
92
+ * specifically, and does NOT generalize to every Lua construct that can
93
+ * catch an error. It was FALSE, for one such construct, until a separate
94
+ * fix landed: the stock C `xpcall` invokes its message handler WHILE still
95
+ * inside the C-level xpcall error-unwind frame, and a hook-triggered
96
+ * longjmp firing again during that handler (trivial once the hook has
97
+ * tightened to `count = 1` after the first breach) re-enters that same
98
+ * setjmp/Asyncify state and deadlocks wasmoon's `thread.run()` outright —
99
+ * not a script-controlled "success", but a hung HOST, which is worse. That
100
+ * specific hole is closed by replacing `xpcall` with a pure-Lua
101
+ * reimplementation built on `pcall` (see `./globals`'s
102
+ * `XPCALL_REIMPLEMENTATION`), so the message handler runs at ordinary Lua
103
+ * call depth instead of inside the C xpcall frame, where a re-firing hook
104
+ * behaves exactly like the already-safe `pcall` case. **Best-effort, not
105
+ * airtight** claim, even with that fix: the breached script's own further
106
+ * CPU consumption is curtailed almost immediately, not provably instantly,
107
+ * and this in-VM hook is fundamentally a cooperative, best-effort
108
+ * mechanism — it only runs BETWEEN Lua VM instructions and cannot preempt
109
+ * a single WASM-synchronous hang (whether from an as-yet-undiscovered catch
110
+ * construct with the same nested-frame shape as the old `xpcall`, or from
111
+ * something outside the VM's own instruction stream entirely). The
112
+ * AUTHORITATIVE kill for that class of failure is not this hook at all —
113
+ * it's the host's EXTERNAL, terminatable-isolate watchdog (dedicated Web
114
+ * Worker/`worker_thread` + an outside wall-clock timer calling
115
+ * `terminate()`), now normative in DESIGN.md §10 ("In-process limits are
116
+ * best-effort; the terminatable isolate is the real guarantee"). This
117
+ * module's hook reduces how often that external kill is needed and gives
118
+ * fast, precise, in-band error classification for the common compute-bound
119
+ * case; it is not, and cannot be, a substitute for the external watchdog.
120
+ *
121
+ * ## Why hooks are per-thread, and why that matters here
122
+ *
123
+ * `lua_sethook` sets the hook on the specific `lua_State` passed to it.
124
+ * `engine.doString()` internally creates a NEW child thread via
125
+ * `engine.global.newThread()` and runs the script there — a hook installed
126
+ * on `engine.global.address` would never fire for code run through
127
+ * `doString`. `sandbox.ts` therefore does NOT use `engine.doString()` for
128
+ * the untrusted script; it creates the child thread itself (mirroring what
129
+ * `doString` does internally) so it can install this hook on that exact
130
+ * thread before loading/running the script.
131
+ *
132
+ * ## Wall-clock accuracy
133
+ *
134
+ * The wall-clock check only runs when the hook fires — i.e. every
135
+ * `hookIntervalInstructions` VM instructions. This is not a true
136
+ * OS-level preemption (nothing in wasmoon offers that inside a single JS
137
+ * thread); it bounds overshoot to "however long `hookIntervalInstructions`
138
+ * takes to execute", which for the default of 10,000 is sub-millisecond on
139
+ * ordinary hardware. It does NOT protect against a single hung *host*
140
+ * operation (e.g. a capability call that never resolves its promise) —
141
+ * that's a separate concern handled by the `Promise.race` wall-clock guard
142
+ * in `sandbox.ts`, since Lua isn't executing any instructions while
143
+ * suspended on an `await`, so this hook simply never fires during that
144
+ * window.
145
+ */
146
+ export declare function installLimits(thread: LuaThread, limits: Pick<ScriptLimits, 'maxInstructions' | 'wallClockMs' | 'hookIntervalInstructions'>): LimitHandle;
package/dist/limits.js ADDED
@@ -0,0 +1,172 @@
1
+ import { LuaEventMasks } from 'wasmoon';
2
+ export const DEFAULT_LIMITS = {
3
+ maxInstructions: 100_000_000,
4
+ wallClockMs: 5_000,
5
+ maxMemoryBytes: 32 * 1024 * 1024,
6
+ hookIntervalInstructions: 10_000,
7
+ };
8
+ /**
9
+ * Installs the instruction-count + wall-clock interrupt on `thread` (a
10
+ * dedicated child thread from `engine.global.newThread()` — see the "hooks
11
+ * are per-thread" note below) and returns a handle to read/tear it down.
12
+ *
13
+ * ## Why this exists instead of wasmoon's own `Thread.setTimeout`/`run({
14
+ * timeout })`
15
+ *
16
+ * wasmoon ships a built-in mechanism (`Thread.setTimeout`, used internally
17
+ * by `run({ timeout })`): it installs a `lua_sethook` count hook that, once
18
+ * `Date.now() > deadline`, pushes a `LuaTimeoutError` and calls
19
+ * `lua_error`. That is a completely ordinary Lua error from the VM's point
20
+ * of view — indistinguishable from a script calling `error("x")` itself —
21
+ * which means it is fully catchable by `pcall`:
22
+ *
23
+ * ```lua
24
+ * pcall(function() while true do end end) -- catches the timeout, keeps going
25
+ * ```
26
+ *
27
+ * This is exactly the case the task calls out as "the crucial one": the
28
+ * interrupt must abort the WHOLE RUN, not be swallowed by the script's own
29
+ * `pcall`. Verified empirically against wasmoon 1.16.0 — a script that
30
+ * `pcall`s an infinite loop and then keeps running afterward completes
31
+ * "successfully" under wasmoon's own `setTimeout`/`run({timeout})`
32
+ * mechanism; the timeout is silently absorbed.
33
+ *
34
+ * ## The fix: an out-of-band JS flag, plus shrinking the hook interval to 1
35
+ *
36
+ * This hook does two things wasmoon's built-in one doesn't:
37
+ *
38
+ * 1. It sets a plain JS closure variable (`breached`) the instant the limit
39
+ * is first exceeded. This flag lives entirely on the JS side — no Lua
40
+ * value, no metatable, no `pcall` can ever see or clear it. `sandbox.ts`
41
+ * checks this flag UNCONDITIONALLY after every run and overrides the
42
+ * result to a hard `ScriptLimitError` if it's set, regardless of what
43
+ * the Lua-level call returned or whether a `pcall` inside the script
44
+ * reported "success". This is the actual enforcement point — not the
45
+ * Lua-level error the hook also raises (see next point).
46
+ * 2. On the FIRST breach, it re-installs the hook with `count = 1` (fires
47
+ * on literally every subsequent VM instruction, not just every
48
+ * `hookIntervalInstructions`). Combined with the hook continuing to
49
+ * call `lua_error` every time it fires, this makes forward progress
50
+ * after a breach exceedingly hard: even a script that wraps the
51
+ * offending loop in its own `pcall` and immediately retries in an outer
52
+ * loop gets re-interrupted after (usually) a single VM instruction, so
53
+ * the error re-escalates and reaches the top of the call stack (a
54
+ * frame with no enclosing `pcall`) almost immediately. Verified
55
+ * empirically: `local n=0; while true do pcall(function() while true do
56
+ * end end); n=n+1 end` — the worst case in the adversarial suite,
57
+ * combining an inner `pcall`-wrapped infinite loop with an outer
58
+ * infinite retry loop — still terminates (as a hard failure, `n` never
59
+ * returned) in under 100ms with `maxInstructions = 5,000,000`.
60
+ *
61
+ * Point 2 is a strong practical deterrent but is NOT a formal proof of
62
+ * "zero possible forward progress after breach" — a script could in
63
+ * principle structure code so that literally every single VM instruction
64
+ * after the breach happens inside its own fresh `pcall` frame (each
65
+ * `pcall` call itself is several instructions, some of which execute
66
+ * outside any protection while the closure/call frame is being set up).
67
+ * Point 1 is what actually closes the loophole: even in that
68
+ * (implausible, and in practice never observed) worst case, the run's
69
+ * *reported outcome* is still forced to a limit failure by the JS-side
70
+ * flag, which the script has no way to reach or clear. **Solid** claim:
71
+ * the reported result of a breached run is always a limit failure, never
72
+ * a script-controlled "success" — but this was verified against `pcall`
73
+ * specifically, and does NOT generalize to every Lua construct that can
74
+ * catch an error. It was FALSE, for one such construct, until a separate
75
+ * fix landed: the stock C `xpcall` invokes its message handler WHILE still
76
+ * inside the C-level xpcall error-unwind frame, and a hook-triggered
77
+ * longjmp firing again during that handler (trivial once the hook has
78
+ * tightened to `count = 1` after the first breach) re-enters that same
79
+ * setjmp/Asyncify state and deadlocks wasmoon's `thread.run()` outright —
80
+ * not a script-controlled "success", but a hung HOST, which is worse. That
81
+ * specific hole is closed by replacing `xpcall` with a pure-Lua
82
+ * reimplementation built on `pcall` (see `./globals`'s
83
+ * `XPCALL_REIMPLEMENTATION`), so the message handler runs at ordinary Lua
84
+ * call depth instead of inside the C xpcall frame, where a re-firing hook
85
+ * behaves exactly like the already-safe `pcall` case. **Best-effort, not
86
+ * airtight** claim, even with that fix: the breached script's own further
87
+ * CPU consumption is curtailed almost immediately, not provably instantly,
88
+ * and this in-VM hook is fundamentally a cooperative, best-effort
89
+ * mechanism — it only runs BETWEEN Lua VM instructions and cannot preempt
90
+ * a single WASM-synchronous hang (whether from an as-yet-undiscovered catch
91
+ * construct with the same nested-frame shape as the old `xpcall`, or from
92
+ * something outside the VM's own instruction stream entirely). The
93
+ * AUTHORITATIVE kill for that class of failure is not this hook at all —
94
+ * it's the host's EXTERNAL, terminatable-isolate watchdog (dedicated Web
95
+ * Worker/`worker_thread` + an outside wall-clock timer calling
96
+ * `terminate()`), now normative in DESIGN.md §10 ("In-process limits are
97
+ * best-effort; the terminatable isolate is the real guarantee"). This
98
+ * module's hook reduces how often that external kill is needed and gives
99
+ * fast, precise, in-band error classification for the common compute-bound
100
+ * case; it is not, and cannot be, a substitute for the external watchdog.
101
+ *
102
+ * ## Why hooks are per-thread, and why that matters here
103
+ *
104
+ * `lua_sethook` sets the hook on the specific `lua_State` passed to it.
105
+ * `engine.doString()` internally creates a NEW child thread via
106
+ * `engine.global.newThread()` and runs the script there — a hook installed
107
+ * on `engine.global.address` would never fire for code run through
108
+ * `doString`. `sandbox.ts` therefore does NOT use `engine.doString()` for
109
+ * the untrusted script; it creates the child thread itself (mirroring what
110
+ * `doString` does internally) so it can install this hook on that exact
111
+ * thread before loading/running the script.
112
+ *
113
+ * ## Wall-clock accuracy
114
+ *
115
+ * The wall-clock check only runs when the hook fires — i.e. every
116
+ * `hookIntervalInstructions` VM instructions. This is not a true
117
+ * OS-level preemption (nothing in wasmoon offers that inside a single JS
118
+ * thread); it bounds overshoot to "however long `hookIntervalInstructions`
119
+ * takes to execute", which for the default of 10,000 is sub-millisecond on
120
+ * ordinary hardware. It does NOT protect against a single hung *host*
121
+ * operation (e.g. a capability call that never resolves its promise) —
122
+ * that's a separate concern handled by the `Promise.race` wall-clock guard
123
+ * in `sandbox.ts`, since Lua isn't executing any instructions while
124
+ * suspended on an `await`, so this hook simply never fires during that
125
+ * window.
126
+ */
127
+ export function installLimits(thread, limits) {
128
+ let breached = false;
129
+ let kind;
130
+ let instructionsSeen = 0;
131
+ let hookInterval = limits.hookIntervalInstructions;
132
+ const deadline = Date.now() + limits.wallClockMs;
133
+ const hookPointer = thread.lua.module.addFunction(() => {
134
+ instructionsSeen += hookInterval;
135
+ if (!breached) {
136
+ if (instructionsSeen >= limits.maxInstructions) {
137
+ breached = true;
138
+ kind = 'instructions';
139
+ }
140
+ else if (Date.now() >= deadline) {
141
+ breached = true;
142
+ kind = 'timeout';
143
+ }
144
+ if (breached) {
145
+ // Tighten the hook so every subsequent instruction re-triggers —
146
+ // see the module doc comment ("point 2").
147
+ hookInterval = 1;
148
+ thread.lua.lua_sethook(thread.address, hookPointer, LuaEventMasks.Count, 1);
149
+ }
150
+ }
151
+ if (breached) {
152
+ thread.pushValue(`MARK_LIMIT: ${kind ?? 'instructions'} limit exceeded`);
153
+ thread.lua.lua_error(thread.address);
154
+ }
155
+ // Unreachable in practice: lua_error longjmps and never returns here.
156
+ }, 'vii');
157
+ thread.lua.lua_sethook(thread.address, hookPointer, LuaEventMasks.Count, limits.hookIntervalInstructions);
158
+ let disposed = false;
159
+ return {
160
+ isBreached: () => breached,
161
+ breachKind: () => kind,
162
+ dispose: () => {
163
+ if (disposed)
164
+ return;
165
+ disposed = true;
166
+ if (!thread.isClosed()) {
167
+ thread.lua.lua_sethook(thread.address, null, 0, 0);
168
+ }
169
+ thread.lua.module.removeFunction(hookPointer);
170
+ },
171
+ };
172
+ }