@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,126 @@
1
+ import type { ScriptMarshalReason } from './errors.js';
2
+ /** Marshal limits: caps on the shape of a script's return value. */
3
+ export interface MarshalLimits {
4
+ /** Max table nesting depth. Default 32. */
5
+ maxDepth: number;
6
+ /** Max total scalar+table nodes visited. Default 20,000. */
7
+ maxNodes: number;
8
+ }
9
+ export declare const DEFAULT_MARSHAL_LIMITS: MarshalLimits;
10
+ /**
11
+ * Why marshaling is done IN LUA, before the value ever reaches wasmoon's
12
+ * own JS conversion:
13
+ *
14
+ * wasmoon's table type extension (`getValue` for `LuaType.Table`) eagerly
15
+ * and unconditionally deep-converts an ENTIRE Lua table into a JS
16
+ * object/array the moment `thread.run()` resolves — there is no hook to
17
+ * intercept or cap that conversion from the JS side, and no way to abort
18
+ * it partway through. Verified empirically: a returned Lua table with
19
+ * 1,000,000 sequential integer keys took ~12 seconds to convert on
20
+ * ordinary hardware, fully materializing a million-element JS array before
21
+ * our own code ever got a chance to look at it or reject it. A node-count
22
+ * cap applied AFTER that conversion would correctly reject the value, but
23
+ * only after already paying the full CPU/memory cost of building it — that
24
+ * fails the "reject quickly, not hang" requirement.
25
+ *
26
+ * So instead: the returned value is walked and re-shaped by a small
27
+ * trusted LUA function (defined by `MARSHAL_PRELUDE` below, injected once
28
+ * per engine — see `sandbox.ts`) that runs INSIDE the sandboxed VM, under
29
+ * the exact same instruction-count hook (`./limits`) as the rest of the
30
+ * script. It counts nodes and depth as it goes and raises a Lua error the
31
+ * INSTANT either cap is exceeded — for a 1,000,000-entry table with the
32
+ * default 20,000-node cap, that's ~20,000 `pairs` steps, not 1,000,000,
33
+ * and it never reaches wasmoon's JS conversion step at all. Only the
34
+ * already-capped, already-shaped result gets handed to wasmoon's
35
+ * `getValue`, so that conversion is now cheap by construction.
36
+ *
37
+ * This also gives us cycle detection for free: Lua tables can be used as
38
+ * table KEYS (by identity), so a `seen[t] = true` map, cleared on the way
39
+ * back out of each table (so the same table appearing twice in DIFFERENT
40
+ * branches — not a cycle — is still allowed), detects a true cycle
41
+ * (`t.self = t`) in native Lua before wasmoon's converter would otherwise
42
+ * have to (wasmoon's converter also handles cycles correctly by reusing a
43
+ * seen JS object per Lua table pointer — verified empirically it does NOT
44
+ * hang or stack-overflow on `t.self = t` — but a cycle is still not
45
+ * JSON-serializable, so we reject it explicitly rather than silently
46
+ * handing the host a self-referential object).
47
+ *
48
+ * Array vs. object: a table is treated as an array iff its keys are
49
+ * exactly `1..n` for some `n` (checked by counting entries during the
50
+ * `pairs` walk and comparing against `#value`) — same "sequential integer
51
+ * keys" rule wasmoon's own converter uses for the DISPLAY shape, applied
52
+ * here so the pre-shaped table wasmoon receives is unambiguous. Any other
53
+ * key type is rejected (`'key-type'`) — JSON object keys are strings only,
54
+ * and allowing e.g. a table keyed by `true`/`false` would silently produce
55
+ * something JSON can't represent. Two edge cases fall out of this and are
56
+ * both intentionally rejected as `'key-type'` rather than guessed at: a
57
+ * table mixing integer and string keys (e.g. `{1, 2, x = "y"}` — not
58
+ * cleanly a JSON array OR object), and a sparse array with holes (e.g.
59
+ * `{[1]=1, [3]=3}`, where Lua's `#` length operator is itself undefined at
60
+ * the border) — both have no faithful JSON shape, so we refuse to guess
61
+ * rather than silently drop or reorder data. An empty table `{}` has no
62
+ * keys to disambiguate and is treated as an empty array (`[]`), matching
63
+ * the "array unless proven otherwise" default the walk falls back to.
64
+ *
65
+ * Numbers: Lua 5.4's `number` type covers both what would be a JSON number
66
+ * AND non-finite IEEE-754 doubles (`0/0`, `1/0`, `-1/0`), which Lua
67
+ * computes and returns natively with no distinct type tag. The Lua-side
68
+ * walk lets any number through unchanged (rejecting here would need a
69
+ * `nan`/`huge` check reimplemented in Lua, which is redundant since we
70
+ * already do it in JS); `finalizeMarshaledValue` below does the actual
71
+ * NaN/Infinity check, in JS, on the final (already depth/node-capped)
72
+ * value.
73
+ *
74
+ * Functions, userdata, and threads are rejected outright (`'type'`) — none
75
+ * of the exposed stdlib or capability surface should ever hand a script a
76
+ * live function/userdata/thread to return in the first place, but this is
77
+ * the backstop in case one leaks through.
78
+ */
79
+ export declare function buildMarshalPrelude(limits: MarshalLimits): string;
80
+ /**
81
+ * Wraps the user's script source so its return value is piped through
82
+ * `__smd_marshal_root` before ever leaving the Lua VM. `__smd_marshal_root`
83
+ * must already be defined as a global in this engine (via
84
+ * `buildMarshalPrelude`, run once per engine in `sandbox.ts`) — it isn't
85
+ * redefined per-call, only referenced.
86
+ *
87
+ * The user code runs inside its own function scope (`__smd_user_chunk`) so
88
+ * a bare top-level `return` in the script behaves exactly as it would if
89
+ * run standalone. `__smd_marshal_root` is a genuine global (see the "NOT
90
+ * local" note on its definition above — it has to be, to survive being
91
+ * defined in a separate chunk from the one that calls it), so it is also
92
+ * directly visible to the untrusted script itself, not just to this
93
+ * wrapper — this is an accepted, deliberate trade-off: a script COULD call
94
+ * `__smd_marshal_root(x)` itself out of curiosity, but that function is
95
+ * pure (no side effects, no capability access, no ambient authority) and
96
+ * its worst case is raising one of the tagged errors above, so exposing it
97
+ * this way is harmless. Avoiding it entirely would require a much heavier
98
+ * mechanism (a custom `_ENV` per chunk) that this phase doesn't need.
99
+ */
100
+ export declare function wrapUserCode(code: string): string;
101
+ /**
102
+ * Final JS-side pass over the value wasmoon already converted from the
103
+ * Lua-side-capped table: strips the `__smd_is_array` marker (converting
104
+ * the marked object into a real JS array, since wasmoon's own array/object
105
+ * detection — "keys are exactly 1..n" — doesn't apply once we've added a
106
+ * non-numeric marker key to what should read as an array) and rejects
107
+ * non-finite numbers (`NaN`, `Infinity`, `-Infinity`).
108
+ *
109
+ * Judgment call on NaN/Infinity: REJECTED, not silently coerced to
110
+ * `null`. JSON has no representation for them, and a script that computed
111
+ * one is far more likely to have hit `0/0` or a runaway `1/(x-x)` by
112
+ * mistake than to have intended it as a value a consuming component should
113
+ * render — silently turning that into `null` would hide the bug. A caller
114
+ * that wants "stale/empty" semantics for that case already gets it for
115
+ * free: a `'marshal'` failure is a normal missing-value case per spec §8
116
+ * ("If the value is missing or the script hasn't run, the component
117
+ * renders its empty/stale state").
118
+ */
119
+ export declare function finalizeMarshaledValue(value: unknown): {
120
+ ok: true;
121
+ value: unknown;
122
+ } | {
123
+ ok: false;
124
+ reason: ScriptMarshalReason;
125
+ message: string;
126
+ };
@@ -0,0 +1,260 @@
1
+ import { MARSHAL_ERROR_TAG } from './errors.js';
2
+ export const DEFAULT_MARSHAL_LIMITS = {
3
+ maxDepth: 32,
4
+ maxNodes: 20_000,
5
+ };
6
+ /**
7
+ * Why marshaling is done IN LUA, before the value ever reaches wasmoon's
8
+ * own JS conversion:
9
+ *
10
+ * wasmoon's table type extension (`getValue` for `LuaType.Table`) eagerly
11
+ * and unconditionally deep-converts an ENTIRE Lua table into a JS
12
+ * object/array the moment `thread.run()` resolves — there is no hook to
13
+ * intercept or cap that conversion from the JS side, and no way to abort
14
+ * it partway through. Verified empirically: a returned Lua table with
15
+ * 1,000,000 sequential integer keys took ~12 seconds to convert on
16
+ * ordinary hardware, fully materializing a million-element JS array before
17
+ * our own code ever got a chance to look at it or reject it. A node-count
18
+ * cap applied AFTER that conversion would correctly reject the value, but
19
+ * only after already paying the full CPU/memory cost of building it — that
20
+ * fails the "reject quickly, not hang" requirement.
21
+ *
22
+ * So instead: the returned value is walked and re-shaped by a small
23
+ * trusted LUA function (defined by `MARSHAL_PRELUDE` below, injected once
24
+ * per engine — see `sandbox.ts`) that runs INSIDE the sandboxed VM, under
25
+ * the exact same instruction-count hook (`./limits`) as the rest of the
26
+ * script. It counts nodes and depth as it goes and raises a Lua error the
27
+ * INSTANT either cap is exceeded — for a 1,000,000-entry table with the
28
+ * default 20,000-node cap, that's ~20,000 `pairs` steps, not 1,000,000,
29
+ * and it never reaches wasmoon's JS conversion step at all. Only the
30
+ * already-capped, already-shaped result gets handed to wasmoon's
31
+ * `getValue`, so that conversion is now cheap by construction.
32
+ *
33
+ * This also gives us cycle detection for free: Lua tables can be used as
34
+ * table KEYS (by identity), so a `seen[t] = true` map, cleared on the way
35
+ * back out of each table (so the same table appearing twice in DIFFERENT
36
+ * branches — not a cycle — is still allowed), detects a true cycle
37
+ * (`t.self = t`) in native Lua before wasmoon's converter would otherwise
38
+ * have to (wasmoon's converter also handles cycles correctly by reusing a
39
+ * seen JS object per Lua table pointer — verified empirically it does NOT
40
+ * hang or stack-overflow on `t.self = t` — but a cycle is still not
41
+ * JSON-serializable, so we reject it explicitly rather than silently
42
+ * handing the host a self-referential object).
43
+ *
44
+ * Array vs. object: a table is treated as an array iff its keys are
45
+ * exactly `1..n` for some `n` (checked by counting entries during the
46
+ * `pairs` walk and comparing against `#value`) — same "sequential integer
47
+ * keys" rule wasmoon's own converter uses for the DISPLAY shape, applied
48
+ * here so the pre-shaped table wasmoon receives is unambiguous. Any other
49
+ * key type is rejected (`'key-type'`) — JSON object keys are strings only,
50
+ * and allowing e.g. a table keyed by `true`/`false` would silently produce
51
+ * something JSON can't represent. Two edge cases fall out of this and are
52
+ * both intentionally rejected as `'key-type'` rather than guessed at: a
53
+ * table mixing integer and string keys (e.g. `{1, 2, x = "y"}` — not
54
+ * cleanly a JSON array OR object), and a sparse array with holes (e.g.
55
+ * `{[1]=1, [3]=3}`, where Lua's `#` length operator is itself undefined at
56
+ * the border) — both have no faithful JSON shape, so we refuse to guess
57
+ * rather than silently drop or reorder data. An empty table `{}` has no
58
+ * keys to disambiguate and is treated as an empty array (`[]`), matching
59
+ * the "array unless proven otherwise" default the walk falls back to.
60
+ *
61
+ * Numbers: Lua 5.4's `number` type covers both what would be a JSON number
62
+ * AND non-finite IEEE-754 doubles (`0/0`, `1/0`, `-1/0`), which Lua
63
+ * computes and returns natively with no distinct type tag. The Lua-side
64
+ * walk lets any number through unchanged (rejecting here would need a
65
+ * `nan`/`huge` check reimplemented in Lua, which is redundant since we
66
+ * already do it in JS); `finalizeMarshaledValue` below does the actual
67
+ * NaN/Infinity check, in JS, on the final (already depth/node-capped)
68
+ * value.
69
+ *
70
+ * Functions, userdata, and threads are rejected outright (`'type'`) — none
71
+ * of the exposed stdlib or capability surface should ever hand a script a
72
+ * live function/userdata/thread to return in the first place, but this is
73
+ * the backstop in case one leaks through.
74
+ */
75
+ export function buildMarshalPrelude(limits) {
76
+ return `
77
+ local function __smd_marshal(value, seen, depth, budget)
78
+ local t = type(value)
79
+ if t == "nil" or t == "boolean" or t == "string" or t == "number" then
80
+ budget.n = budget.n + 1
81
+ if budget.n > budget.maxNodes then error("${MARSHAL_ERROR_TAG}:nodes") end
82
+ return value
83
+ elseif t == "table" then
84
+ if depth >= budget.maxDepth then error("${MARSHAL_ERROR_TAG}:depth") end
85
+ if seen[value] then error("${MARSHAL_ERROR_TAG}:cycle") end
86
+ seen[value] = true
87
+ budget.n = budget.n + 1
88
+ if budget.n > budget.maxNodes then error("${MARSHAL_ERROR_TAG}:nodes") end
89
+ local count = 0
90
+ local isArray = true
91
+ for k, _ in pairs(value) do
92
+ count = count + 1
93
+ if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
94
+ isArray = false
95
+ end
96
+ end
97
+ local out = {}
98
+ if isArray and count == #value then
99
+ for i = 1, count do
100
+ out[i] = __smd_marshal(value[i], seen, depth + 1, budget)
101
+ end
102
+ else
103
+ isArray = false
104
+ for k, v in pairs(value) do
105
+ if type(k) ~= "string" then
106
+ error("${MARSHAL_ERROR_TAG}:key-type")
107
+ end
108
+ out[k] = __smd_marshal(v, seen, depth + 1, budget)
109
+ end
110
+ end
111
+ seen[value] = nil
112
+ if isArray then
113
+ out.__smd_is_array = true
114
+ end
115
+ return out
116
+ else
117
+ error("${MARSHAL_ERROR_TAG}:type:" .. t)
118
+ end
119
+ end
120
+
121
+ -- Deliberately NOT "local": this prelude and the wrapped user code (see
122
+ -- \`wrapUserCode\` below) are compiled as SEPARATE chunks via separate
123
+ -- \`loadString\` calls, and a Lua \`local\` never survives past the chunk
124
+ -- that declared it. \`__smd_marshal_root\` must be a global so the
125
+ -- wrapper chunk can call it. \`__smd_marshal\` itself stays local -- it is
126
+ -- only ever called from within THIS chunk (from \`__smd_marshal_root\`,
127
+ -- which closes over it as an upvalue regardless of who calls
128
+ -- \`__smd_marshal_root\` later), so it never needs to be reachable by name
129
+ -- from anywhere else.
130
+ function __smd_marshal_root(value)
131
+ local budget = { n = 0, maxNodes = ${limits.maxNodes}, maxDepth = ${limits.maxDepth} }
132
+ return __smd_marshal(value, {}, 0, budget)
133
+ end
134
+ `;
135
+ }
136
+ /**
137
+ * Wraps the user's script source so its return value is piped through
138
+ * `__smd_marshal_root` before ever leaving the Lua VM. `__smd_marshal_root`
139
+ * must already be defined as a global in this engine (via
140
+ * `buildMarshalPrelude`, run once per engine in `sandbox.ts`) — it isn't
141
+ * redefined per-call, only referenced.
142
+ *
143
+ * The user code runs inside its own function scope (`__smd_user_chunk`) so
144
+ * a bare top-level `return` in the script behaves exactly as it would if
145
+ * run standalone. `__smd_marshal_root` is a genuine global (see the "NOT
146
+ * local" note on its definition above — it has to be, to survive being
147
+ * defined in a separate chunk from the one that calls it), so it is also
148
+ * directly visible to the untrusted script itself, not just to this
149
+ * wrapper — this is an accepted, deliberate trade-off: a script COULD call
150
+ * `__smd_marshal_root(x)` itself out of curiosity, but that function is
151
+ * pure (no side effects, no capability access, no ambient authority) and
152
+ * its worst case is raising one of the tagged errors above, so exposing it
153
+ * this way is harmless. Avoiding it entirely would require a much heavier
154
+ * mechanism (a custom `_ENV` per chunk) that this phase doesn't need.
155
+ */
156
+ export function wrapUserCode(code) {
157
+ return `local function __smd_user_chunk()\n${code}\nend\nreturn __smd_marshal_root(__smd_user_chunk())`;
158
+ }
159
+ /** True array marker set by the Lua-side marshal walk (see `buildMarshalPrelude`). */
160
+ const ARRAY_MARKER = '__smd_is_array';
161
+ /**
162
+ * Final JS-side pass over the value wasmoon already converted from the
163
+ * Lua-side-capped table: strips the `__smd_is_array` marker (converting
164
+ * the marked object into a real JS array, since wasmoon's own array/object
165
+ * detection — "keys are exactly 1..n" — doesn't apply once we've added a
166
+ * non-numeric marker key to what should read as an array) and rejects
167
+ * non-finite numbers (`NaN`, `Infinity`, `-Infinity`).
168
+ *
169
+ * Judgment call on NaN/Infinity: REJECTED, not silently coerced to
170
+ * `null`. JSON has no representation for them, and a script that computed
171
+ * one is far more likely to have hit `0/0` or a runaway `1/(x-x)` by
172
+ * mistake than to have intended it as a value a consuming component should
173
+ * render — silently turning that into `null` would hide the bug. A caller
174
+ * that wants "stale/empty" semantics for that case already gets it for
175
+ * free: a `'marshal'` failure is a normal missing-value case per spec §8
176
+ * ("If the value is missing or the script hasn't run, the component
177
+ * renders its empty/stale state").
178
+ */
179
+ export function finalizeMarshaledValue(value) {
180
+ const seen = new Set();
181
+ function walk(v) {
182
+ if (v === null || v === undefined)
183
+ return { ok: true, value: null };
184
+ const t = typeof v;
185
+ if (t === 'string' || t === 'boolean')
186
+ return { ok: true, value: v };
187
+ if (t === 'number') {
188
+ if (!Number.isFinite(v)) {
189
+ return {
190
+ ok: false,
191
+ reason: 'non-finite-number',
192
+ message: `script returned a non-finite number (${String(v)})`,
193
+ };
194
+ }
195
+ return { ok: true, value: v };
196
+ }
197
+ if (Array.isArray(v)) {
198
+ if (seen.has(v)) {
199
+ return {
200
+ ok: false,
201
+ reason: 'cycle',
202
+ message: 'script return value contains a cycle',
203
+ };
204
+ }
205
+ seen.add(v);
206
+ const out = [];
207
+ for (const item of v) {
208
+ const r = walk(item);
209
+ if (!r.ok)
210
+ return r;
211
+ out.push(r.value);
212
+ }
213
+ seen.delete(v);
214
+ return { ok: true, value: out };
215
+ }
216
+ if (t === 'object') {
217
+ const obj = v;
218
+ if (seen.has(obj)) {
219
+ return {
220
+ ok: false,
221
+ reason: 'cycle',
222
+ message: 'script return value contains a cycle',
223
+ };
224
+ }
225
+ seen.add(obj);
226
+ const isArray = obj[ARRAY_MARKER] === true;
227
+ if (isArray) {
228
+ const keys = Object.keys(obj)
229
+ .filter((k) => k !== ARRAY_MARKER)
230
+ .sort((a, b) => Number(a) - Number(b));
231
+ const out = [];
232
+ for (const k of keys) {
233
+ const r = walk(obj[k]);
234
+ if (!r.ok)
235
+ return r;
236
+ out.push(r.value);
237
+ }
238
+ seen.delete(obj);
239
+ return { ok: true, value: out };
240
+ }
241
+ const out = {};
242
+ for (const [k, val] of Object.entries(obj)) {
243
+ const r = walk(val);
244
+ if (!r.ok)
245
+ return r;
246
+ out[k] = r.value;
247
+ }
248
+ seen.delete(obj);
249
+ return { ok: true, value: out };
250
+ }
251
+ // functions/other host objects should never reach here — the Lua-side
252
+ // walk already rejects them before they're returned at all.
253
+ return {
254
+ ok: false,
255
+ reason: 'type',
256
+ message: `script return value contained an unsupported JS type: ${t}`,
257
+ };
258
+ }
259
+ return walk(value);
260
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sandboxed `require` (spec §8 "Script placement, long scripts, and
3
+ * modules"): the eventual design is exactly two sources — bundle-local
4
+ * modules (`require "scripts/util"`, same path-jail as `bundle.read`) and
5
+ * pack-namespaced pure-Lua modules (`require "ana/http"`) — pure Lua
6
+ * source only, no C, no bytecode, no network, module cache per run.
7
+ *
8
+ * DESIGN NOTE / explicit deferral: this phase (the sandbox primitive
9
+ * itself) does not wire that up yet. `bundle.read` (via the injected
10
+ * `ScriptView`, see `./capabilities`) already gives a script everything it
11
+ * needs to fetch a bundle-local module's SOURCE TEXT; what a real
12
+ * `require` adds on top is caching per module name and running the loaded
13
+ * source as a new protected chunk with the same globals/capabilities —
14
+ * both of those depend on the pack/namespace resolution rules (§8) that
15
+ * belong to a later phase (packs, §5/§12), not to this security primitive.
16
+ * Rather than build a real (but pack-less, cache-less) `require` now and
17
+ * having to change its resolution semantics later, `require` is left
18
+ * UNDEFINED — not stubbed as an always-erroring global — in this phase.
19
+ *
20
+ * Concretely: `sandbox.ts` never calls anything from this module, and the
21
+ * curated environment (`./globals`) never sets a `require` global at all.
22
+ * `NOT_YET_SUPPORTED_MESSAGE` and `buildRequireStub` are kept here,
23
+ * disconnected from the sandbox wiring, as the landing point for that
24
+ * later phase — a future change only needs to call `buildRequireStub()`
25
+ * (or replace it with the real resolver) from `sandbox.ts`'s prelude
26
+ * assembly, next to `buildCapabilities`.
27
+ *
28
+ * Either way — absent entirely (current state) or stubbed to always error
29
+ * (the stub below, for a host that wants a friendlier error message than
30
+ * a bare "attempt to call a nil value") — the guarantee the adversarial
31
+ * suite checks holds: no raw Lua `require` is ever reachable, and no
32
+ * script can load arbitrary source or bytecode through it.
33
+ */
34
+ export declare const NOT_YET_SUPPORTED_MESSAGE = "modules not yet supported (require \"%s\")";
35
+ /**
36
+ * Not wired into `sandbox.ts` in this phase (see module doc comment
37
+ * above) — provided so a host that wants an explicit, friendlier error
38
+ * message instead of Lua's default "attempt to call a nil value (global
39
+ * 'require')" can opt in without this package needing to change shape
40
+ * later. Deliberately does not touch the filesystem, network, or `load` —
41
+ * it only ever raises.
42
+ */
43
+ export declare function buildRequireStub(): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Sandboxed `require` (spec §8 "Script placement, long scripts, and
3
+ * modules"): the eventual design is exactly two sources — bundle-local
4
+ * modules (`require "scripts/util"`, same path-jail as `bundle.read`) and
5
+ * pack-namespaced pure-Lua modules (`require "ana/http"`) — pure Lua
6
+ * source only, no C, no bytecode, no network, module cache per run.
7
+ *
8
+ * DESIGN NOTE / explicit deferral: this phase (the sandbox primitive
9
+ * itself) does not wire that up yet. `bundle.read` (via the injected
10
+ * `ScriptView`, see `./capabilities`) already gives a script everything it
11
+ * needs to fetch a bundle-local module's SOURCE TEXT; what a real
12
+ * `require` adds on top is caching per module name and running the loaded
13
+ * source as a new protected chunk with the same globals/capabilities —
14
+ * both of those depend on the pack/namespace resolution rules (§8) that
15
+ * belong to a later phase (packs, §5/§12), not to this security primitive.
16
+ * Rather than build a real (but pack-less, cache-less) `require` now and
17
+ * having to change its resolution semantics later, `require` is left
18
+ * UNDEFINED — not stubbed as an always-erroring global — in this phase.
19
+ *
20
+ * Concretely: `sandbox.ts` never calls anything from this module, and the
21
+ * curated environment (`./globals`) never sets a `require` global at all.
22
+ * `NOT_YET_SUPPORTED_MESSAGE` and `buildRequireStub` are kept here,
23
+ * disconnected from the sandbox wiring, as the landing point for that
24
+ * later phase — a future change only needs to call `buildRequireStub()`
25
+ * (or replace it with the real resolver) from `sandbox.ts`'s prelude
26
+ * assembly, next to `buildCapabilities`.
27
+ *
28
+ * Either way — absent entirely (current state) or stubbed to always error
29
+ * (the stub below, for a host that wants a friendlier error message than
30
+ * a bare "attempt to call a nil value") — the guarantee the adversarial
31
+ * suite checks holds: no raw Lua `require` is ever reachable, and no
32
+ * script can load arbitrary source or bytecode through it.
33
+ */
34
+ export const NOT_YET_SUPPORTED_MESSAGE = 'modules not yet supported (require "%s")';
35
+ /**
36
+ * Not wired into `sandbox.ts` in this phase (see module doc comment
37
+ * above) — provided so a host that wants an explicit, friendlier error
38
+ * message instead of Lua's default "attempt to call a nil value (global
39
+ * 'require')" can opt in without this package needing to change shape
40
+ * later. Deliberately does not touch the filesystem, network, or `load` —
41
+ * it only ever raises.
42
+ */
43
+ export function buildRequireStub() {
44
+ return `
45
+ require = function(name)
46
+ error(string.format(${JSON.stringify(NOT_YET_SUPPORTED_MESSAGE)}, tostring(name)))
47
+ end
48
+ `;
49
+ }
@@ -0,0 +1,71 @@
1
+ import type { ScriptView } from '@markii/bundle';
2
+ import { type CacheProvider, type CapabilityTier, type NetGrants, type NetProvider } from './capabilities.js';
3
+ import type { ScriptFailure } from './errors.js';
4
+ import { type ScriptLimits } from './limits.js';
5
+ import { type MarshalLimits } from './marshal.js';
6
+ export interface RunScriptOptions {
7
+ code: string;
8
+ /** Spec §8's trigger tier: 'manual' unlocks effectful ops, 'auto' is read-only regardless of what's granted. */
9
+ tier: CapabilityTier;
10
+ net?: NetProvider;
11
+ netGrants?: NetGrants;
12
+ cache?: CacheProvider;
13
+ /** Bundle-scoped filesystem (spec §11), already capability-restricted — see `@markii/bundle`'s `createScriptView`. */
14
+ bundle?: ScriptView;
15
+ maxFetchBytes?: number;
16
+ limits?: Partial<ScriptLimits>;
17
+ marshalLimits?: Partial<MarshalLimits>;
18
+ /**
19
+ * Forwarded to `./globals`' `createEmptyLuaEngine` as its
20
+ * `wasmUri` option (wasmoon's `customWasmUri`). Left `undefined` (the
21
+ * default), engine creation is byte-for-byte the same as before this
22
+ * option existed: local `node_modules` resolution in Node, the unpkg CDN
23
+ * default in an unconfigured browser bundle. A host that bundles its own
24
+ * copy of wasmoon's `glue.wasm` (e.g. the playground, to avoid a runtime
25
+ * dependency on the unpkg CDN) passes that local URL here instead — see
26
+ * `createEmptyLuaEngine`'s doc comment for the full rationale.
27
+ */
28
+ wasmUri?: string;
29
+ }
30
+ export type RunScriptResult = {
31
+ ok: true;
32
+ value: unknown;
33
+ } | {
34
+ ok: false;
35
+ error: ScriptFailure;
36
+ };
37
+ /**
38
+ * Runs one Lua script in a fresh, fully isolated sandbox and always tears
39
+ * the engine down before returning — a run never leaves state (globals,
40
+ * memory, hooks) for a later run to inherit. Never throws: every way
41
+ * hostile code can fail comes back as `{ ok: false, error }`, never a raw
42
+ * exception (see `./errors`).
43
+ *
44
+ * Orchestration, in order:
45
+ * 1. `./globals` — fresh engine, curated empty environment (no `os`/`io`/
46
+ * `require`/etc; see that module for exactly what's kept and why).
47
+ * 2. Memory cap (`engine.global.setMemoryMax`, backed by the
48
+ * `traceAllocations: true` custom allocator `./globals` requests).
49
+ * 3. `./capabilities` — build the `net`/`cache`/`bundle` Lua tables from
50
+ * whatever providers/grants/tier this call was given.
51
+ * 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
52
+ * the wrapped user code's return value is piped through.
53
+ * 5. A dedicated child thread (NOT `engine.doString`, which creates its
54
+ * own internal thread we'd have no handle to — see `./limits`'s "hooks
55
+ * are per-thread" note) gets the instruction/wall-clock hook installed,
56
+ * then runs the wrapped user code.
57
+ * 6. The out-of-band breach flag from step 5's hook is checked
58
+ * UNCONDITIONALLY and, if set, wins over whatever the run otherwise
59
+ * reported — see `./limits`'s doc comment for why this is the actual
60
+ * enforcement point for "not swallowed by the script's own `pcall`".
61
+ * 7. Otherwise, a thrown error is classified by three non-spoofable
62
+ * out-of-band signals, in order, before ever falling back to
63
+ * `classifyRuntimeError`'s message-based path: the wall-clock guard's
64
+ * own `ScriptLimitError` sentinel (`instanceof`, Defect 3), then the
65
+ * raw `LuaReturn.ErrorMem` status code (Defect 2). A successful return
66
+ * goes through `finalizeMarshaledValue` for the final NaN/Infinity
67
+ * check and marker cleanup.
68
+ * 8. `finally`: hook removed, thread popped, engine closed — every path,
69
+ * including every early return above.
70
+ */
71
+ export declare function runScript(options: RunScriptOptions): Promise<RunScriptResult>;