@markii/lua 0.13.0 → 0.14.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.
Files changed (2) hide show
  1. package/dist/capabilities.js +123 -144
  2. package/package.json +3 -3
@@ -198,63 +198,72 @@ export function buildCapabilities(config) {
198
198
  // rather than behind one combined condition — an earlier version of
199
199
  // this function nested POST/PATCH wiring inside "if GET is granted",
200
200
  // which silently produced no `net.post` at all for a POST-only grant.
201
+ //
202
+ // The `net` table itself, and every one of its three methods, is now
203
+ // ALWAYS wired, regardless of grants, provider wiring, or tier (GitHub
204
+ // capability-stub finding, batch 7 #47). Before this, an ungranted
205
+ // script saw a missing global (`net` itself omitted when nothing was
206
+ // granted, or a missing method when a specific host/verb wasn't) and
207
+ // failed with an ordinary Lua "attempt to index/call a nil value" —
208
+ // indistinguishable, from inside the script and from a host reading
209
+ // `runScript`'s outcome, from a plain typo. A denied capability and a
210
+ // typo must never look the same (see `./errors`'s module doc comment).
211
+ // Each method below performs its own grant/tier check and records a
212
+ // denial through `recordDenial` (the non-spoofable `CapabilityDenials`
213
+ // handle `sandbox.ts` reads) before throwing — never a typed error
214
+ // string, and never anything a script's own `error()` call could forge;
215
+ // see `./errors.ts`'s `CAPABILITY_ERROR_TAG` doc comment for why the
216
+ // message text itself must never become a classification signal. One
217
+ // side effect, expected and documented in CHANGELOG: a script that does
218
+ // `if net then ... end` to feature-detect now always sees a table.
201
219
  const netGrants = config.netGrants ?? { get: [], post: [] };
202
- // NOTE: no longer conditioned on `config.tier === 'manual'` for the POST
203
- // half under 'auto' with POST hosts granted, `net.post`/`net.patch` are
204
- // now wired to TIER-BLOCKED STUBS below (not left undefined), so the
205
- // `net` table itself must exist for those stubs to attach to.
206
- const netTableNeeded = config.net !== undefined &&
207
- (netGrants.get.length > 0 || netGrants.post.length > 0);
208
- if (netTableNeeded) {
209
- preludeParts.push('net = net or {}\n');
210
- }
211
- if (config.net && netGrants.get.length > 0) {
212
- rawGlobals.__smd_net_get_raw = (async (url) => {
213
- const host = hostnameOf(url);
214
- if (!host || !netGrants.get.includes(host)) {
215
- const message = `net access to host "${host ?? url}" not granted for GET`;
216
- recordDenial('denied', message);
217
- throw capabilityError(message);
218
- }
219
- const res = await callNetProvider(() => config.net.get(url));
220
- if (res.body.length > maxFetchBytes) {
221
- const message = `fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`;
222
- recordDenial('denied', message);
223
- throw capabilityError(message);
224
- }
225
- let parsed;
226
- try {
227
- parsed = JSON.parse(res.body);
228
- }
229
- catch {
230
- const message = `fetch response for "${url}" was not valid JSON`;
231
- recordDenial('denied', message);
232
- throw capabilityError(message);
233
- }
234
- // Depth/node budget, checked HERE on the plain parsed JS value and
235
- // BEFORE the raw text is ever handed to Lua — see `./json-decode`'s
236
- // doc comment (GitHub issue #6) for why decoding happens entirely in
237
- // Lua, and `MarshalLimits`' doc comment above for why this reuses the
238
- // same budget the return-value marshal walk already enforces.
239
- const budgetCheck = checkJsonWithinLimits(parsed, marshalLimits);
240
- if (!budgetCheck.ok) {
241
- const message = `fetch response for "${url}" ${budgetCheck.message}`;
242
- recordDenial('denied', message);
243
- throw capabilityError(message);
244
- }
245
- // Hand back the RAW JSON TEXT, not the parsed JS value: any object or
246
- // array crossing this JS->Lua boundary as-is would arrive in Lua as a
247
- // wasmoon `js_proxy` userdata, not a genuine table (see
248
- // `./json-decode`'s doc comment for the full mechanism and why that
249
- // breaks `type()`/`#`/marshaling a nested return value, and silently
250
- // raises on a `null` field read). Strings, unlike objects/arrays, are
251
- // scalars and cross the boundary cleanly with no proxy involved; the
252
- // prelude below decodes this text into a genuine Lua table entirely
253
- // in Lua (`__smd_json_decode`, `./json-decode`).
254
- return res.body;
255
- });
256
- ensureJsonDecodePrelude();
257
- preludeParts.push(`
220
+ preludeParts.push('net = net or {}\n');
221
+ rawGlobals.__smd_net_get_raw = (async (url) => {
222
+ const host = hostnameOf(url);
223
+ if (!config.net || !host || !netGrants.get.includes(host)) {
224
+ const message = `net access to host "${host ?? url}" not granted for GET`;
225
+ recordDenial('denied', message);
226
+ throw capabilityError(message);
227
+ }
228
+ const res = await callNetProvider(() => config.net.get(url));
229
+ if (res.body.length > maxFetchBytes) {
230
+ const message = `fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`;
231
+ recordDenial('denied', message);
232
+ throw capabilityError(message);
233
+ }
234
+ let parsed;
235
+ try {
236
+ parsed = JSON.parse(res.body);
237
+ }
238
+ catch {
239
+ const message = `fetch response for "${url}" was not valid JSON`;
240
+ recordDenial('denied', message);
241
+ throw capabilityError(message);
242
+ }
243
+ // Depth/node budget, checked HERE on the plain parsed JS value and
244
+ // BEFORE the raw text is ever handed to Lua — see `./json-decode`'s
245
+ // doc comment (GitHub issue #6) for why decoding happens entirely in
246
+ // Lua, and `MarshalLimits`' doc comment above for why this reuses the
247
+ // same budget the return-value marshal walk already enforces.
248
+ const budgetCheck = checkJsonWithinLimits(parsed, marshalLimits);
249
+ if (!budgetCheck.ok) {
250
+ const message = `fetch response for "${url}" ${budgetCheck.message}`;
251
+ recordDenial('denied', message);
252
+ throw capabilityError(message);
253
+ }
254
+ // Hand back the RAW JSON TEXT, not the parsed JS value: any object or
255
+ // array crossing this JS->Lua boundary as-is would arrive in Lua as a
256
+ // wasmoon `js_proxy` userdata, not a genuine table (see
257
+ // `./json-decode`'s doc comment for the full mechanism and why that
258
+ // breaks `type()`/`#`/marshaling a nested return value, and silently
259
+ // raises on a `null` field read). Strings, unlike objects/arrays, are
260
+ // scalars and cross the boundary cleanly with no proxy involved; the
261
+ // prelude below decodes this text into a genuine Lua table entirely
262
+ // in Lua (`__smd_json_decode`, `./json-decode`).
263
+ return res.body;
264
+ });
265
+ ensureJsonDecodePrelude();
266
+ preludeParts.push(`
258
267
  local __smd_net_get = __smd_net_get_raw
259
268
  -- Captured into a local HERE, at prelude-definition time (this whole
260
269
  -- prelude runs once, before any untrusted script code) -- NOT resolved as
@@ -268,39 +277,43 @@ local __smd_net_get_json_decode = __smd_json_decode
268
277
  __smd_net_get_raw = nil
269
278
  net.fetch_json = function(url) return __smd_net_get_json_decode(__smd_net_get(url):await()) end
270
279
  `);
271
- }
272
- // POST/PATCH are effectful. Under the 'manual' tier, wired to the real
273
- // provider for hosts the effective grant set allows for POST. Under
274
- // 'auto', even when POST hosts ARE granted, they are wired to STUBS
275
- // that record a 'tier-blocked' denial and throw WITHOUT EVER reaching
276
- // `config.net.post`/`.patch` this grants nothing new (the provider is
277
- // never called), it only makes "granted but tier-forbidden" a
278
- // classifiable, non-spoofable outcome instead of collapsing into an
279
- // ordinary "attempt to call a nil value" runtime error (spec §8: "An
280
- // effectful call under an auto trigger fails cleanly").
281
- if (config.tier === 'manual' &&
282
- config.net?.post &&
283
- netGrants.post.length > 0) {
284
- rawGlobals.__smd_net_post_raw = (async (url, body) => {
285
- const host = hostnameOf(url);
286
- if (!host || !netGrants.post.includes(host)) {
287
- const message = `net access to host "${host ?? url}" not granted for POST`;
288
- recordDenial('denied', message);
289
- throw capabilityError(message);
290
- }
291
- const res = await callNetProvider(() => config.net.post(url, body));
292
- // As with `net.fetch_json` above (GitHub issue #6): a plain JS object
293
- // (even one this shallow) crosses into Lua as a `js_proxy` userdata,
294
- // not a genuine table. `status`/`body` are both scalars, so instead
295
- // of proxying the whole response object, resolve with a
296
- // `LuaMultiReturn` `:await()` recognizes that and expands it into
297
- // TWO separate Lua return values (see `wasmoon`'s promise
298
- // `await`/`MultiReturn` handling) and let the trusted prelude below
299
- // rebuild a real `{status=..., body=...}` table out of ordinary Lua
300
- // table-constructor syntax.
301
- return LuaMultiReturn.of(res.status, res.body);
302
- });
303
- preludeParts.push(`
280
+ // POST/PATCH are effectful. Wired unconditionally like GET above: the
281
+ // raw handler itself decides, in this order, whether the call is
282
+ // ungranted (`'denied'`), tier-forbidden (`'tier-blocked'`, only reached
283
+ // once the host/verb IS granted, under the read-only 'auto' tier — spec
284
+ // §8: "An effectful call under an auto trigger fails cleanly"), or has
285
+ // no provider wired for that verb at all (`'denied'`, since the host
286
+ // never offered it), before ever reaching `config.net.post`/`.patch`.
287
+ rawGlobals.__smd_net_post_raw = (async (url, body) => {
288
+ const host = hostnameOf(url);
289
+ if (!host || !netGrants.post.includes(host)) {
290
+ const message = `net access to host "${host ?? url}" not granted for POST`;
291
+ recordDenial('denied', message);
292
+ throw capabilityError(message);
293
+ }
294
+ if (config.tier === 'auto') {
295
+ const message = 'net.post is granted but not permitted under the read-only auto tier (requires a manual run)';
296
+ recordDenial('tier-blocked', message);
297
+ throw capabilityError(message);
298
+ }
299
+ if (!config.net?.post) {
300
+ const message = `net access to host "${host}" not granted for POST`;
301
+ recordDenial('denied', message);
302
+ throw capabilityError(message);
303
+ }
304
+ const res = await callNetProvider(() => config.net.post(url, body));
305
+ // As with `net.fetch_json` above (GitHub issue #6): a plain JS object
306
+ // (even one this shallow) crosses into Lua as a `js_proxy` userdata,
307
+ // not a genuine table. `status`/`body` are both scalars, so instead
308
+ // of proxying the whole response object, resolve with a
309
+ // `LuaMultiReturn` — `:await()` recognizes that and expands it into
310
+ // TWO separate Lua return values (see `wasmoon`'s promise
311
+ // `await`/`MultiReturn` handling) — and let the trusted prelude below
312
+ // rebuild a real `{status=..., body=...}` table out of ordinary Lua
313
+ // table-constructor syntax.
314
+ return LuaMultiReturn.of(res.status, res.body);
315
+ });
316
+ preludeParts.push(`
304
317
  local __smd_net_post = __smd_net_post_raw
305
318
  __smd_net_post_raw = nil
306
319
  net.post = function(url, body)
@@ -308,46 +321,29 @@ net.post = function(url, body)
308
321
  return { status = status, body = respBody }
309
322
  end
310
323
  `);
311
- }
312
- else if (
313
- // Mirrors the 'manual' condition above EXACTLY except for the tier, so
314
- // the read-only tier never exposes a wider method surface than the
315
- // full-grant tier would: a stub appears only where a real `net.post`
316
- // would have appeared under 'manual'. Without the `config.net?.post`
317
- // half, a host whose provider implements no POST at all would still
318
- // show `net.post` under 'auto' (as a tier-block stub) while showing
319
- // nothing under 'manual' an inconsistency a feature-detecting script
320
- // (`if net.post then`) would read exactly backwards.
321
- config.tier === 'auto' &&
322
- config.net?.post &&
323
- netGrants.post.length > 0) {
324
- rawGlobals.__smd_net_post_tier_blocked_raw = (async () => {
325
- const message = 'net.post is granted but not permitted under the read-only auto tier (requires a manual run)';
324
+ rawGlobals.__smd_net_patch_raw = (async (url, body) => {
325
+ const host = hostnameOf(url);
326
+ if (!host || !netGrants.post.includes(host)) {
327
+ const message = `net access to host "${host ?? url}" not granted for PATCH`;
328
+ recordDenial('denied', message);
329
+ throw capabilityError(message);
330
+ }
331
+ if (config.tier === 'auto') {
332
+ const message = 'net.patch is granted but not permitted under the read-only auto tier (requires a manual run)';
326
333
  recordDenial('tier-blocked', message);
327
334
  throw capabilityError(message);
328
- });
329
- preludeParts.push(`
330
- local __smd_net_post_blocked = __smd_net_post_tier_blocked_raw
331
- __smd_net_post_tier_blocked_raw = nil
332
- net.post = function(url, body) return __smd_net_post_blocked(url, body):await() end
333
- `);
334
- }
335
- if (config.tier === 'manual' &&
336
- config.net?.patch &&
337
- netGrants.post.length > 0) {
338
- rawGlobals.__smd_net_patch_raw = (async (url, body) => {
339
- const host = hostnameOf(url);
340
- if (!host || !netGrants.post.includes(host)) {
341
- const message = `net access to host "${host ?? url}" not granted for PATCH`;
342
- recordDenial('denied', message);
343
- throw capabilityError(message);
344
- }
345
- const res = await callNetProvider(() => config.net.patch(url, body));
346
- // Same fix as `net.post` above (GitHub issue #6) — see that block's
347
- // comment for the full mechanism.
348
- return LuaMultiReturn.of(res.status, res.body);
349
- });
350
- preludeParts.push(`
335
+ }
336
+ if (!config.net?.patch) {
337
+ const message = `net access to host "${host}" not granted for PATCH`;
338
+ recordDenial('denied', message);
339
+ throw capabilityError(message);
340
+ }
341
+ const res = await callNetProvider(() => config.net.patch(url, body));
342
+ // Same fix as `net.post` above (GitHub issue #6) — see that block's
343
+ // comment for the full mechanism.
344
+ return LuaMultiReturn.of(res.status, res.body);
345
+ });
346
+ preludeParts.push(`
351
347
  local __smd_net_patch = __smd_net_patch_raw
352
348
  __smd_net_patch_raw = nil
353
349
  net.patch = function(url, body)
@@ -355,23 +351,6 @@ net.patch = function(url, body)
355
351
  return { status = status, body = respBody }
356
352
  end
357
353
  `);
358
- }
359
- else if (
360
- // Same mirroring as the POST stub above — see its comment.
361
- config.tier === 'auto' &&
362
- config.net?.patch &&
363
- netGrants.post.length > 0) {
364
- rawGlobals.__smd_net_patch_tier_blocked_raw = (async () => {
365
- const message = 'net.patch is granted but not permitted under the read-only auto tier (requires a manual run)';
366
- recordDenial('tier-blocked', message);
367
- throw capabilityError(message);
368
- });
369
- preludeParts.push(`
370
- local __smd_net_patch_blocked = __smd_net_patch_tier_blocked_raw
371
- __smd_net_patch_tier_blocked_raw = nil
372
- net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await() end
373
- `);
374
- }
375
354
  // --- cache --------------------------------------------------------------
376
355
  // cache.get is implemented ENTIRELY IN LUA (see the prelude below),
377
356
  // calling the script-provided `fn` as a normal Lua-to-Lua call. This is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/lua",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.13.0",
48
- "@markii/runtime": "^0.13.0",
47
+ "@markii/bundle": "^0.14.0",
48
+ "@markii/runtime": "^0.14.0",
49
49
  "wasmoon": "^1.16.0"
50
50
  }
51
51
  }