@markii/lua 0.1.0 → 0.3.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.
@@ -53,6 +53,25 @@ export interface CapabilityConfig {
53
53
  maxFetchBytes?: number;
54
54
  }
55
55
  export declare const DEFAULT_MAX_FETCH_BYTES = 2000000;
56
+ /** One genuine capability denial, as recorded by `buildCapabilities`' `denials` handle — see its doc comment. */
57
+ export interface CapabilityDenial {
58
+ reason: 'denied' | 'tier-blocked';
59
+ message: string;
60
+ }
61
+ /**
62
+ * Non-spoofable, out-of-band record of the LAST genuine capability denial
63
+ * that happened during one `buildCapabilities` call's lifetime (i.e. one
64
+ * `runScript` call — see `./sandbox`). This is a plain JS closure: no Lua
65
+ * value, no metatable, nothing a script running in the sandbox can ever
66
+ * read or write, mirroring the discipline `./limits`' breach flag already
67
+ * uses for resource-limit kills. `sandbox.ts`'s `classifyRuntimeError`
68
+ * consults `last()` — never any error message string that crossed the Lua
69
+ * boundary — to decide whether a failed run was genuinely a `'capability'`
70
+ * kind, and if so which `capability` flavor (`'denied'` vs `'tier-blocked'`).
71
+ */
72
+ export interface CapabilityDenials {
73
+ last(): CapabilityDenial | undefined;
74
+ }
56
75
  /**
57
76
  * `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
58
77
  * per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
@@ -76,7 +95,7 @@ export declare function luaStringToBytes(s: string): Uint8Array;
76
95
  /**
77
96
  * Builds the raw, host-facing async functions to inject as flat globals,
78
97
  * 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)`,
98
+ * `cache` / `bundle` tables the docs/spec.md host API documents (`net.fetch_json(url)`,
80
99
  * `cache.get(key, ttl, fn)`, `bundle.read/write/exists(path)`).
81
100
  *
82
101
  * ## Why "raw flat globals + a Lua prelude" instead of `global.set('net', {...})`
@@ -107,7 +126,7 @@ export declare function luaStringToBytes(s: string): Uint8Array;
107
126
  * `js_promise` userdata with `:await()`/`:next()`/`:catch()` methods; the
108
127
  * CALLER must explicitly invoke `:await()` to get the resolved value
109
128
  * (verified empirically: without it, a script sees the raw promise
110
- * userdata, not the awaited result). Since `DESIGN.md`'s example script
129
+ * userdata, not the awaited result). Since `docs/spec.md`'s example script
111
130
  * (`local repo = net.fetch_json(url)`) is written as if this were
112
131
  * synchronous, the awaiting is done for the author, once, HERE — inside
113
132
  * the prelude's Lua wrapper — never exposed to the untrusted script.
@@ -121,4 +140,5 @@ export declare function luaStringToBytes(s: string): Uint8Array;
121
140
  export declare function buildCapabilities(config: CapabilityConfig): {
122
141
  rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
123
142
  preludeLua: string;
143
+ denials: CapabilityDenials;
124
144
  };
@@ -50,7 +50,7 @@ export function luaStringToBytes(s) {
50
50
  /**
51
51
  * Builds the raw, host-facing async functions to inject as flat globals,
52
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)`,
53
+ * `cache` / `bundle` tables the docs/spec.md host API documents (`net.fetch_json(url)`,
54
54
  * `cache.get(key, ttl, fn)`, `bundle.read/write/exists(path)`).
55
55
  *
56
56
  * ## Why "raw flat globals + a Lua prelude" instead of `global.set('net', {...})`
@@ -81,7 +81,7 @@ export function luaStringToBytes(s) {
81
81
  * `js_promise` userdata with `:await()`/`:next()`/`:catch()` methods; the
82
82
  * CALLER must explicitly invoke `:await()` to get the resolved value
83
83
  * (verified empirically: without it, a script sees the raw promise
84
- * userdata, not the awaited result). Since `DESIGN.md`'s example script
84
+ * userdata, not the awaited result). Since `docs/spec.md`'s example script
85
85
  * (`local repo = net.fetch_json(url)`) is written as if this were
86
86
  * synchronous, the awaiting is done for the author, once, HERE — inside
87
87
  * the prelude's Lua wrapper — never exposed to the untrusted script.
@@ -96,6 +96,15 @@ export function buildCapabilities(config) {
96
96
  const maxFetchBytes = config.maxFetchBytes ?? DEFAULT_MAX_FETCH_BYTES;
97
97
  const rawGlobals = {};
98
98
  const preludeParts = [];
99
+ // Out-of-band denial record — see `CapabilityDenials`'s doc comment. Every
100
+ // site below that throws a `capabilityError` records here FIRST, so
101
+ // `sandbox.ts` can classify the failure by this JS-only signal instead of
102
+ // by re-reading the (script-forgeable) error message.
103
+ let lastDenial;
104
+ function recordDenial(reason, message) {
105
+ lastDenial = { reason, message };
106
+ }
107
+ const denials = { last: () => lastDenial };
99
108
  // --- net --------------------------------------------------------------
100
109
  // `fetch_json` and `post`/`patch` are gated INDEPENDENTLY of each other
101
110
  // (a manifest can grant POST to a host without granting it GET, or vice
@@ -104,9 +113,12 @@ export function buildCapabilities(config) {
104
113
  // this function nested POST/PATCH wiring inside "if GET is granted",
105
114
  // which silently produced no `net.post` at all for a POST-only grant.
106
115
  const netGrants = config.netGrants ?? { get: [], post: [] };
116
+ // NOTE: no longer conditioned on `config.tier === 'manual'` for the POST
117
+ // half — under 'auto' with POST hosts granted, `net.post`/`net.patch` are
118
+ // now wired to TIER-BLOCKED STUBS below (not left undefined), so the
119
+ // `net` table itself must exist for those stubs to attach to.
107
120
  const netTableNeeded = config.net !== undefined &&
108
- (netGrants.get.length > 0 ||
109
- (config.tier === 'manual' && netGrants.post.length > 0));
121
+ (netGrants.get.length > 0 || netGrants.post.length > 0);
110
122
  if (netTableNeeded) {
111
123
  preludeParts.push('net = net or {}\n');
112
124
  }
@@ -114,18 +126,24 @@ export function buildCapabilities(config) {
114
126
  rawGlobals.__smd_net_get_raw = (async (url) => {
115
127
  const host = hostnameOf(url);
116
128
  if (!host || !netGrants.get.includes(host)) {
117
- throw capabilityError(`net access to host "${host ?? url}" not granted for GET`);
129
+ const message = `net access to host "${host ?? url}" not granted for GET`;
130
+ recordDenial('denied', message);
131
+ throw capabilityError(message);
118
132
  }
119
133
  const res = await config.net.get(url);
120
134
  if (res.body.length > maxFetchBytes) {
121
- throw capabilityError(`fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`);
135
+ const message = `fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`;
136
+ recordDenial('denied', message);
137
+ throw capabilityError(message);
122
138
  }
123
139
  let parsed;
124
140
  try {
125
141
  parsed = JSON.parse(res.body);
126
142
  }
127
143
  catch {
128
- throw capabilityError(`fetch response for "${url}" was not valid JSON`);
144
+ const message = `fetch response for "${url}" was not valid JSON`;
145
+ recordDenial('denied', message);
146
+ throw capabilityError(message);
129
147
  }
130
148
  return parsed;
131
149
  });
@@ -135,10 +153,14 @@ __smd_net_get_raw = nil
135
153
  net.fetch_json = function(url) return __smd_net_get(url):await() end
136
154
  `);
137
155
  }
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
156
+ // POST/PATCH are effectful. Under the 'manual' tier, wired to the real
157
+ // provider for hosts the effective grant set allows for POST. Under
158
+ // 'auto', even when POST hosts ARE granted, they are wired to STUBS
159
+ // that record a 'tier-blocked' denial and throw WITHOUT EVER reaching
160
+ // `config.net.post`/`.patch` — this grants nothing new (the provider is
161
+ // never called), it only makes "granted but tier-forbidden" a
162
+ // classifiable, non-spoofable outcome instead of collapsing into an
163
+ // ordinary "attempt to call a nil value" runtime error (spec §8: "An
142
164
  // effectful call under an auto trigger fails cleanly").
143
165
  if (config.tier === 'manual' &&
144
166
  config.net?.post &&
@@ -146,7 +168,9 @@ net.fetch_json = function(url) return __smd_net_get(url):await() end
146
168
  rawGlobals.__smd_net_post_raw = (async (url, body) => {
147
169
  const host = hostnameOf(url);
148
170
  if (!host || !netGrants.post.includes(host)) {
149
- throw capabilityError(`net access to host "${host ?? url}" not granted for POST`);
171
+ const message = `net access to host "${host ?? url}" not granted for POST`;
172
+ recordDenial('denied', message);
173
+ throw capabilityError(message);
150
174
  }
151
175
  return config.net.post(url, body);
152
176
  });
@@ -154,6 +178,29 @@ net.fetch_json = function(url) return __smd_net_get(url):await() end
154
178
  local __smd_net_post = __smd_net_post_raw
155
179
  __smd_net_post_raw = nil
156
180
  net.post = function(url, body) return __smd_net_post(url, body):await() end
181
+ `);
182
+ }
183
+ else if (
184
+ // Mirrors the 'manual' condition above EXACTLY except for the tier, so
185
+ // the read-only tier never exposes a wider method surface than the
186
+ // full-grant tier would: a stub appears only where a real `net.post`
187
+ // would have appeared under 'manual'. Without the `config.net?.post`
188
+ // half, a host whose provider implements no POST at all would still
189
+ // show `net.post` under 'auto' (as a tier-block stub) while showing
190
+ // nothing under 'manual' — an inconsistency a feature-detecting script
191
+ // (`if net.post then`) would read exactly backwards.
192
+ config.tier === 'auto' &&
193
+ config.net?.post &&
194
+ netGrants.post.length > 0) {
195
+ rawGlobals.__smd_net_post_tier_blocked_raw = (async () => {
196
+ const message = 'net.post is granted but not permitted under the read-only auto tier (requires a manual run)';
197
+ recordDenial('tier-blocked', message);
198
+ throw capabilityError(message);
199
+ });
200
+ preludeParts.push(`
201
+ local __smd_net_post_blocked = __smd_net_post_tier_blocked_raw
202
+ __smd_net_post_tier_blocked_raw = nil
203
+ net.post = function(url, body) return __smd_net_post_blocked(url, body):await() end
157
204
  `);
158
205
  }
159
206
  if (config.tier === 'manual' &&
@@ -162,7 +209,9 @@ net.post = function(url, body) return __smd_net_post(url, body):await() end
162
209
  rawGlobals.__smd_net_patch_raw = (async (url, body) => {
163
210
  const host = hostnameOf(url);
164
211
  if (!host || !netGrants.post.includes(host)) {
165
- throw capabilityError(`net access to host "${host ?? url}" not granted for PATCH`);
212
+ const message = `net access to host "${host ?? url}" not granted for PATCH`;
213
+ recordDenial('denied', message);
214
+ throw capabilityError(message);
166
215
  }
167
216
  return config.net.patch(url, body);
168
217
  });
@@ -170,6 +219,22 @@ net.post = function(url, body) return __smd_net_post(url, body):await() end
170
219
  local __smd_net_patch = __smd_net_patch_raw
171
220
  __smd_net_patch_raw = nil
172
221
  net.patch = function(url, body) return __smd_net_patch(url, body):await() end
222
+ `);
223
+ }
224
+ else if (
225
+ // Same mirroring as the POST stub above — see its comment.
226
+ config.tier === 'auto' &&
227
+ config.net?.patch &&
228
+ netGrants.post.length > 0) {
229
+ rawGlobals.__smd_net_patch_tier_blocked_raw = (async () => {
230
+ const message = 'net.patch is granted but not permitted under the read-only auto tier (requires a manual run)';
231
+ recordDenial('tier-blocked', message);
232
+ throw capabilityError(message);
233
+ });
234
+ preludeParts.push(`
235
+ local __smd_net_patch_blocked = __smd_net_patch_tier_blocked_raw
236
+ __smd_net_patch_tier_blocked_raw = nil
237
+ net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await() end
173
238
  `);
174
239
  }
175
240
  // --- cache --------------------------------------------------------------
@@ -220,24 +285,27 @@ end
220
285
  // Delegates entirely to the injected `ScriptView` (`@markii/bundle`), which
221
286
  // already enforces the path-jail and the read/write:cache/ split (spec
222
287
  // §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.
288
+ // `bundle.write` (a tier-blocked stub under 'auto' — read-only tier) and
289
+ // the byte<->Lua-string conversion.
225
290
  if (config.bundle) {
226
291
  const view = config.bundle;
227
292
  // `ScriptView` (@markii/bundle) throws its own `ScriptCapabilityError` /
228
293
  // `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.
294
+ // are re-tagged here with `CAPABILITY_ERROR_TAG` (a cosmetic prefix
295
+ // only, see `./errors`'s doc comment) AND recorded on the `denials`
296
+ // handle as reason `'denied'`, so `sandbox.ts` reports them as
297
+ // `kind: 'capability', capability: 'denied'` uniformly, the same as a
298
+ // net host-allowlist denial, rather than falling through to the
299
+ // generic `'runtime'` bucket.
234
300
  rawGlobals.__smd_bundle_read_raw = (async (path) => {
235
301
  let data;
236
302
  try {
237
303
  data = await view.read(path);
238
304
  }
239
305
  catch (err) {
240
- throw capabilityError(describeThrown(err));
306
+ const message = describeThrown(err);
307
+ recordDenial('denied', message);
308
+ throw capabilityError(message);
241
309
  }
242
310
  return data === undefined ? null : bytesToLuaString(data);
243
311
  });
@@ -246,7 +314,9 @@ end
246
314
  return await view.exists(path);
247
315
  }
248
316
  catch (err) {
249
- throw capabilityError(describeThrown(err));
317
+ const message = describeThrown(err);
318
+ recordDenial('denied', message);
319
+ throw capabilityError(message);
250
320
  }
251
321
  });
252
322
  preludeParts.push(`
@@ -264,7 +334,9 @@ bundle.exists = function(path) return __smd_bundle_exists(path):await() end
264
334
  await view.write(path, luaStringToBytes(data));
265
335
  }
266
336
  catch (err) {
267
- throw capabilityError(describeThrown(err));
337
+ const message = describeThrown(err);
338
+ recordDenial('denied', message);
339
+ throw capabilityError(message);
268
340
  }
269
341
  return true;
270
342
  });
@@ -274,8 +346,25 @@ __smd_bundle_write_raw = nil
274
346
  bundle.write = function(path, data) return __smd_bundle_write(path, data):await() end
275
347
  `);
276
348
  }
277
- // Under 'auto', `bundle.write` is simply never defined — spec §8's
278
- // read-only tier table: "bundle/cache reads, cache writes only".
349
+ else {
350
+ // Under 'auto': `bundle.write` is wired to a TIER-BLOCKED STUB that
351
+ // records a 'tier-blocked' denial and throws WITHOUT EVER reaching
352
+ // `view.write` — the bundle view's own write path is never touched,
353
+ // so this grants nothing new; it only makes "write is available but
354
+ // this tier forbids it" classifiable instead of collapsing into an
355
+ // ordinary "attempt to call a nil value" runtime error (spec §8:
356
+ // "bundle/cache reads, cache writes only" under the read-only tier).
357
+ rawGlobals.__smd_bundle_write_tier_blocked_raw = (async () => {
358
+ const message = 'bundle.write is not permitted under the read-only auto tier (requires a manual run)';
359
+ recordDenial('tier-blocked', message);
360
+ throw capabilityError(message);
361
+ });
362
+ preludeParts.push(`
363
+ local __smd_bundle_write_blocked = __smd_bundle_write_tier_blocked_raw
364
+ __smd_bundle_write_tier_blocked_raw = nil
365
+ bundle.write = function(path, data) return __smd_bundle_write_blocked(path, data):await() end
366
+ `);
367
+ }
279
368
  }
280
- return { rawGlobals, preludeLua: preludeParts.join('\n') };
369
+ return { rawGlobals, preludeLua: preludeParts.join('\n'), denials };
281
370
  }
package/dist/errors.d.ts CHANGED
@@ -13,33 +13,61 @@
13
13
  * checks on the far side are useless (verified empirically against
14
14
  * wasmoon 1.16.0: `new MyError('x')` thrown inside an injected async
15
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.
16
+ * classification of marshal failures raised *from inside Lua execution* is
17
+ * done by tagging the error message with `MARSHAL_ERROR_TAG` and
18
+ * pattern-matching on it in `sandbox.ts` after the run fails (a forged
19
+ * marshal tag only relabels one script-error-class failure as another
20
+ * see `classifyRuntimeError`'s doc comment).
21
+ *
22
+ * Capability failures are classified DIFFERENTLY, and NOT by message
23
+ * matching (this used to be message-tag-based too — `error("MARK_CAPABILITY:
24
+ * ...")` from a script would forge a `kind: 'capability'` result, which is
25
+ * exactly the bug this taxonomy closes). See `./capabilities`'s
26
+ * `CapabilityDenials` handle: every genuine denial is recorded on a plain JS
27
+ * closure BEFORE the corresponding throw, entirely out of Lua's reach, and
28
+ * `sandbox.ts` consults that handle — never the message that came back out
29
+ * of the VM — to decide `kind: 'capability'`. This mirrors the discipline
30
+ * already used for resource-limit breaches: they're tracked out-of-band via
31
+ * a plain JS closure flag set inside the instruction hook (see `./limits`)
32
+ * and the raw `LuaReturn.ErrorMem` C-API status code (see
33
+ * `captureAssertOkStatus` in `./sandbox`), neither of which Lua code can see
34
+ * or touch, so classification is exact regardless of what any error message
35
+ * says.
36
+ */
37
+ /**
38
+ * Prefix tag applied to the `Error` message thrown into Lua for a capability
39
+ * (permission) denial. PURELY COSMETIC as of the capability-denial JS-closure
40
+ * rework (see the module doc comment above): it makes a message readable to
41
+ * a human/log, and nothing else. It is NOT, and must never again become, a
42
+ * classification signal — `sandbox.ts`'s `classifyRuntimeError` does not
43
+ * (and must not) inspect it; a script forging `error("MARK_CAPABILITY:
44
+ * ...")` produces a message containing this tag but classifies as an
45
+ * ordinary `'runtime'`/`'script-error'` failure, exactly like any other
46
+ * `error()` call, because no genuine denial was ever recorded on the
47
+ * `CapabilityDenials` handle for that run.
23
48
  */
