@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sadigaxund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @markii/lua
2
+
3
+ A sandboxed Lua 5.4 (via `wasmoon`) execution runtime for [Mark](https://github.com/sadigaxund/markii)'s document scripting: an empty-environment global whitelist, capability-gated net/cache/bundle access, instruction-count and wall-clock/memory limits, and depth/size-capped Lua↔JS value marshaling. No React, no markdown parsing.
4
+
5
+ See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
@@ -0,0 +1,124 @@
1
+ import type { ScriptView } from '@markii/bundle';
2
+ /** A GET/POST/PATCH result handed back to Lua as `{status=..., body=...}`. */
3
+ export interface NetResponse {
4
+ status: number;
5
+ body: string;
6
+ }
7
+ /**
8
+ * Host-provided network primitive. The runtime never imports a global
9
+ * `fetch` or reaches the network on its own — this is injected by the
10
+ * host, which is where SSRF/allowlist policy actually lives (spec §10).
11
+ * `net.fetch_json`/`net.post`/`net.patch` below are a thin, capability- and
12
+ * size-checked Lua-facing wrapper around whatever `provider` does.
13
+ */
14
+ export interface NetProvider {
15
+ get(url: string): Promise<NetResponse>;
16
+ post?(url: string, body: string): Promise<NetResponse>;
17
+ patch?(url: string, body: string): Promise<NetResponse>;
18
+ }
19
+ /** One cached entry: the stored value plus when it was stored, for TTL comparison. */
20
+ export interface CacheEntry {
21
+ value: unknown;
22
+ storedAtMs: number;
23
+ }
24
+ /**
25
+ * Host-provided cache primitive backing `cache.get(key, ttl, fn)`. Real
26
+ * persistence (bundle `cache/`, IndexedDB, whatever the host uses) is the
27
+ * host's concern; this package only defines the read-if-fresh-else-run-fn
28
+ * contract.
29
+ */
30
+ export interface CacheProvider {
31
+ get(key: string): Promise<CacheEntry | undefined>;
32
+ set(key: string, entry: CacheEntry): Promise<void>;
33
+ }
34
+ /** Spec §8's two-tier gate. `'manual'` = explicit run/run-all click, all grants apply. `'auto'` = on-open or scheduled, read-only regardless of what was granted. */
35
+ export type CapabilityTier = 'manual' | 'auto';
36
+ /**
37
+ * Hostnames this run may reach, ALREADY intersected by the caller (manifest
38
+ * ∩ user grant — same pattern as `@markii/bundle`'s `createScriptView`, DEFECT
39
+ * 10). This module does not re-derive grants from a manifest; it trusts
40
+ * `get`/`post` as the final, effective allowlist for this one run.
41
+ */
42
+ export interface NetGrants {
43
+ get: readonly string[];
44
+ post: readonly string[];
45
+ }
46
+ export interface CapabilityConfig {
47
+ tier: CapabilityTier;
48
+ net?: NetProvider;
49
+ netGrants?: NetGrants;
50
+ cache?: CacheProvider;
51
+ /** Bundle-scoped filesystem view (spec §11) — already capability-restricted by `@markii/bundle`'s `createScriptView`; this module delegates to it, never re-implements the path-jail or write policy. */
52
+ bundle?: ScriptView;
53
+ maxFetchBytes?: number;
54
+ }
55
+ export declare const DEFAULT_MAX_FETCH_BYTES = 2000000;
56
+ /**
57
+ * `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
58
+ * per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
59
+ * arrays, but wasmoon's own JS<->Lua marshaling for strings does NOT
60
+ * preserve embedded NUL (0x00) bytes end-to-end — verified empirically
61
+ * (wasmoon 1.16.0): a JS string or Lua `string.char(...)` value containing
62
+ * a `\0` is truncated at the first NUL by the time it crosses the
63
+ * boundary, in BOTH directions (`global.set`, and a Lua value passed as an
64
+ * argument to a host function). This is a real, currently-unclosed gap for
65
+ * binary asset data containing NUL bytes (some binary formats do; JSON
66
+ * cache payloads and Lua source — the two documented `bundle.*` use cases
67
+ * per spec §9/§11 — do not). Documented here rather than silently
68
+ * "handled": `bundle.read`/`bundle.write` should be treated as reliable
69
+ * for text/JSON payloads and NOT YET reliable for arbitrary binary
70
+ * containing NUL bytes. See the adversarial test asserting this exact
71
+ * (current, imperfect) behavior so a future wasmoon upgrade that fixes it
72
+ * is a visible, reviewed diff rather than a silent behavior change.
73
+ */
74
+ export declare function bytesToLuaString(bytes: Uint8Array): string;
75
+ export declare function luaStringToBytes(s: string): Uint8Array;
76
+ /**
77
+ * Builds the raw, host-facing async functions to inject as flat globals,
78
+ * and the trusted Lua prelude that wraps them into the ergonomic `net` /
79
+ * `cache` / `bundle` tables the DESIGN.md host API documents (`net.fetch_json(url)`,
80
+ * `cache.get(key, ttl, fn)`, `bundle.read/write/exists(path)`).
81
+ *
82
+ * ## Why "raw flat globals + a Lua prelude" instead of `global.set('net', {...})`
83
+ *
84
+ * The natural-looking approach — `engine.global.set('net', { fetch_json:
85
+ * async (url) => ... })` — silently breaks async/await for any capability
86
+ * whose Lua-facing wrapper needs to be REPLACED with a Lua closure later
87
+ * (which `cache.get` and every `:await()`-wrapping function here need to
88
+ * be, since raw host functions return promises that must be explicitly
89
+ * awaited — see below). With wasmoon's default `enableProxy: true`, a
90
+ * plain JS object passed to `global.set` becomes a live PROXY table: Lua
91
+ * writes to it round-trip back through JS, and reading a Lua-defined
92
+ * function back OUT of that proxy re-wraps it as a synchronous JS-callable
93
+ * bridge (`lua_pcallk`), which cannot yield. Concretely: assigning
94
+ * `cache.get = function(...) ... end` onto a proxied table and then
95
+ * calling `cache.get(...)` from a script ends up invoking that Lua
96
+ * function through the SYNCHRONOUS bridge, and if it (or anything it
97
+ * calls) tries to `:await()` a promise, Lua raises "attempt to yield
98
+ * across a C-call boundary" — verified empirically. Using genuine,
99
+ * Lua-native tables (built with `{}` inside the prelude, never JS-backed)
100
+ * avoids this entirely: every read/write of `net`/`cache`/`bundle` after
101
+ * setup is a normal Lua table operation, no JS round trip involved.
102
+ *
103
+ * ## Why raw calls need `:await()` at all
104
+ *
105
+ * A JS async function called from Lua does NOT automatically suspend the
106
+ * calling coroutine — wasmoon marshals its Promise into Lua as a
107
+ * `js_promise` userdata with `:await()`/`:next()`/`:catch()` methods; the
108
+ * CALLER must explicitly invoke `:await()` to get the resolved value
109
+ * (verified empirically: without it, a script sees the raw promise
110
+ * userdata, not the awaited result). Since `DESIGN.md`'s example script
111
+ * (`local repo = net.fetch_json(url)`) is written as if this were
112
+ * synchronous, the awaiting is done for the author, once, HERE — inside
113
+ * the prelude's Lua wrapper — never exposed to the untrusted script.
114
+ *
115
+ * Each raw handle is captured into a `local` inside the prelude and the
116
+ * matching global is set to `nil` immediately after, so it is not
117
+ * reachable as a global by the untrusted script that runs afterward (only
118
+ * the ergonomic wrapper closures, which close over the local, remain
119
+ * callable).
120
+ */
121
+ export declare function buildCapabilities(config: CapabilityConfig): {
122
+ rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
123
+ preludeLua: string;
124
+ };
@@ -0,0 +1,281 @@
1
+ import { CAPABILITY_ERROR_TAG } from './errors.js';
2
+ export const DEFAULT_MAX_FETCH_BYTES = 2_000_000;
3
+ function capabilityError(message) {
4
+ return new Error(`${CAPABILITY_ERROR_TAG}: ${message}`);
5
+ }
6
+ function describeThrown(err) {
7
+ return err instanceof Error ? err.message : String(err);
8
+ }
9
+ /** Bare hostname from a URL string, or `undefined` if the URL doesn't parse. */
10
+ function hostnameOf(url) {
11
+ try {
12
+ return new URL(url).hostname.toLowerCase();
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ /**
19
+ * `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
20
+ * per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
21
+ * arrays, but wasmoon's own JS<->Lua marshaling for strings does NOT
22
+ * preserve embedded NUL (0x00) bytes end-to-end — verified empirically
23
+ * (wasmoon 1.16.0): a JS string or Lua `string.char(...)` value containing
24
+ * a `\0` is truncated at the first NUL by the time it crosses the
25
+ * boundary, in BOTH directions (`global.set`, and a Lua value passed as an
26
+ * argument to a host function). This is a real, currently-unclosed gap for
27
+ * binary asset data containing NUL bytes (some binary formats do; JSON
28
+ * cache payloads and Lua source — the two documented `bundle.*` use cases
29
+ * per spec §9/§11 — do not). Documented here rather than silently
30
+ * "handled": `bundle.read`/`bundle.write` should be treated as reliable
31
+ * for text/JSON payloads and NOT YET reliable for arbitrary binary
32
+ * containing NUL bytes. See the adversarial test asserting this exact
33
+ * (current, imperfect) behavior so a future wasmoon upgrade that fixes it
34
+ * is a visible, reviewed diff rather than a silent behavior change.
35
+ */
36
+ export function bytesToLuaString(bytes) {
37
+ let out = '';
38
+ for (let i = 0; i < bytes.length; i++) {
39
+ out += String.fromCharCode(bytes[i] ?? 0);
40
+ }
41
+ return out;
42
+ }
43
+ export function luaStringToBytes(s) {
44
+ const out = new Uint8Array(s.length);
45
+ for (let i = 0; i < s.length; i++) {
46
+ out[i] = s.charCodeAt(i) & 0xff;
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Builds the raw, host-facing async functions to inject as flat globals,
52
+ * and the trusted Lua prelude that wraps them into the ergonomic `net` /
53
+ * `cache` / `bundle` tables the DESIGN.md host API documents (`net.fetch_json(url)`,
54
+ * `cache.get(key, ttl, fn)`, `bundle.read/write/exists(path)`).
55
+ *
56
+ * ## Why "raw flat globals + a Lua prelude" instead of `global.set('net', {...})`
57
+ *
58
+ * The natural-looking approach — `engine.global.set('net', { fetch_json:
59
+ * async (url) => ... })` — silently breaks async/await for any capability
60
+ * whose Lua-facing wrapper needs to be REPLACED with a Lua closure later
61
+ * (which `cache.get` and every `:await()`-wrapping function here need to
62
+ * be, since raw host functions return promises that must be explicitly
63
+ * awaited — see below). With wasmoon's default `enableProxy: true`, a
64
+ * plain JS object passed to `global.set` becomes a live PROXY table: Lua
65
+ * writes to it round-trip back through JS, and reading a Lua-defined
66
+ * function back OUT of that proxy re-wraps it as a synchronous JS-callable
67
+ * bridge (`lua_pcallk`), which cannot yield. Concretely: assigning
68
+ * `cache.get = function(...) ... end` onto a proxied table and then
69
+ * calling `cache.get(...)` from a script ends up invoking that Lua
70
+ * function through the SYNCHRONOUS bridge, and if it (or anything it
71
+ * calls) tries to `:await()` a promise, Lua raises "attempt to yield
72
+ * across a C-call boundary" — verified empirically. Using genuine,
73
+ * Lua-native tables (built with `{}` inside the prelude, never JS-backed)
74
+ * avoids this entirely: every read/write of `net`/`cache`/`bundle` after
75
+ * setup is a normal Lua table operation, no JS round trip involved.
76
+ *
77
+ * ## Why raw calls need `:await()` at all
78
+ *
79
+ * A JS async function called from Lua does NOT automatically suspend the
80
+ * calling coroutine — wasmoon marshals its Promise into Lua as a
81
+ * `js_promise` userdata with `:await()`/`:next()`/`:catch()` methods; the
82
+ * CALLER must explicitly invoke `:await()` to get the resolved value
83
+ * (verified empirically: without it, a script sees the raw promise
84
+ * userdata, not the awaited result). Since `DESIGN.md`'s example script
85
+ * (`local repo = net.fetch_json(url)`) is written as if this were
86
+ * synchronous, the awaiting is done for the author, once, HERE — inside
87
+ * the prelude's Lua wrapper — never exposed to the untrusted script.
88
+ *
89
+ * Each raw handle is captured into a `local` inside the prelude and the
90
+ * matching global is set to `nil` immediately after, so it is not
91
+ * reachable as a global by the untrusted script that runs afterward (only
92
+ * the ergonomic wrapper closures, which close over the local, remain
93
+ * callable).
94
+ */
95
+ export function buildCapabilities(config) {
96
+ const maxFetchBytes = config.maxFetchBytes ?? DEFAULT_MAX_FETCH_BYTES;
97
+ const rawGlobals = {};
98
+ const preludeParts = [];
99
+ // --- net --------------------------------------------------------------
100
+ // `fetch_json` and `post`/`patch` are gated INDEPENDENTLY of each other
101
+ // (a manifest can grant POST to a host without granting it GET, or vice
102
+ // versa), so the `net` table and each method are wired up separately
103
+ // rather than behind one combined condition — an earlier version of
104
+ // this function nested POST/PATCH wiring inside "if GET is granted",
105
+ // which silently produced no `net.post` at all for a POST-only grant.
106
+ const netGrants = config.netGrants ?? { get: [], post: [] };
107
+ const netTableNeeded = config.net !== undefined &&
108
+ (netGrants.get.length > 0 ||
109
+ (config.tier === 'manual' && netGrants.post.length > 0));
110
+ if (netTableNeeded) {
111
+ preludeParts.push('net = net or {}\n');
112
+ }
113
+ if (config.net && netGrants.get.length > 0) {
114
+ rawGlobals.__smd_net_get_raw = (async (url) => {
115
+ const host = hostnameOf(url);
116
+ if (!host || !netGrants.get.includes(host)) {
117
+ throw capabilityError(`net access to host "${host ?? url}" not granted for GET`);
118
+ }
119
+ const res = await config.net.get(url);
120
+ if (res.body.length > maxFetchBytes) {
121
+ throw capabilityError(`fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`);
122
+ }
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(res.body);
126
+ }
127
+ catch {
128
+ throw capabilityError(`fetch response for "${url}" was not valid JSON`);
129
+ }
130
+ return parsed;
131
+ });
132
+ preludeParts.push(`
133
+ local __smd_net_get = __smd_net_get_raw
134
+ __smd_net_get_raw = nil
135
+ net.fetch_json = function(url) return __smd_net_get(url):await() end
136
+ `);
137
+ }
138
+ // POST/PATCH are effectful — only wired at all under the 'manual' tier,
139
+ // and only for hosts the effective grant set allows for POST. Under
140
+ // 'auto' these are simply never defined: calling `net.post` fails as
141
+ // "attempt to call a nil value", a clean typed failure (spec §8: "An
142
+ // effectful call under an auto trigger fails cleanly").
143
+ if (config.tier === 'manual' &&
144
+ config.net?.post &&
145
+ netGrants.post.length > 0) {
146
+ rawGlobals.__smd_net_post_raw = (async (url, body) => {
147
+ const host = hostnameOf(url);
148
+ if (!host || !netGrants.post.includes(host)) {
149
+ throw capabilityError(`net access to host "${host ?? url}" not granted for POST`);
150
+ }
151
+ return config.net.post(url, body);
152
+ });
153
+ preludeParts.push(`
154
+ local __smd_net_post = __smd_net_post_raw
155
+ __smd_net_post_raw = nil
156
+ net.post = function(url, body) return __smd_net_post(url, body):await() end
157
+ `);
158
+ }
159
+ if (config.tier === 'manual' &&
160
+ config.net?.patch &&
161
+ netGrants.post.length > 0) {
162
+ rawGlobals.__smd_net_patch_raw = (async (url, body) => {
163
+ const host = hostnameOf(url);
164
+ if (!host || !netGrants.post.includes(host)) {
165
+ throw capabilityError(`net access to host "${host ?? url}" not granted for PATCH`);
166
+ }
167
+ return config.net.patch(url, body);
168
+ });
169
+ preludeParts.push(`
170
+ local __smd_net_patch = __smd_net_patch_raw
171
+ __smd_net_patch_raw = nil
172
+ net.patch = function(url, body) return __smd_net_patch(url, body):await() end
173
+ `);
174
+ }
175
+ // --- cache --------------------------------------------------------------
176
+ // cache.get is implemented ENTIRELY IN LUA (see the prelude below),
177
+ // calling the script-provided `fn` as a normal Lua-to-Lua call. This is
178
+ // deliberate, not just tidy: `fn` may itself call `net.fetch_json`
179
+ // (which needs to `:await()`), and a Lua function invoked FROM JS
180
+ // (rather than from Lua) goes through the same non-yieldable
181
+ // `lua_pcallk` bridge described above — so `cache.get`'s JS side only
182
+ // ever exposes plain read/write primitives (`__smd_cache_get_raw`,
183
+ // `__smd_cache_set_raw`); the read-if-fresh-else-run-fn CONTROL FLOW is
184
+ // Lua calling Lua, never JS calling Lua.
185
+ if (config.cache) {
186
+ rawGlobals.__smd_cache_get_raw = (async (key) => config.cache.get(key));
187
+ rawGlobals.__smd_cache_set_raw = (async (key, value, storedAtMs) => {
188
+ await config.cache.set(key, { value, storedAtMs });
189
+ return true;
190
+ });
191
+ // `now` (for TTL freshness) is computed in JS, once per cache.get
192
+ // call, and handed to Lua as a plain number argument — there is no
193
+ // `os.time()` in this sandbox (§10: no `os` library at all), so the
194
+ // clock is a host-provided value, not a Lua-reachable ambient
195
+ // capability.
196
+ rawGlobals.__smd_now_ms_raw = (async () => Date.now());
197
+ preludeParts.push(`
198
+ local __smd_cache_get = __smd_cache_get_raw
199
+ local __smd_cache_set = __smd_cache_set_raw
200
+ local __smd_now_ms = __smd_now_ms_raw
201
+ __smd_cache_get_raw = nil
202
+ __smd_cache_set_raw = nil
203
+ __smd_now_ms_raw = nil
204
+ cache = cache or {}
205
+ cache.get = function(key, ttl, fn)
206
+ local existing = __smd_cache_get(key):await()
207
+ if existing ~= nil then
208
+ local now = __smd_now_ms():await()
209
+ if (now - existing.storedAtMs) < (ttl * 1000) then
210
+ return existing.value
211
+ end
212
+ end
213
+ local value = fn()
214
+ __smd_cache_set(key, value, __smd_now_ms():await()):await()
215
+ return value
216
+ end
217
+ `);
218
+ }
219
+ // --- bundle -------------------------------------------------------------
220
+ // Delegates entirely to the injected `ScriptView` (`@markii/bundle`), which
221
+ // already enforces the path-jail and the read/write:cache/ split (spec
222
+ // §11). This module adds nothing on top except the tier gate for
223
+ // `bundle.write` (absent entirely under 'auto' — read-only tier) and the
224
+ // byte<->Lua-string conversion.
225
+ if (config.bundle) {
226
+ const view = config.bundle;
227
+ // `ScriptView` (@markii/bundle) throws its own `ScriptCapabilityError` /
228
+ // `BundlePathError` for a denied or path-jail-violating call — those
229
+ // are re-tagged here with `CAPABILITY_ERROR_TAG` so `sandbox.ts`'s
230
+ // message-based classification (see `./errors`'s doc comment on why
231
+ // that's necessary) reports them as `kind: 'capability'` uniformly,
232
+ // the same as a net host-allowlist denial, rather than falling
233
+ // through to the generic `'runtime'` bucket.
234
+ rawGlobals.__smd_bundle_read_raw = (async (path) => {
235
+ let data;
236
+ try {
237
+ data = await view.read(path);
238
+ }
239
+ catch (err) {
240
+ throw capabilityError(describeThrown(err));
241
+ }
242
+ return data === undefined ? null : bytesToLuaString(data);
243
+ });
244
+ rawGlobals.__smd_bundle_exists_raw = (async (path) => {
245
+ try {
246
+ return await view.exists(path);
247
+ }
248
+ catch (err) {
249
+ throw capabilityError(describeThrown(err));
250
+ }
251
+ });
252
+ preludeParts.push(`
253
+ local __smd_bundle_read = __smd_bundle_read_raw
254
+ local __smd_bundle_exists = __smd_bundle_exists_raw
255
+ __smd_bundle_read_raw = nil
256
+ __smd_bundle_exists_raw = nil
257
+ bundle = bundle or {}
258
+ bundle.read = function(path) return __smd_bundle_read(path):await() end
259
+ bundle.exists = function(path) return __smd_bundle_exists(path):await() end
260
+ `);
261
+ if (config.tier === 'manual') {
262
+ rawGlobals.__smd_bundle_write_raw = (async (path, data) => {
263
+ try {
264
+ await view.write(path, luaStringToBytes(data));
265
+ }
266
+ catch (err) {
267
+ throw capabilityError(describeThrown(err));
268
+ }
269
+ return true;
270
+ });
271
+ preludeParts.push(`
272
+ local __smd_bundle_write = __smd_bundle_write_raw
273
+ __smd_bundle_write_raw = nil
274
+ bundle.write = function(path, data) return __smd_bundle_write(path, data):await() end
275
+ `);
276
+ }
277
+ // Under 'auto', `bundle.write` is simply never defined — spec §8's
278
+ // read-only tier table: "bundle/cache reads, cache writes only".
279
+ }
280
+ return { rawGlobals, preludeLua: preludeParts.join('\n') };
281
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Typed failure taxonomy for `runScript` (see `./sandbox`). Running hostile
3
+ * Lua must never throw a raw/opaque exception out of this package — every
4
+ * failure mode a script can trigger (resource limits, capability denial,
5
+ * an unmarshalable return value, or an ordinary Lua runtime error) is
6
+ * classified into one of these kinds before it reaches the caller.
7
+ *
8
+ * IMPORTANT CONSTRAINT this module works around: wasmoon does not preserve
9
+ * JS `Error` subclass identity across a round trip through the Lua VM. A
10
+ * custom `Error` thrown from a host-provided async capability function (see
11
+ * `./capabilities`) comes back out of `thread.run()` as a plain `Error`
12
+ * whose `.message` is the *stringified* original error — `instanceof`
13
+ * checks on the far side are useless (verified empirically against
14
+ * wasmoon 1.16.0: `new MyError('x')` thrown inside an injected async
15
+ * function round-trips as `Error: MyError: x`, not `MyError`). So
16
+ * classification of capability/marshal failures raised *from inside Lua
17
+ * execution* is done by tagging the error message with one of the
18
+ * `*_ERROR_TAG` prefixes below and pattern-matching on it in `sandbox.ts`
19
+ * after the run fails. Resource-limit breaches are NOT classified this way
20
+ * — they're tracked out-of-band via a plain JS closure flag set inside the
21
+ * instruction hook (see `./limits`), which Lua code can never see or touch,
22
+ * so that classification is exact regardless of what any error message says.
23
+ */
24
+ /** Prefix tag for a capability (permission) denial raised into Lua from a host-provided function. */
25
+ export declare const CAPABILITY_ERROR_TAG = "MARK_CAPABILITY";
26
+ /** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
27
+ export declare const MARSHAL_ERROR_TAG = "MARK_MARSHAL";
28
+ /** The limits a run can breach; see `./limits`. */
29
+ export type ScriptLimitKind = 'instructions' | 'timeout' | 'memory';
30
+ /** Why a return value was rejected by the marshaller; see `./marshal`. */
31
+ export type ScriptMarshalReason = 'depth' | 'nodes' | 'cycle' | 'type' | 'key-type' | 'non-finite-number';
32
+ /**
33
+ * The full discriminated failure shape `runScript` returns. `kind`:
34
+ * - `'limit'` — a resource limit was breached (instruction count, wall
35
+ * clock, or memory). `limit` says which.
36
+ * - `'capability'` — the script attempted something its granted
37
+ * capabilities don't allow (ungranted host, effectful op under an
38
+ * auto-run tier, disallowed bundle path/write).
39
+ * - `'marshal'` — the script's return value could not be safely converted
40
+ * to a JSON-serializable JS value (function/userdata/thread, a cycle,
41
+ * too deep, too many nodes, a non-string table key, or a non-finite
42
+ * number). `reason` says which.
43
+ * - `'runtime'` — an ordinary Lua error (syntax error, `error()` call,
44
+ * type error, stack overflow, etc.) not covered by the above.
45
+ */
46
+ export interface ScriptFailure {
47
+ kind: 'limit' | 'capability' | 'marshal' | 'runtime';
48
+ message: string;
49
+ limit?: ScriptLimitKind;
50
+ reason?: ScriptMarshalReason;
51
+ }
52
+ /**
53
+ * Thrown by the instruction-count/wall-clock hook installed in `./limits`
54
+ * when this package needs to surface a limit breach as a JS-level
55
+ * exception (e.g. from the outer `Promise.race` wall-clock guard in
56
+ * `sandbox.ts`, for the async-hang case a Lua-level hook can't observe).
57
+ * `runScript` always catches this itself — it is not part of the public
58
+ * throwing surface, only an internal signal.
59
+ */
60
+ export declare class ScriptLimitError extends Error {
61
+ readonly limitKind: ScriptLimitKind;
62
+ constructor(limitKind: ScriptLimitKind, message: string);
63
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed failure taxonomy for `runScript` (see `./sandbox`). Running hostile
3
+ * Lua must never throw a raw/opaque exception out of this package — every
4
+ * failure mode a script can trigger (resource limits, capability denial,
5
+ * an unmarshalable return value, or an ordinary Lua runtime error) is
6
+ * classified into one of these kinds before it reaches the caller.
7
+ *
8
+ * IMPORTANT CONSTRAINT this module works around: wasmoon does not preserve
9
+ * JS `Error` subclass identity across a round trip through the Lua VM. A
10
+ * custom `Error` thrown from a host-provided async capability function (see
11
+ * `./capabilities`) comes back out of `thread.run()` as a plain `Error`
12
+ * whose `.message` is the *stringified* original error — `instanceof`
13
+ * checks on the far side are useless (verified empirically against
14
+ * wasmoon 1.16.0: `new MyError('x')` thrown inside an injected async
15
+ * function round-trips as `Error: MyError: x`, not `MyError`). So
16
+ * classification of capability/marshal failures raised *from inside Lua
17
+ * execution* is done by tagging the error message with one of the
18
+ * `*_ERROR_TAG` prefixes below and pattern-matching on it in `sandbox.ts`
19
+ * after the run fails. Resource-limit breaches are NOT classified this way
20
+ * — they're tracked out-of-band via a plain JS closure flag set inside the
21
+ * instruction hook (see `./limits`), which Lua code can never see or touch,
22
+ * so that classification is exact regardless of what any error message says.
23
+ */
24
+ /** Prefix tag for a capability (permission) denial raised into Lua from a host-provided function. */
25
+ export const CAPABILITY_ERROR_TAG = 'MARK_CAPABILITY';
26
+ /** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
27
+ export const MARSHAL_ERROR_TAG = 'MARK_MARSHAL';
28
+ /**
29
+ * Thrown by the instruction-count/wall-clock hook installed in `./limits`
30
+ * when this package needs to surface a limit breach as a JS-level
31
+ * exception (e.g. from the outer `Promise.race` wall-clock guard in
32
+ * `sandbox.ts`, for the async-hang case a Lua-level hook can't observe).
33
+ * `runScript` always catches this itself — it is not part of the public
34
+ * throwing surface, only an internal signal.
35
+ */
36
+ export class ScriptLimitError extends Error {
37
+ limitKind;
38
+ constructor(limitKind, message) {
39
+ super(message);
40
+ this.name = 'ScriptLimitError';
41
+ this.limitKind = limitKind;
42
+ }
43
+ }
@@ -0,0 +1,39 @@
1
+ import type { ScriptExecutor } from '@markii/runtime';
2
+ import { type RunScriptOptions } from './sandbox.js';
3
+ /**
4
+ * Slice 2 of the scripting-usability layer (DESIGN.md §8): the reusable
5
+ * adapter from this package's `runScript` to `@markii/runtime`'s
6
+ * language-agnostic `ScriptExecutor` shape, so `runDocumentScripts` can run
7
+ * a document's script blocks through the real Lua sandbox without
8
+ * `@markii/runtime` ever depending on `@markii/lua`, wasmoon, or any
9
+ * particular language runtime.
10
+ *
11
+ * Dependency direction: `@markii/lua -> @markii/runtime` (this file), TYPE
12
+ * ONLY (`ScriptExecutor`/`ExecuteResult` are `import type`, so nothing from
13
+ * `@markii/runtime` is bundled or evaluated at runtime by this package).
14
+ * `@markii/runtime` never imports `@markii/lua` — see `run.ts` there,
15
+ * which only knows about the `ScriptExecutor` function shape. There is no
16
+ * cycle.
17
+ */
18
+ /**
19
+ * Everything `runScript` accepts except `code`/`tier` — those two are
20
+ * supplied per call by `runDocumentScripts` (`@markii/runtime`); everything
21
+ * else (net/netGrants/cache/bundle/maxFetchBytes/limits/marshalLimits) is
22
+ * this note's fixed capability/resource configuration, closed over once at
23
+ * executor-construction time.
24
+ */
25
+ export type LuaExecutorConfig = Omit<RunScriptOptions, 'code' | 'tier'>;
26
+ /**
27
+ * Builds a `ScriptExecutor` (`@markii/runtime`) backed by this package's
28
+ * `runScript`. `config` is captured once and reused for every script the
29
+ * returned executor runs; the returned function's only per-call inputs are
30
+ * `code` and `tier`, matching `ScriptExecutor`'s provider-agnostic
31
+ * signature. `runScript` never throws (see `./sandbox`'s doc comment), so
32
+ * this adapter doesn't need its own try/catch — it only reshapes the
33
+ * result: `ok: true` passes `value` through untouched; `ok: false` maps
34
+ * `runScript`'s `ScriptFailure` (`{ kind, message, ... }`) down to the
35
+ * narrower `{ kind: string; message: string }` `ExecuteFailure` shape
36
+ * `@markii/runtime` expects, dropping the Lua-specific `limit`/`reason`
37
+ * fields (still visible in `message` for anyone reading the stored error).
38
+ */
39
+ export declare function createLuaExecutor(config?: LuaExecutorConfig): ScriptExecutor;
@@ -0,0 +1,26 @@
1
+ import { runScript } from './sandbox.js';
2
+ /**
3
+ * Builds a `ScriptExecutor` (`@markii/runtime`) backed by this package's
4
+ * `runScript`. `config` is captured once and reused for every script the
5
+ * returned executor runs; the returned function's only per-call inputs are
6
+ * `code` and `tier`, matching `ScriptExecutor`'s provider-agnostic
7
+ * signature. `runScript` never throws (see `./sandbox`'s doc comment), so
8
+ * this adapter doesn't need its own try/catch — it only reshapes the
9
+ * result: `ok: true` passes `value` through untouched; `ok: false` maps
10
+ * `runScript`'s `ScriptFailure` (`{ kind, message, ... }`) down to the
11
+ * narrower `{ kind: string; message: string }` `ExecuteFailure` shape
12
+ * `@markii/runtime` expects, dropping the Lua-specific `limit`/`reason`
13
+ * fields (still visible in `message` for anyone reading the stored error).
14
+ */
15
+ export function createLuaExecutor(config = {}) {
16
+ return async ({ code, tier }) => {
17
+ const result = await runScript({ ...config, code, tier });
18
+ if (result.ok) {
19
+ return { ok: true, value: result.value };
20
+ }
21
+ return {
22
+ ok: false,
23
+ error: { kind: result.error.kind, message: result.error.message },
24
+ };
25
+ };
26
+ }
@@ -0,0 +1,32 @@
1
+ import { type LuaEngine } from 'wasmoon';
2
+ /** The Base-library names this sandbox intentionally keeps reachable. */
3
+ export declare const ALLOWED_GLOBALS: readonly string[];
4
+ /** Names this sandbox verifies are absent after scrubbing (see the adversarial test suite). */
5
+ export declare const DENIED_GLOBALS: readonly string[];
6
+ export interface CreateEmptyLuaEngineOptions {
7
+ /**
8
+ * Forwarded verbatim as wasmoon's `LuaFactory` first constructor argument
9
+ * (`customWasmUri` — confirmed against `node_modules/wasmoon/dist/
10
+ * factory.d.ts`). Left `undefined` (the default), `LuaFactory` keeps its
11
+ * own built-in resolution: the local `node_modules/wasmoon/dist/glue.wasm`
12
+ * file in Node (used by this package's own Vitest suite), or — in a
13
+ * browser bundle with no bundler-provided URL — a fetch to
14
+ * `https://unpkg.com/wasmoon@<version>/dist/glue.wasm` at runtime
15
+ * (confirmed in `node_modules/wasmoon/dist/index.js`). That CDN fetch is
16
+ * exactly what makes an unconfigured browser host non-offline-capable: no
17
+ * network to unpkg means no script can ever run. A host that wants to
18
+ * avoid it (e.g. the playground, via a Vite `?url` asset import so the
19
+ * wasm ships in its own bundle) passes its own local URL here instead.
20
+ * Passing `undefined` is IDENTICAL to omitting this options object
21
+ * entirely — this parameter only ever narrows behavior, never changes it
22
+ * by default.
23
+ */
24
+ wasmUri?: string;
25
+ }
26
+ /**
27
+ * Creates a fresh wasmoon engine with the curated, empty environment
28
+ * described above. `traceAllocations: true` is required for the memory cap
29
+ * (`./limits` / `./sandbox` call `engine.global.setMemoryMax`) — without it
30
+ * wasmoon uses the plain, uncapped allocator.
31
+ */
32
+ export declare function createEmptyLuaEngine(options?: CreateEmptyLuaEngineOptions): Promise<LuaEngine>;