@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,336 @@
1
+ import { LuaReturn } from 'wasmoon';
2
+ import { buildCapabilities, } from './capabilities.js';
3
+ import { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
4
+ import { createEmptyLuaEngine } from './globals.js';
5
+ import { DEFAULT_LIMITS, installLimits } from './limits.js';
6
+ import { buildMarshalPrelude, DEFAULT_MARSHAL_LIMITS, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
7
+ /**
8
+ * The wall-clock hard-kill in `./limits` only fires between Lua VM
9
+ * instructions — it cannot observe a script suspended on `:await()`-ing a
10
+ * host-provided async capability call that never resolves (no instructions
11
+ * execute during that wait, so the hook never gets scheduled). This extra
12
+ * margin over `limits.wallClockMs` before the outer race guard below fires
13
+ * gives the IN-VM hook first right of way for the (far more common)
14
+ * compute-bound case, so the two mechanisms don't race each other for the
15
+ * `breachKind` attribution; this guard exists purely as the backstop for
16
+ * the async-hang case the in-VM hook structurally cannot see.
17
+ */
18
+ const WALL_CLOCK_GUARD_SLACK_MS = 250;
19
+ function describeError(err) {
20
+ if (err instanceof Error)
21
+ return err.message;
22
+ return String(err);
23
+ }
24
+ /**
25
+ * Wraps `thread.assertOk` (public wasmoon API, `thread.d.ts`) to capture the
26
+ * raw C-API status code (`LuaReturn`) that `lua_resume`/`lua_pcall` returned
27
+ * for the run, before wasmoon collapses it into a generic `Error`. Returns a
28
+ * getter for that last-seen code, plus a restore function.
29
+ *
30
+ * ## Why this exists: memory-cap breaches were misclassified as `'runtime'`
31
+ *
32
+ * `thread.run()` calls `this.assertOk(resumeResult.result)` exactly once,
33
+ * with the terminal status of the run. When the Lua allocator (the
34
+ * `traceAllocations`-backed custom allocator `./globals` installs, capped by
35
+ * `engine.global.setMemoryMax`) returns null past the cap, the VM raises
36
+ * `LUA_ERRMEM` — `assertOk` sees `LuaReturn.ErrorMem` (4) and throws a plain
37
+ * `Error` whose `.message` is set directly from `lua_tolstring` (skipping
38
+ * the traceback step, since traceback generation itself needs allocation
39
+ * that could also fail under OOM). That message is INDISTINGUISHABLE from
40
+ * an ordinary runtime error's message on the far side — both are plain
41
+ * strings — so without this hook, a genuine memory-cap breach and a
42
+ * script's own `error("not enough memory")` collapse to the exact same
43
+ * shape and there is no reliable way to tell them apart from the message
44
+ * alone.
45
+ *
46
+ * ## Why the status code is non-spoofable (unlike message matching)
47
+ *
48
+ * `LuaReturn.ErrorMem` is the literal C-API return code from
49
+ * `lua_resume`/`lua_pcall` — it is set by the Lua VM's own error-throwing
50
+ * path (`luaD_throw` with `LUA_ERRMEM`) when the allocator fails, and by
51
+ * nothing else. A script calling `error("not enough memory")` raises an
52
+ * ORDINARY Lua error (`LUA_ERRRUN`, code 2) — verified empirically in a
53
+ * throwaway harness: `error('not enough memory')` yields status 2, while an
54
+ * actual allocator-capped allocation yields status 4, with output messages
55
+ * that are otherwise identical strings. A script has no way to make Lua's
56
+ * own C `lua_resume` return `LUA_ERRMEM` other than genuinely exhausting the
57
+ * capped allocator.
58
+ *
59
+ * ## Why this does NOT reclassify a script's own `pcall`-caught OOM
60
+ *
61
+ * When a script wraps the failing allocation in its OWN `pcall`
62
+ * (`pcall(function() return string.rep(...) end)`), the `LUA_ERRMEM` is
63
+ * raised and caught entirely INSIDE that inner `lua_pcall`, at the Lua
64
+ * level — the outer `lua_resume` that `thread.run()` drives still completes
65
+ * with status `LuaReturn.Ok` (the script's own `pcall` returned `false,
66
+ * "not enough memory"` as an ordinary value). `assertOk` is therefore never
67
+ * called with `ErrorMem` in that case, so this hook correctly leaves that
68
+ * case alone — matching the existing (and intentional) behavior asserted in
69
+ * `sandbox.test.ts`'s "memory cap stops a string.rep balloon ... without
70
+ * OOM-ing the process" test, which expects that case to come back as an
71
+ * ordinary successful run (`ok: true, value: 'false'`), not a `'limit'`
72
+ * failure.
73
+ */
74
+ function captureAssertOkStatus(thread) {
75
+ const original = thread.assertOk.bind(thread);
76
+ let lastStatus;
77
+ thread.assertOk = (result) => {
78
+ lastStatus = result;
79
+ original(result);
80
+ };
81
+ return {
82
+ lastStatus: () => lastStatus,
83
+ restore: () => {
84
+ thread.assertOk = original;
85
+ },
86
+ };
87
+ }
88
+ function extractMarshalReason(message) {
89
+ // The tagged reason is always on the SAME LINE as the tag (Lua's
90
+ // `error()` produces "chunkname:line: MARK_MARSHAL:<reason>[:extra]");
91
+ // wasmoon appends a "\nstack traceback:\n..." block after it, which
92
+ // itself contains further colons (e.g. "[string \"...\"]:10:") — take
93
+ // only the first line before splitting on ":", or those traceback
94
+ // colons get mistaken for part of the tag.
95
+ const afterTag = message
96
+ .slice(message.indexOf(MARSHAL_ERROR_TAG) + MARSHAL_ERROR_TAG.length)
97
+ .split('\n')[0];
98
+ const tag = (afterTag ?? '').replace(/^:/, '').split(':')[0]?.trim();
99
+ switch (tag) {
100
+ case 'nodes':
101
+ return 'nodes';
102
+ case 'depth':
103
+ return 'depth';
104
+ case 'cycle':
105
+ return 'cycle';
106
+ case 'key-type':
107
+ return 'key-type';
108
+ case 'type':
109
+ return 'type';
110
+ default:
111
+ return 'type';
112
+ }
113
+ }
114
+ /**
115
+ * Classifies an error thrown out of `thread.run()` into the discriminated
116
+ * `ScriptFailure` shape. Message-prefix matching (`CAPABILITY_ERROR_TAG`,
117
+ * `MARSHAL_ERROR_TAG`) is used rather than `instanceof` because wasmoon
118
+ * does not preserve JS `Error` subclass identity across the Lua round trip
119
+ * — see the doc comment on those tags in `./errors` for the empirical
120
+ * evidence. Resource-limit breaches are ALL classified separately, BEFORE
121
+ * this function is ever called, via non-spoofable out-of-band signals —
122
+ * never through this message-based path, since a script can trivially
123
+ * forge any message string (e.g. `error("MARK_LIMIT: ...")` or
124
+ * `error("not enough memory")`) but cannot forge these:
125
+ * - instruction/wall-clock breaches: the JS closure flag from
126
+ * `./limits`' hook (see `runScript` below);
127
+ * - the async-hang backstop: `instanceof ScriptLimitError` on the
128
+ * `Promise.race` guard's own sentinel, which never crosses the Lua
129
+ * boundary (see the guard's construction in `runScript`);
130
+ * - memory-cap breaches: the raw `LuaReturn.ErrorMem` C-API status code
131
+ * captured by `captureAssertOkStatus` (see its doc comment).
132
+ */
133
+ function classifyRuntimeError(err) {
134
+ const message = describeError(err);
135
+ if (message.includes(CAPABILITY_ERROR_TAG)) {
136
+ return {
137
+ kind: 'capability',
138
+ message: message.replace(`${CAPABILITY_ERROR_TAG}: `, ''),
139
+ };
140
+ }
141
+ if (message.includes(MARSHAL_ERROR_TAG)) {
142
+ return {
143
+ kind: 'marshal',
144
+ reason: extractMarshalReason(message),
145
+ message,
146
+ };
147
+ }
148
+ return { kind: 'runtime', message };
149
+ }
150
+ /**
151
+ * Runs one Lua script in a fresh, fully isolated sandbox and always tears
152
+ * the engine down before returning — a run never leaves state (globals,
153
+ * memory, hooks) for a later run to inherit. Never throws: every way
154
+ * hostile code can fail comes back as `{ ok: false, error }`, never a raw
155
+ * exception (see `./errors`).
156
+ *
157
+ * Orchestration, in order:
158
+ * 1. `./globals` — fresh engine, curated empty environment (no `os`/`io`/
159
+ * `require`/etc; see that module for exactly what's kept and why).
160
+ * 2. Memory cap (`engine.global.setMemoryMax`, backed by the
161
+ * `traceAllocations: true` custom allocator `./globals` requests).
162
+ * 3. `./capabilities` — build the `net`/`cache`/`bundle` Lua tables from
163
+ * whatever providers/grants/tier this call was given.
164
+ * 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
165
+ * the wrapped user code's return value is piped through.
166
+ * 5. A dedicated child thread (NOT `engine.doString`, which creates its
167
+ * own internal thread we'd have no handle to — see `./limits`'s "hooks
168
+ * are per-thread" note) gets the instruction/wall-clock hook installed,
169
+ * then runs the wrapped user code.
170
+ * 6. The out-of-band breach flag from step 5's hook is checked
171
+ * UNCONDITIONALLY and, if set, wins over whatever the run otherwise
172
+ * reported — see `./limits`'s doc comment for why this is the actual
173
+ * enforcement point for "not swallowed by the script's own `pcall`".
174
+ * 7. Otherwise, a thrown error is classified by three non-spoofable
175
+ * out-of-band signals, in order, before ever falling back to
176
+ * `classifyRuntimeError`'s message-based path: the wall-clock guard's
177
+ * own `ScriptLimitError` sentinel (`instanceof`, Defect 3), then the
178
+ * raw `LuaReturn.ErrorMem` status code (Defect 2). A successful return
179
+ * goes through `finalizeMarshaledValue` for the final NaN/Infinity
180
+ * check and marker cleanup.
181
+ * 8. `finally`: hook removed, thread popped, engine closed — every path,
182
+ * including every early return above.
183
+ */
184
+ export async function runScript(options) {
185
+ const limits = { ...DEFAULT_LIMITS, ...options.limits };
186
+ const marshalLimits = {
187
+ ...DEFAULT_MARSHAL_LIMITS,
188
+ ...options.marshalLimits,
189
+ };
190
+ const engine = await createEmptyLuaEngine({ wasmUri: options.wasmUri });
191
+ engine.global.setMemoryMax(limits.maxMemoryBytes);
192
+ let thread;
193
+ let threadStackIndex;
194
+ let limitHandle;
195
+ let guardTimer;
196
+ try {
197
+ const { rawGlobals, preludeLua } = buildCapabilities({
198
+ tier: options.tier,
199
+ net: options.net,
200
+ netGrants: options.netGrants,
201
+ cache: options.cache,
202
+ bundle: options.bundle,
203
+ maxFetchBytes: options.maxFetchBytes,
204
+ });
205
+ for (const [name, fn] of Object.entries(rawGlobals)) {
206
+ engine.global.set(name, fn);
207
+ }
208
+ if (preludeLua.trim().length > 0) {
209
+ await engine.doString(preludeLua);
210
+ }
211
+ await engine.doString(buildMarshalPrelude(marshalLimits));
212
+ thread = engine.global.newThread();
213
+ threadStackIndex = engine.global.getTop();
214
+ limitHandle = installLimits(thread, limits);
215
+ try {
216
+ thread.loadString(wrapUserCode(options.code));
217
+ }
218
+ catch (err) {
219
+ return {
220
+ ok: false,
221
+ error: { kind: 'runtime', message: describeError(err) },
222
+ };
223
+ }
224
+ // Identity-based sentinel (Defect 3): this rejects with a
225
+ // `ScriptLimitError` — a class defined and thrown entirely within this
226
+ // module, never round-tripped through Lua — so the `instanceof` check
227
+ // below cannot be spoofed by a script's own `error("...")` call, even
228
+ // one using this exact message text (see the test asserting that
229
+ // distinction). Message-string matching would be spoofable; `instanceof`
230
+ // is not, because unlike the `CAPABILITY_ERROR_TAG`/`MARSHAL_ERROR_TAG`
231
+ // cases in `classifyRuntimeError` (which DO cross the Lua boundary and
232
+ // so lose subclass identity — see `./errors`'s doc comment), this
233
+ // guard's reject() and its catch below are the same JS scope: the error
234
+ // never enters the Lua VM at all.
235
+ const guard = new Promise((_resolve, reject) => {
236
+ guardTimer = setTimeout(() => {
237
+ reject(new ScriptLimitError('timeout', 'wall-clock timeout exceeded (external async guard: a host capability call never resolved)'));
238
+ }, limits.wallClockMs + WALL_CLOCK_GUARD_SLACK_MS);
239
+ guardTimer.unref?.();
240
+ });
241
+ // Defect 2: capture the raw LuaReturn status code for this run so a
242
+ // genuine memory-cap breach can be told apart, non-spoofably, from an
243
+ // ordinary Lua runtime error whose message happens to say "not enough
244
+ // memory" — see `captureAssertOkStatus`'s doc comment.
245
+ const statusCapture = captureAssertOkStatus(thread);
246
+ let runResult;
247
+ try {
248
+ const values = await Promise.race([thread.run(0), guard]);
249
+ runResult = {
250
+ kind: 'ok',
251
+ value: values.length > 0 ? values[0] : undefined,
252
+ };
253
+ }
254
+ catch (err) {
255
+ runResult = { kind: 'error', err };
256
+ }
257
+ finally {
258
+ if (guardTimer)
259
+ clearTimeout(guardTimer);
260
+ statusCapture.restore();
261
+ }
262
+ // Authoritative check: a resource-limit breach always wins, regardless
263
+ // of whether Lua-level execution otherwise appears to have "succeeded"
264
+ // (a script's own `pcall` can catch and survive the in-VM interrupt
265
+ // Lua-side; it can never see or clear this JS-side flag). See
266
+ // `./limits` for the full reasoning and the empirical evidence.
267
+ if (limitHandle.isBreached()) {
268
+ const kind = limitHandle.breachKind();
269
+ return {
270
+ ok: false,
271
+ error: {
272
+ kind: 'limit',
273
+ limit: kind,
274
+ message: `script exceeded its ${kind ?? 'resource'} limit`,
275
+ },
276
+ };
277
+ }
278
+ if (runResult.kind === 'error') {
279
+ // Defect 3: the external wall-clock guard's own sentinel error,
280
+ // identified by class identity (never by message) — see the guard's
281
+ // construction above.
282
+ if (runResult.err instanceof ScriptLimitError) {
283
+ return {
284
+ ok: false,
285
+ error: {
286
+ kind: 'limit',
287
+ limit: runResult.err.limitKind,
288
+ message: `script exceeded its ${runResult.err.limitKind} limit`,
289
+ },
290
+ };
291
+ }
292
+ // Defect 2: a genuine, uncaught memory-cap breach, identified by the
293
+ // non-spoofable raw LuaReturn status code — see
294
+ // `captureAssertOkStatus`'s doc comment.
295
+ if (statusCapture.lastStatus() === LuaReturn.ErrorMem) {
296
+ return {
297
+ ok: false,
298
+ error: {
299
+ kind: 'limit',
300
+ limit: 'memory',
301
+ message: 'script exceeded its memory limit',
302
+ },
303
+ };
304
+ }
305
+ return { ok: false, error: classifyRuntimeError(runResult.err) };
306
+ }
307
+ const finalized = finalizeMarshaledValue(runResult.value);
308
+ if (!finalized.ok) {
309
+ return {
310
+ ok: false,
311
+ error: {
312
+ kind: 'marshal',
313
+ reason: finalized.reason,
314
+ message: finalized.message,
315
+ },
316
+ };
317
+ }
318
+ return { ok: true, value: finalized.value };
319
+ }
320
+ finally {
321
+ limitHandle?.dispose();
322
+ if (thread !== undefined && threadStackIndex !== undefined) {
323
+ try {
324
+ if (!engine.global.isClosed()) {
325
+ engine.global.remove(threadStackIndex);
326
+ }
327
+ }
328
+ catch {
329
+ // Best-effort cleanup only; the engine is closed unconditionally next.
330
+ }
331
+ }
332
+ if (!engine.global.isClosed()) {
333
+ engine.global.close();
334
+ }
335
+ }
336
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@markii/lua",
3
+ "version": "0.1.0",
4
+ "description": "Sandboxed Lua 5.4 (wasmoon) execution runtime for Mark's document scripting: an empty-env global whitelist, two-tier capability-gated net/cache/bundle access, instruction-count/wall-clock/memory limits, and depth/size-capped Lua<->JS marshaling.",
5
+ "keywords": [
6
+ "markdown",
7
+ "mark",
8
+ "mk.md",
9
+ "lua",
10
+ "wasmoon",
11
+ "sandbox",
12
+ "scripting"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "sadigaxund",
16
+ "homepage": "https://github.com/sadigaxund/markii#readme",
17
+ "bugs": "https://github.com/sadigaxund/markii/issues",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/sadigaxund/markii.git",
21
+ "directory": "packages/markii-lua"
22
+ },
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "module": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "test": "vitest run",
42
+ "build": "tsc --noEmit -p tsconfig.json",
43
+ "build:dist": "rm -rf dist && tsc -p tsconfig.build.json",
44
+ "lint": "eslint ."
45
+ },
46
+ "dependencies": {
47
+ "@markii/bundle": "0.1.0",
48
+ "@markii/runtime": "0.1.0",
49
+ "wasmoon": "^1.16.0"
50
+ }
51
+ }