24
- /** Prefix tag for a capability (permission) denial raised into Lua from a host-provided function. */
25
49
  export declare const CAPABILITY_ERROR_TAG = "MARK_CAPABILITY";
26
50
  /** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
27
51
  export declare const MARSHAL_ERROR_TAG = "MARK_MARSHAL";
28
52
  /** The limits a run can breach; see `./limits`. */
29
53
  export type ScriptLimitKind = 'instructions' | 'timeout' | 'memory';
30
54
  /** Why a return value was rejected by the marshaller; see `./marshal`. */
31
- export type ScriptMarshalReason = 'depth' | 'nodes' | 'cycle' | 'type' | 'key-type' | 'non-finite-number';
55
+ export type ScriptMarshalReason = 'depth' | 'nodes' | 'cycle' | 'type' | 'key-type' | 'non-finite-number' | 'nul-byte';
32
56
  /**
33
57
  * The full discriminated failure shape `runScript` returns. `kind`:
34
58
  * - `'limit'` — a resource limit was breached (instruction count, wall
35
59
  * clock, or memory). `limit` says which.
36
60
  * - `'capability'` — the script attempted something its granted
37
61
  * capabilities don't allow (ungranted host, effectful op under an
38
- * auto-run tier, disallowed bundle path/write).
62
+ * auto-run tier, disallowed bundle path/write). `capability` says which
63
+ * flavor — see below.
39
64
  * - `'marshal'` — the script's return value could not be safely converted
40
65
  * 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.
66
+ * too deep, too many nodes, a non-string table key, a non-finite
67
+ * number, or a string containing an embedded NUL byte — wasmoon silently
68
+ * truncates a Lua string at its first NUL when converting to JS, so this
69
+ * is rejected on the Lua side before that truncation can happen).
70
+ * `reason` says which.
43
71
  * - `'runtime'` — an ordinary Lua error (syntax error, `error()` call,
44
72
  * type error, stack overflow, etc.) not covered by the above.
45
73
  */
@@ -48,6 +76,20 @@ export interface ScriptFailure {
48
76
  message: string;
49
77
  limit?: ScriptLimitKind;
50
78
  reason?: ScriptMarshalReason;
79
+ /**
80
+ * Set only when `kind === 'capability'`, discriminating WHICH flavor of
81
+ * capability failure this was — derived exclusively from `./capabilities`'
82
+ * `CapabilityDenials` handle (a plain JS closure, never from any message
83
+ * text; see the module doc comment):
84
+ * - `'denied'` — the grant was absent, or the host actively refused
85
+ * (an ungranted net host, a bundle path-jail rejection, a fetch-size
86
+ * cap). A manual run with the SAME grants would fail identically.
87
+ * - `'tier-blocked'` — the capability genuinely exists in the granted set,
88
+ * but the current execution tier (`'auto'`) forbids exercising it. A
89
+ * manual run of the exact same script, with the exact same grants,
90
+ * would succeed.
91
+ */
92
+ capability?: 'denied' | 'tier-blocked';
51
93
  }
52
94
  /**
53
95
  * Thrown by the instruction-count/wall-clock hook installed in `./limits`
package/dist/errors.js CHANGED
@@ -13,15 +13,39 @@
13
13
  * checks on the far side are useless (verified empirically against
14
14
  * wasmoon 1.16.0: `new MyError('x')` thrown inside an injected async
15
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.
16
+ * classification of marshal failures raised *from inside Lua execution* is
17
+ * done by tagging the error message with `MARSHAL_ERROR_TAG` and
18
+ * pattern-matching on it in `sandbox.ts` after the run fails (a forged
19
+ * marshal tag only relabels one script-error-class failure as another
20
+ * see `classifyRuntimeError`'s doc comment).
21
+ *
22
+ * Capability failures are classified DIFFERENTLY, and NOT by message
23
+ * matching (this used to be message-tag-based too — `error("MARK_CAPABILITY:
24
+ * ...")` from a script would forge a `kind: 'capability'` result, which is
25
+ * exactly the bug this taxonomy closes). See `./capabilities`'s
26
+ * `CapabilityDenials` handle: every genuine denial is recorded on a plain JS
27
+ * closure BEFORE the corresponding throw, entirely out of Lua's reach, and
28
+ * `sandbox.ts` consults that handle — never the message that came back out
29
+ * of the VM — to decide `kind: 'capability'`. This mirrors the discipline
30
+ * already used for resource-limit breaches: they're tracked out-of-band via
31
+ * a plain JS closure flag set inside the instruction hook (see `./limits`)
32
+ * and the raw `LuaReturn.ErrorMem` C-API status code (see
33
+ * `captureAssertOkStatus` in `./sandbox`), neither of which Lua code can see
34
+ * or touch, so classification is exact regardless of what any error message
35
+ * says.
36
+ */
37
+ /**
38
+ * Prefix tag applied to the `Error` message thrown into Lua for a capability
39
+ * (permission) denial. PURELY COSMETIC as of the capability-denial JS-closure
40
+ * rework (see the module doc comment above): it makes a message readable to
41
+ * a human/log, and nothing else. It is NOT, and must never again become, a
42
+ * classification signal — `sandbox.ts`'s `classifyRuntimeError` does not
43
+ * (and must not) inspect it; a script forging `error("MARK_CAPABILITY:
44
+ * ...")` produces a message containing this tag but classifies as an
45
+ * ordinary `'runtime'`/`'script-error'` failure, exactly like any other
46
+ * `error()` call, because no genuine denial was ever recorded on the
47
+ * `CapabilityDenials` handle for that run.
23
48
  */
24
- /** Prefix tag for a capability (permission) denial raised into Lua from a host-provided function. */
25
49
  export const CAPABILITY_ERROR_TAG = 'MARK_CAPABILITY';
26
50
  /** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
27
51
  export const MARSHAL_ERROR_TAG = 'MARK_MARSHAL';
@@ -1,7 +1,7 @@
1
1
  import type { ScriptExecutor } from '@markii/runtime';
2
2
  import { type RunScriptOptions } from './sandbox.js';
3
3
  /**
4
- * Slice 2 of the scripting-usability layer (DESIGN.md §8): the reusable
4
+ * Slice 2 of the scripting-usability layer (docs/scripting.md): the reusable
5
5
  * adapter from this package's `runScript` to `@markii/runtime`'s
6
6
  * language-agnostic `ScriptExecutor` shape, so `runDocumentScripts` can run
7
7
  * a document's script blocks through the real Lua sandbox without
@@ -31,9 +31,10 @@ export type LuaExecutorConfig = Omit<RunScriptOptions, 'code' | 'tier'>;
31
31
  * signature. `runScript` never throws (see `./sandbox`'s doc comment), so
32
32
  * this adapter doesn't need its own try/catch — it only reshapes the
33
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).
34
+ * `runScript`'s `ScriptFailure` (`{ kind, message, ... }`) down to
35
+ * `@markii/runtime`'s closed `ExecuteFailure` shape via
36
+ * `toRuntimeFailureKind`, dropping the Lua-specific `limit`/`reason`/
37
+ * `capability` sub-fields (still visible in `message` for anyone reading
38
+ * the stored error).
38
39
  */
39
40
  export declare function createLuaExecutor(config?: LuaExecutorConfig): ScriptExecutor;
package/dist/executor.js CHANGED
@@ -1,4 +1,32 @@
1
1
  import { runScript } from './sandbox.js';
2
+ /**
3
+ * Maps this package's own `ScriptFailure.kind` (`'limit' | 'capability' |
4
+ * 'marshal' | 'runtime'`, further discriminated by `ScriptFailure.
5
+ * capability` for the `'capability'` case) down to `@markii/runtime`'s
6
+ * closed, shared `FailureKind` union (`'script-error' | 'capability-denied'
7
+ * | 'tier-blocked' | 'limit'`):
8
+ * - `'limit'` -> `'limit'`
9
+ * - `'capability'` with `capability === 'tier-blocked'` -> `'tier-blocked'`
10
+ * - `'capability'` otherwise (i.e. `'denied'`, or — defensively — absent)
11
+ * -> `'capability-denied'`
12
+ * - `'marshal'` / `'runtime'` -> `'script-error'`
13
+ *
14
+ * This is the one place the Lua-specific taxonomy and the runtime-shared
15
+ * one meet; every other module in this package only knows `ScriptFailure`.
16
+ */
17
+ function toRuntimeFailureKind(failure) {
18
+ switch (failure.kind) {
19
+ case 'limit':
20
+ return 'limit';
21
+ case 'capability':
22
+ return failure.capability === 'tier-blocked'
23
+ ? 'tier-blocked'
24
+ : 'capability-denied';
25
+ case 'marshal':
26
+ case 'runtime':
27
+ return 'script-error';
28
+ }
29
+ }
2
30
  /**
3
31
  * Builds a `ScriptExecutor` (`@markii/runtime`) backed by this package's
4
32
  * `runScript`. `config` is captured once and reused for every script the
@@ -7,10 +35,11 @@ import { runScript } from './sandbox.js';
7
35
  * signature. `runScript` never throws (see `./sandbox`'s doc comment), so
8
36
  * this adapter doesn't need its own try/catch — it only reshapes the
9
37
  * 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).
38
+ * `runScript`'s `ScriptFailure` (`{ kind, message, ... }`) down to
39
+ * `@markii/runtime`'s closed `ExecuteFailure` shape via
40
+ * `toRuntimeFailureKind`, dropping the Lua-specific `limit`/`reason`/
41
+ * `capability` sub-fields (still visible in `message` for anyone reading
42
+ * the stored error).
14
43
  */
15
44
  export function createLuaExecutor(config = {}) {
16
45
  return async ({ code, tier }) => {
@@ -20,7 +49,10 @@ export function createLuaExecutor(config = {}) {
20
49
  }
21
50
  return {
22
51
  ok: false,
23
- error: { kind: result.error.kind, message: result.error.message },
52
+ error: {
53
+ kind: toRuntimeFailureKind(result.error),
54
+ message: result.error.message,
55
+ },
24
56
  };
25
57
  };
26
58
  }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export type { CreateEmptyLuaEngineOptions } from './globals.js';
4
4
  export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
5
5
  export type { LimitHandle, ScriptLimits } from './limits.js';
6
6
  export { DEFAULT_LIMITS, installLimits } from './limits.js';
7
- export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
7
+ export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityDenial, CapabilityDenials, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
8
8
  export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
9
9
  export type { MarshalLimits } from './marshal.js';
10
10
  export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @markii/lua: the sandboxed Lua 5.4 (wasmoon) execution primitive backing
2
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
3
+ // filesystem). No React, no @markii/core, no @markii/react — see AGENTS.md's
4
4
  // import rule and the ESLint guard in the root config. May depend on
5
5
  // @markii/bundle for the `ScriptView` capability type only.
6
6
  export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
package/dist/limits.d.ts CHANGED
@@ -112,7 +112,7 @@ export interface LimitHandle {
112
112
  * AUTHORITATIVE kill for that class of failure is not this hook at all —
113
113
  * it's the host's EXTERNAL, terminatable-isolate watchdog (dedicated Web
114
114
  * Worker/`worker_thread` + an outside wall-clock timer calling
115
- * `terminate()`), now normative in DESIGN.md §10 ("In-process limits are
115
+ * `terminate()`), now normative in docs/security.md ("In-process limits are
116
116
  * best-effort; the terminatable isolate is the real guarantee"). This
117
117
  * module's hook reduces how often that external kill is needed and gives
118
118
  * fast, precise, in-band error classification for the common compute-bound
package/dist/limits.js CHANGED
@@ -93,7 +93,7 @@ export const DEFAULT_LIMITS = {
93
93
  * AUTHORITATIVE kill for that class of failure is not this hook at all —
94
94
  * it's the host's EXTERNAL, terminatable-isolate watchdog (dedicated Web
95
95
  * Worker/`worker_thread` + an outside wall-clock timer calling
96
- * `terminate()`), now normative in DESIGN.md §10 ("In-process limits are
96
+ * `terminate()`), now normative in docs/security.md ("In-process limits are
97
97
  * best-effort; the terminatable isolate is the real guarantee"). This
98
98
  * module's hook reduces how often that external kill is needed and gives
99
99
  * fast, precise, in-band error classification for the common compute-bound
package/dist/marshal.js CHANGED
@@ -74,9 +74,21 @@ export const DEFAULT_MARSHAL_LIMITS = {
74
74
  */
75
75
  export function buildMarshalPrelude(limits) {
76
76
  return `
77
+ -- Captured into locals AT PRELUDE-DEFINITION TIME (this chunk is doString'd
78
+ -- once per engine, before any untrusted user code runs), so the walk below
79
+ -- closes over these as upvalues fixed to the genuine primitives -- immune to
80
+ -- a later script doing e.g. \`error = function() end\` in the shared globals
81
+ -- table before this walk runs. See finding F-1 in the sandbox audit: the
82
+ -- walk previously resolved error/type/pairs/math.floor as DYNAMIC GLOBAL
83
+ -- lookups, which a script could rebind to neuter its own caps.
84
+ local error, type, pairs, floor, sfind = error, type, pairs, math.floor, string.find
85
+
77
86
  local function __smd_marshal(value, seen, depth, budget)
78
87
  local t = type(value)
79
88
  if t == "nil" or t == "boolean" or t == "string" or t == "number" then
89
+ if t == "string" and sfind(value, "\\0", 1, true) then
90
+ error("${MARSHAL_ERROR_TAG}:nul-byte")
91
+ end
80
92
  budget.n = budget.n + 1
81
93
  if budget.n > budget.maxNodes then error("${MARSHAL_ERROR_TAG}:nodes") end
82
94
  return value
@@ -90,7 +102,7 @@ local function __smd_marshal(value, seen, depth, budget)
90
102
  local isArray = true
91
103
  for k, _ in pairs(value) do
92
104
  count = count + 1
93
- if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
105
+ if type(k) ~= "number" or k < 1 or floor(k) ~= k then
94
106
  isArray = false
95
107
  end
96
108
  end
package/dist/sandbox.d.ts CHANGED
@@ -58,11 +58,13 @@ export type RunScriptResult = {
58
58
  * UNCONDITIONALLY and, if set, wins over whatever the run otherwise
59
59
  * reported — see `./limits`'s doc comment for why this is the actual
60
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
61
+ * 7. Otherwise, a thrown error is classified by non-spoofable out-of-band
62
+ * signals, in order, before ever falling back to
63
63
  * `classifyRuntimeError`'s message-based path: the wall-clock guard's
64
64
  * own `ScriptLimitError` sentinel (`instanceof`, Defect 3), then the
65
- * raw `LuaReturn.ErrorMem` status code (Defect 2). A successful return
65
+ * raw `LuaReturn.ErrorMem` status code (Defect 2), then step 3's
66
+ * `CapabilityDenials` handle (`denials.last()` — was ANY genuine
67
+ * denial/tier-block recorded during this run?). A successful return
66
68
  * goes through `finalizeMarshaledValue` for the final NaN/Infinity
67
69
  * check and marker cleanup.
68
70
  * 8. `finally`: hook removed, thread popped, engine closed — every path,
package/dist/sandbox.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { LuaReturn } from 'wasmoon';
2
2
  import { buildCapabilities, } from './capabilities.js';
3
- import { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
3
+ import { MARSHAL_ERROR_TAG, ScriptLimitError } from './errors.js';
4
4
  import { createEmptyLuaEngine } from './globals.js';
5
5
  import { DEFAULT_LIMITS, installLimits } from './limits.js';
6
6
  import { buildMarshalPrelude, DEFAULT_MARSHAL_LIMITS, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
@@ -105,6 +105,8 @@ function extractMarshalReason(message) {
105
105
  return 'cycle';
106
106
  case 'key-type':
107
107
  return 'key-type';
108
+ case 'nul-byte':
109
+ return 'nul-byte';
108
110
  case 'type':
109
111
  return 'type';
110
112
  default:
@@ -113,15 +115,27 @@ function extractMarshalReason(message) {
113
115
  }
114
116
  /**
115
117
  * 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:
118
+ * `ScriptFailure` shape, EXCLUDING capability failures — those are decided
119
+ * separately, BEFORE this function is ever consulted (see `runScript`
120
+ * below), by the `CapabilityDenials` out-of-band handle (`./capabilities`).
121
+ * This function only ever returns `'marshal'` or `'runtime'`.
122
+ *
123
+ * Message-prefix matching (`MARSHAL_ERROR_TAG`) is used here rather than
124
+ * `instanceof` because wasmoon does not preserve JS `Error` subclass
125
+ * identity across the Lua round trip see the doc comment on that tag in
126
+ * `./errors` for the empirical evidence. This IS spoofable in principle (a
127
+ * script could `error("MARK_MARSHAL:type ...")`), but the consequence of
128
+ * that forgery is bounded and accepted: it only relabels one
129
+ * `'script-error'`-class failure (`'runtime'`) as another (`'marshal'`) —
130
+ * neither claims a capability was ever exercised, so there is no security
131
+ * property being defended here the way there is for `'capability'`/
132
+ * `'limit'`.
133
+ *
134
+ * Resource-limit breaches are ALL classified separately, BEFORE this
135
+ * function is ever called, via non-spoofable out-of-band signals — never
136
+ * through a message-based path, since a script can trivially forge any
137
+ * message string (e.g. `error("MARK_LIMIT: ...")` or `error("not enough
138
+ * memory")`) but cannot forge these:
125
139
  * - instruction/wall-clock breaches: the JS closure flag from
126
140
  * `./limits`' hook (see `runScript` below);
127
141
  * - the async-hang backstop: `instanceof ScriptLimitError` on the
@@ -129,15 +143,14 @@ function extractMarshalReason(message) {
129
143
  * boundary (see the guard's construction in `runScript`);
130
144
  * - memory-cap breaches: the raw `LuaReturn.ErrorMem` C-API status code
131
145
  * captured by `captureAssertOkStatus` (see its doc comment).
146
+ * Capability failures are classified the same non-spoofable way, via the
147
+ * `CapabilityDenials` handle — a script forging `error("MARK_CAPABILITY:
148
+ * ...")` no longer produces `kind: 'capability'`; with no genuine denial
149
+ * recorded, it falls through to THIS function and comes back `'runtime'`,
150
+ * exactly like any other `error()` call with no special meaning.
132
151
  */
133
152
  function classifyRuntimeError(err) {
134
153
  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
154
  if (message.includes(MARSHAL_ERROR_TAG)) {
142
155
  return {
143
156
  kind: 'marshal',
@@ -171,11 +184,13 @@ function classifyRuntimeError(err) {
171
184
  * UNCONDITIONALLY and, if set, wins over whatever the run otherwise
172
185
  * reported — see `./limits`'s doc comment for why this is the actual
173
186
  * 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
187
+ * 7. Otherwise, a thrown error is classified by non-spoofable out-of-band
188
+ * signals, in order, before ever falling back to
176
189
  * `classifyRuntimeError`'s message-based path: the wall-clock guard's
177
190
  * own `ScriptLimitError` sentinel (`instanceof`, Defect 3), then the
178
- * raw `LuaReturn.ErrorMem` status code (Defect 2). A successful return
191
+ * raw `LuaReturn.ErrorMem` status code (Defect 2), then step 3's
192
+ * `CapabilityDenials` handle (`denials.last()` — was ANY genuine
193
+ * denial/tier-block recorded during this run?). A successful return
179
194
  * goes through `finalizeMarshaledValue` for the final NaN/Infinity
180
195
  * check and marker cleanup.
181
196
  * 8. `finally`: hook removed, thread popped, engine closed — every path,
@@ -194,7 +209,7 @@ export async function runScript(options) {
194
209
  let limitHandle;
195
210
  let guardTimer;
196
211
  try {
197
- const { rawGlobals, preludeLua } = buildCapabilities({
212
+ const { rawGlobals, preludeLua, denials } = buildCapabilities({
198
213
  tier: options.tier,
199
214
  net: options.net,
200
215
  netGrants: options.netGrants,
@@ -302,6 +317,39 @@ export async function runScript(options) {
302
317
  },
303
318
  };
304
319
  }
320
+ // Capability failures, identified the same non-spoofable way as the
321
+ // limit breaches above: `denials.last()` is a plain JS closure
322
+ // (`./capabilities`'s `CapabilityDenials`) that Lua can never see or
323
+ // touch, recorded BEFORE the corresponding throw at every genuine
324
+ // denial/tier-block site. If ANY denial was recorded during this run,
325
+ // it wins over whatever `classifyRuntimeError`'s message-based path
326
+ // would otherwise conclude — using the RECORDED message, never the
327
+ // message that came back out of Lua (which the script could have
328
+ // rewritten via its own `pcall`/`error` games).
329
+ //
330
+ // Known, accepted edge case (same precedent as the limit-breach flag
331
+ // in `./limits` winning unconditionally): a script that triggers a
332
+ // real denial, swallows it with its own `pcall`, and then throws its
333
+ // OWN unrelated error is still attributed to that genuine denial —
334
+ // `denials.last()` has no way to know the denial was "handled" by the
335
+ // script, and the failure genuinely did happen during this run. It
336
+ // can never work the other way around: a script can trigger zero
337
+ // denials and still forge `kind: 'capability'` — that path is now
338
+ // fully closed. If more than one denial is recorded in a single run
339
+ // (e.g. a caught GET denial followed by an uncaught POST tier-block),
340
+ // `last()` reports the LAST one, matching normal "most recent state
341
+ // wins" semantics for a single mutable JS closure variable.
342
+ const denial = denials.last();
343
+ if (denial) {
344
+ return {
345
+ ok: false,
346
+ error: {
347
+ kind: 'capability',
348
+ capability: denial.reason,
349
+ message: denial.message,
350
+ },
351
+ };
352
+ }
305
353
  return { ok: false, error: classifyRuntimeError(runResult.err) };
306
354
  }
307
355
  const finalized = finalizeMarshaledValue(runResult.value);
@@ -317,6 +365,24 @@ export async function runScript(options) {
317
365
  }
318
366
  return { ok: true, value: finalized.value };
319
367
  }
368
+ catch (err) {
369
+ // Backstop for the never-throws guarantee: every expected failure mode
370
+ // above returns before reaching here (limit breaches, capability
371
+ // denials, marshal rejections, ordinary runtime errors are all
372
+ // returned, not thrown). This catch exists for anything UNEXPECTED that
373
+ // throws synchronously inside the try — e.g. `finalizeMarshaledValue`
374
+ // recursing deep enough to overflow the JS call stack, which today is
375
+ // safe only INCIDENTALLY because wasmoon's own `getValue` conversion
376
+ // overflows first on sufficiently deep input (see the sandbox audit's
377
+ // finding F-1 "also recommended" note) — so that guarantee no longer
378
+ // depends on that ordering holding forever. Classified the same as any
379
+ // other unclassified failure: a plain `'runtime'` failure, never a raw
380
+ // throw out of `runScript`.
381
+ return {
382
+ ok: false,
383
+ error: { kind: 'runtime', message: describeError(err) },
384
+ };
385
+ }
320
386
  finally {
321
387
  limitHandle?.dispose();
322
388
  if (thread !== undefined && threadStackIndex !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/lua",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
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
5
  "keywords": [
6
6
  "markdown",
@@ -44,8 +44,8 @@
44
44
  "lint": "eslint ."
45
45
  },
46
46
  "dependencies": {
47
- "@markii/bundle": "0.1.0",
48
- "@markii/runtime": "0.1.0",
47
+ "@markii/bundle": "0.3.0",
48
+ "@markii/runtime": "0.3.0",
49
49
  "wasmoon": "^1.16.0"
50
50
  }
51
51
  }