@markii/lua 0.12.1 → 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.
- package/dist/capabilities.d.ts +1 -1
- package/dist/capabilities.js +126 -147
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/json-table.d.ts +88 -0
- package/dist/json-table.js +151 -0
- package/dist/sandbox.d.ts +4 -1
- package/dist/sandbox.js +33 -2
- package/package.json +3 -3
package/dist/capabilities.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface CacheEntry {
|
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Host-provided cache primitive backing `cache.get(key, ttl, fn)`. Real
|
|
27
|
-
* persistence (bundle
|
|
27
|
+
* persistence (bundle `.cache/`, IndexedDB, whatever the host uses) is the
|
|
28
28
|
* host's concern; this package only defines the read-if-fresh-else-run-fn
|
|
29
29
|
* contract.
|
|
30
30
|
*/
|
package/dist/capabilities.js
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// never
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
@@ -422,7 +401,7 @@ net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await(
|
|
|
422
401
|
// JSON-safe result is handed to `config.cache!.set` — so
|
|
423
402
|
// `CacheEntry.value`'s STORAGE shape is unchanged (still whatever
|
|
424
403
|
// plain value the host's `CacheProvider` already expects; e.g. a
|
|
425
|
-
// bundle's
|
|
404
|
+
// bundle's `.cache/*.json` file), and a script's own scalar values
|
|
426
405
|
// (numbers, strings, booleans) round-trip exactly as before.
|
|
427
406
|
// - Either enforcement failing raises the existing, already-classified
|
|
428
407
|
// `MARSHAL_ERROR_TAG` error (`sandbox.ts` already recognizes it as
|
|
@@ -438,7 +417,7 @@ net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await(
|
|
|
438
417
|
// to text and handed to Lua — mirrors `net.fetch_json`'s own
|
|
439
418
|
// pre-check exactly (adversarial finding B2). A host-stored value is
|
|
440
419
|
// exactly as untrusted as a remote fetch body — a bundle's
|
|
441
|
-
//
|
|
420
|
+
// `.cache/*.json` file, for instance, can be edited by anything with
|
|
442
421
|
// write access to the bundle, not just this sandbox's own WRITE side
|
|
443
422
|
// below — so without this check, a 300k-element cached array reached
|
|
444
423
|
// the script completely uncapped even though the FETCH path was
|
|
@@ -557,7 +536,7 @@ end
|
|
|
557
536
|
}
|
|
558
537
|
// --- bundle -------------------------------------------------------------
|
|
559
538
|
// Delegates entirely to the injected `ScriptView` (`@markii/bundle`), which
|
|
560
|
-
// already enforces the path-jail and the read/write
|
|
539
|
+
// already enforces the path-jail and the read/write:.cache/ split (spec
|
|
561
540
|
// §11). This module adds nothing on top except the tier gate for
|
|
562
541
|
// `bundle.write` (a tier-blocked stub under 'auto' — read-only tier) and
|
|
563
542
|
// the byte<->Lua-string conversion.
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityDenial, Cap
|
|
|
8
8
|
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
|
|
9
9
|
export type { DocConfig } from './doc.js';
|
|
10
10
|
export { buildDoc } from './doc.js';
|
|
11
|
+
export type { JsonTableConfig } from './json-table.js';
|
|
12
|
+
export { buildJsonTable } from './json-table.js';
|
|
11
13
|
export type { MarshalLimits } from './marshal.js';
|
|
12
14
|
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
13
15
|
export type { PackModuleResolver, RequireConfig } from './require.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './global
|
|
|
8
8
|
export { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
9
9
|
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
|
|
10
10
|
export { buildDoc } from './doc.js';
|
|
11
|
+
export { buildJsonTable } from './json-table.js';
|
|
11
12
|
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
12
13
|
export { buildRequire } from './require.js';
|
|
13
14
|
export { runScript } from './sandbox.js';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type MarshalLimits } from './marshal.js';
|
|
2
|
+
export interface JsonTableConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Depth/node budget applied to a `json.decode` input and, via
|
|
5
|
+
* `__smd_marshal_root`, to a `json.encode` value. The SAME `MarshalLimits`
|
|
6
|
+
* `./sandbox` already uses for the marshal prelude and `./capabilities`
|
|
7
|
+
* uses for `net.fetch_json` -- never a second, independently-tuned cap.
|
|
8
|
+
*/
|
|
9
|
+
limits: MarshalLimits;
|
|
10
|
+
/**
|
|
11
|
+
* Byte-size cap on the TEXT handed to `json.decode`, checked before the
|
|
12
|
+
* text is JSON-parsed at all. The SAME cap `net.fetch_json` applies to a
|
|
13
|
+
* fetched response body (`./capabilities`' `maxFetchBytes`), reused here
|
|
14
|
+
* rather than forked.
|
|
15
|
+
*/
|
|
16
|
+
maxFetchBytes: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Builds the raw JS globals and trusted Lua prelude for the `json` table:
|
|
20
|
+
* `json.decode(text)` and `json.encode(value)` (GitHub issue #40, slice 3a).
|
|
21
|
+
*
|
|
22
|
+
* `json` is pure computation with no I/O -- it never touches the network,
|
|
23
|
+
* the bundle filesystem, or the clock -- so `./sandbox` injects this
|
|
24
|
+
* unconditionally, for every tier, with no capability grant required. This
|
|
25
|
+
* mirrors how `./marshal`'s and `./doc`'s preludes are already injected
|
|
26
|
+
* unconditionally; `json` joins that same "always present" set rather than
|
|
27
|
+
* being wired through `./capabilities`' tier/grant machinery, which exists
|
|
28
|
+
* specifically to gate effectful or ambient-authority operations this
|
|
29
|
+
* table has none of.
|
|
30
|
+
*
|
|
31
|
+
* ## `json.decode`: reuses the EXISTING decoder and EXISTING budget
|
|
32
|
+
*
|
|
33
|
+
* The actual parse is `./json-decode`'s `__smd_json_decode` -- the same
|
|
34
|
+
* trusted, in-Lua JSON decoder `net.fetch_json`/`cache.get` already use in
|
|
35
|
+
* `./capabilities` (see that module's doc comment for why decoding must
|
|
36
|
+
* happen entirely in Lua rather than trusting wasmoon's own JS-object
|
|
37
|
+
* conversion). This function never writes a second decoder.
|
|
38
|
+
*
|
|
39
|
+
* Before that decoder ever sees the text, a JS-side precheck applies the
|
|
40
|
+
* exact same two-part budget `net.fetch_json` applies to a fetched body:
|
|
41
|
+
* the `maxFetchBytes` byte-size cap (an ordinary, untagged `error()`,
|
|
42
|
+
* `kind: 'runtime'` -- no `ScriptMarshalReason` value fits "too many
|
|
43
|
+
* bytes"), and `./marshal`'s `checkJsonWithinLimits` (the very function
|
|
44
|
+
* `./capabilities` calls) against the `JSON.parse`d value, tagged
|
|
45
|
+
* `MARK_MARSHAL:depth`/`MARK_MARSHAL:nodes` so `./sandbox`'s
|
|
46
|
+
* `classifyRuntimeError` reports it as `kind: 'marshal'`, `reason: 'depth'
|
|
47
|
+
* | 'nodes'` -- the same classification a `json.encode` budget violation
|
|
48
|
+
* gets (see below), so a script sees one consistent failure shape for "too
|
|
49
|
+
* deep or too wide" regardless of which direction it happened. Text that
|
|
50
|
+
* fails to `JSON.parse` at all is NOT diagnosed here in JS: it is
|
|
51
|
+
* handed straight to `__smd_json_decode`, which raises its own "malformed"
|
|
52
|
+
* error -- duplicating that diagnosis in two places risks the two
|
|
53
|
+
* disagreeing, and the Lua decoder's message is the one already documented.
|
|
54
|
+
*
|
|
55
|
+
* ## `json.encode`: reuses the EXISTING marshal walk
|
|
56
|
+
*
|
|
57
|
+
* `json.encode(value)` runs `value` through `__smd_marshal_root`
|
|
58
|
+
* (`./marshal`'s `buildMarshalPrelude`, already injected once per engine
|
|
59
|
+
* before this prelude runs) -- the SAME depth/node-capped, cycle-detecting,
|
|
60
|
+
* type-checking walk a script's own top-level return value already goes
|
|
61
|
+
* through. No second walk is written. The capped, marker-tagged result then
|
|
62
|
+
* crosses to JS as a single bounded function argument (safe by construction:
|
|
63
|
+
* the walk has already capped it, exactly as `./capabilities`' `cache.get`
|
|
64
|
+
* write path already relies on for the same reason), where
|
|
65
|
+
* `finalizeMarshaledValue` -- the same JS-side pass `./sandbox` runs on a
|
|
66
|
+
* script's return value -- strips the array marker and rejects a
|
|
67
|
+
* non-finite number, before the result is `JSON.stringify`d. A cycle,
|
|
68
|
+
* excess depth/nodes, a non-string table key, or a function/userdata/thread
|
|
69
|
+
* all raise the walk's existing `MARK_MARSHAL:<reason>` tag, so `json.encode`
|
|
70
|
+
* failures classify exactly like a script's own return-value marshal
|
|
71
|
+
* failures: `kind: 'marshal'`, with `reason` one of `./marshal`'s existing
|
|
72
|
+
* `ScriptMarshalReason` values.
|
|
73
|
+
*
|
|
74
|
+
* ## Rebinding safety
|
|
75
|
+
*
|
|
76
|
+
* `json.decode`/`json.encode` themselves close over `type`, `error`,
|
|
77
|
+
* `__smd_json_decode`, and `__smd_marshal_root` captured into locals AT
|
|
78
|
+
* PRELUDE-DEFINITION TIME -- before any untrusted script code runs -- so a
|
|
79
|
+
* later script reassigning any of those globals (or `json` itself) cannot
|
|
80
|
+
* neuter the type check or reach past the reused decoder/marshal walk. This
|
|
81
|
+
* is the same discipline `./json-decode` (finding A1) and `./capabilities`
|
|
82
|
+
* (findings A2/D1) already apply to their own internal calls into these
|
|
83
|
+
* exact two functions.
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildJsonTable(config: JsonTableConfig): {
|
|
86
|
+
rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
|
|
87
|
+
preludeLua: string;
|
|
88
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { MARSHAL_ERROR_TAG } from './errors.js';
|
|
2
|
+
import { buildJsonDecodePrelude } from './json-decode.js';
|
|
3
|
+
import { checkJsonWithinLimits, finalizeMarshaledValue, } from './marshal.js';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the raw JS globals and trusted Lua prelude for the `json` table:
|
|
6
|
+
* `json.decode(text)` and `json.encode(value)` (GitHub issue #40, slice 3a).
|
|
7
|
+
*
|
|
8
|
+
* `json` is pure computation with no I/O -- it never touches the network,
|
|
9
|
+
* the bundle filesystem, or the clock -- so `./sandbox` injects this
|
|
10
|
+
* unconditionally, for every tier, with no capability grant required. This
|
|
11
|
+
* mirrors how `./marshal`'s and `./doc`'s preludes are already injected
|
|
12
|
+
* unconditionally; `json` joins that same "always present" set rather than
|
|
13
|
+
* being wired through `./capabilities`' tier/grant machinery, which exists
|
|
14
|
+
* specifically to gate effectful or ambient-authority operations this
|
|
15
|
+
* table has none of.
|
|
16
|
+
*
|
|
17
|
+
* ## `json.decode`: reuses the EXISTING decoder and EXISTING budget
|
|
18
|
+
*
|
|
19
|
+
* The actual parse is `./json-decode`'s `__smd_json_decode` -- the same
|
|
20
|
+
* trusted, in-Lua JSON decoder `net.fetch_json`/`cache.get` already use in
|
|
21
|
+
* `./capabilities` (see that module's doc comment for why decoding must
|
|
22
|
+
* happen entirely in Lua rather than trusting wasmoon's own JS-object
|
|
23
|
+
* conversion). This function never writes a second decoder.
|
|
24
|
+
*
|
|
25
|
+
* Before that decoder ever sees the text, a JS-side precheck applies the
|
|
26
|
+
* exact same two-part budget `net.fetch_json` applies to a fetched body:
|
|
27
|
+
* the `maxFetchBytes` byte-size cap (an ordinary, untagged `error()`,
|
|
28
|
+
* `kind: 'runtime'` -- no `ScriptMarshalReason` value fits "too many
|
|
29
|
+
* bytes"), and `./marshal`'s `checkJsonWithinLimits` (the very function
|
|
30
|
+
* `./capabilities` calls) against the `JSON.parse`d value, tagged
|
|
31
|
+
* `MARK_MARSHAL:depth`/`MARK_MARSHAL:nodes` so `./sandbox`'s
|
|
32
|
+
* `classifyRuntimeError` reports it as `kind: 'marshal'`, `reason: 'depth'
|
|
33
|
+
* | 'nodes'` -- the same classification a `json.encode` budget violation
|
|
34
|
+
* gets (see below), so a script sees one consistent failure shape for "too
|
|
35
|
+
* deep or too wide" regardless of which direction it happened. Text that
|
|
36
|
+
* fails to `JSON.parse` at all is NOT diagnosed here in JS: it is
|
|
37
|
+
* handed straight to `__smd_json_decode`, which raises its own "malformed"
|
|
38
|
+
* error -- duplicating that diagnosis in two places risks the two
|
|
39
|
+
* disagreeing, and the Lua decoder's message is the one already documented.
|
|
40
|
+
*
|
|
41
|
+
* ## `json.encode`: reuses the EXISTING marshal walk
|
|
42
|
+
*
|
|
43
|
+
* `json.encode(value)` runs `value` through `__smd_marshal_root`
|
|
44
|
+
* (`./marshal`'s `buildMarshalPrelude`, already injected once per engine
|
|
45
|
+
* before this prelude runs) -- the SAME depth/node-capped, cycle-detecting,
|
|
46
|
+
* type-checking walk a script's own top-level return value already goes
|
|
47
|
+
* through. No second walk is written. The capped, marker-tagged result then
|
|
48
|
+
* crosses to JS as a single bounded function argument (safe by construction:
|
|
49
|
+
* the walk has already capped it, exactly as `./capabilities`' `cache.get`
|
|
50
|
+
* write path already relies on for the same reason), where
|
|
51
|
+
* `finalizeMarshaledValue` -- the same JS-side pass `./sandbox` runs on a
|
|
52
|
+
* script's return value -- strips the array marker and rejects a
|
|
53
|
+
* non-finite number, before the result is `JSON.stringify`d. A cycle,
|
|
54
|
+
* excess depth/nodes, a non-string table key, or a function/userdata/thread
|
|
55
|
+
* all raise the walk's existing `MARK_MARSHAL:<reason>` tag, so `json.encode`
|
|
56
|
+
* failures classify exactly like a script's own return-value marshal
|
|
57
|
+
* failures: `kind: 'marshal'`, with `reason` one of `./marshal`'s existing
|
|
58
|
+
* `ScriptMarshalReason` values.
|
|
59
|
+
*
|
|
60
|
+
* ## Rebinding safety
|
|
61
|
+
*
|
|
62
|
+
* `json.decode`/`json.encode` themselves close over `type`, `error`,
|
|
63
|
+
* `__smd_json_decode`, and `__smd_marshal_root` captured into locals AT
|
|
64
|
+
* PRELUDE-DEFINITION TIME -- before any untrusted script code runs -- so a
|
|
65
|
+
* later script reassigning any of those globals (or `json` itself) cannot
|
|
66
|
+
* neuter the type check or reach past the reused decoder/marshal walk. This
|
|
67
|
+
* is the same discipline `./json-decode` (finding A1) and `./capabilities`
|
|
68
|
+
* (findings A2/D1) already apply to their own internal calls into these
|
|
69
|
+
* exact two functions.
|
|
70
|
+
*/
|
|
71
|
+
export function buildJsonTable(config) {
|
|
72
|
+
const { limits, maxFetchBytes } = config;
|
|
73
|
+
const rawGlobals = {};
|
|
74
|
+
rawGlobals.__smd_json_decode_precheck_raw = (async (text) => {
|
|
75
|
+
if (typeof text === 'string' && text.length > maxFetchBytes) {
|
|
76
|
+
// No `ScriptMarshalReason` value fits "too many bytes" (that taxonomy
|
|
77
|
+
// is depth/nodes/cycle/type/key-type/non-finite-number/nul-byte, none
|
|
78
|
+
// of which is a byte-size cap), so this is left untagged: an ordinary
|
|
79
|
+
// Lua error, classified `kind: 'runtime'` by `./sandbox` like any
|
|
80
|
+
// other `error()` call. It is still a clean, bounded, descriptive
|
|
81
|
+
// failure -- never a hang, never a crash -- which is what matters.
|
|
82
|
+
throw new Error(`json.decode: input exceeds the ${maxFetchBytes}-byte limit`);
|
|
83
|
+
}
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(text);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Not valid JSON text: let `__smd_json_decode` (the Lua decoder)
|
|
90
|
+
// raise its own "malformed" error rather than diagnosing this twice,
|
|
91
|
+
// in two places, with two possibly-inconsistent messages.
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const check = checkJsonWithinLimits(parsed, limits);
|
|
95
|
+
if (!check.ok) {
|
|
96
|
+
throw new Error(`${MARSHAL_ERROR_TAG}:${check.reason}: json.decode input ${check.message}`);
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
rawGlobals.__smd_json_encode_raw = (async (marshaledValue) => {
|
|
101
|
+
const finalized = finalizeMarshaledValue(marshaledValue);
|
|
102
|
+
if (!finalized.ok) {
|
|
103
|
+
throw new Error(`${MARSHAL_ERROR_TAG}:${finalized.reason}`);
|
|
104
|
+
}
|
|
105
|
+
const text = JSON.stringify(finalized.value);
|
|
106
|
+
// `JSON.stringify` only returns `undefined` for a value it cannot
|
|
107
|
+
// represent -- already excluded by `finalizeMarshaledValue` above -- so
|
|
108
|
+
// this is a defensive fallback that is never expected to trigger.
|
|
109
|
+
return text === undefined ? 'null' : text;
|
|
110
|
+
});
|
|
111
|
+
const preludeLua = `
|
|
112
|
+
${buildJsonDecodePrelude(limits)}
|
|
113
|
+
|
|
114
|
+
-- Captured into locals HERE, at prelude-definition time -- see the module
|
|
115
|
+
-- doc comment's "Rebinding safety" section.
|
|
116
|
+
local __smd_json_type = type
|
|
117
|
+
local __smd_json_error = error
|
|
118
|
+
local __smd_json_decode_precheck = __smd_json_decode_precheck_raw
|
|
119
|
+
local __smd_json_encode_finish = __smd_json_encode_raw
|
|
120
|
+
local __smd_json_decode_fn = __smd_json_decode
|
|
121
|
+
local __smd_json_marshal_root = __smd_marshal_root
|
|
122
|
+
__smd_json_decode_precheck_raw = nil
|
|
123
|
+
__smd_json_encode_raw = nil
|
|
124
|
+
-- \`__smd_json_decode\` (defined by the injected \`./json-decode\` prelude
|
|
125
|
+
-- above) is only ever needed as a GLOBAL long enough for callers to capture
|
|
126
|
+
-- it into their own local -- exactly like \`__smd_net_get_json_decode\` and
|
|
127
|
+
-- \`__smd_cache_json_decode\` already do in \`./capabilities\`. This prelude
|
|
128
|
+
-- runs AFTER any of those (see \`./sandbox\`'s injection order), so every
|
|
129
|
+
-- earlier consumer has already captured its own reference by this point;
|
|
130
|
+
-- nilling the global here keeps \`json\` from being the run that leaves a
|
|
131
|
+
-- private \`__smd_\` name reachable on every single run (it is now injected
|
|
132
|
+
-- UNCONDITIONALLY, unlike \`net\`/\`cache\`, which only define this global
|
|
133
|
+
-- when actually configured) -- see the pass-3 residue probes
|
|
134
|
+
-- (\`require-pass3.probe.test.ts\`, \`doc.probe.test.ts\`), which assert NO
|
|
135
|
+
-- \`__smd_\`-prefixed global survives except \`__smd_marshal_root\`.
|
|
136
|
+
__smd_json_decode = nil
|
|
137
|
+
|
|
138
|
+
json = json or {}
|
|
139
|
+
json.decode = function(text)
|
|
140
|
+
if __smd_json_type(text) ~= "string" then
|
|
141
|
+
__smd_json_error("json.decode expects a string argument")
|
|
142
|
+
end
|
|
143
|
+
__smd_json_decode_precheck(text):await()
|
|
144
|
+
return __smd_json_decode_fn(text)
|
|
145
|
+
end
|
|
146
|
+
json.encode = function(value)
|
|
147
|
+
return __smd_json_encode_finish(__smd_json_marshal_root(value)):await()
|
|
148
|
+
end
|
|
149
|
+
`;
|
|
150
|
+
return { rawGlobals, preludeLua };
|
|
151
|
+
}
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -63,7 +63,10 @@ export type RunScriptResult = {
|
|
|
63
63
|
* the real `require` global, sharing the same `bundle`/denial-recording
|
|
64
64
|
* wiring (§8's bundle-local and pack-namespaced module sources).
|
|
65
65
|
* 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
|
|
66
|
-
* the wrapped user code's return value is piped through
|
|
66
|
+
* the wrapped user code's return value is piped through, then
|
|
67
|
+
* `./json-table` — the `json.decode`/`json.encode` table, unconditional
|
|
68
|
+
* and ungated (pure computation, no capability grant), reusing that
|
|
69
|
+
* same marshal walk and `./json-decode`'s existing decoder.
|
|
67
70
|
* 5. A dedicated child thread (NOT `engine.doString`, which creates its
|
|
68
71
|
* own internal thread we'd have no handle to — see `./limits`'s "hooks
|
|
69
72
|
* are per-thread" note) gets the instruction/wall-clock hook installed,
|
package/dist/sandbox.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { LuaReturn } from 'wasmoon';
|
|
2
|
-
import { buildCapabilities, } from './capabilities.js';
|
|
2
|
+
import { buildCapabilities, DEFAULT_MAX_FETCH_BYTES, } from './capabilities.js';
|
|
3
3
|
import { buildDoc } from './doc.js';
|
|
4
4
|
import { MARSHAL_ERROR_TAG, ScriptLimitError } from './errors.js';
|
|
5
5
|
import { createEmptyLuaEngine } from './globals.js';
|
|
6
|
+
import { buildJsonTable } from './json-table.js';
|
|
6
7
|
import { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
7
8
|
import { buildMarshalPrelude, DEFAULT_MARSHAL_LIMITS, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
8
9
|
import { buildRequire } from './require.js';
|
|
@@ -109,6 +110,20 @@ function extractMarshalReason(message) {
|
|
|
109
110
|
return 'key-type';
|
|
110
111
|
case 'nul-byte':
|
|
111
112
|
return 'nul-byte';
|
|
113
|
+
case 'non-finite-number':
|
|
114
|
+
// Unlike the other cases above (all raised from INSIDE the Lua-side
|
|
115
|
+
// `__smd_marshal` walk itself), a non-finite number is normally
|
|
116
|
+
// caught by `finalizeMarshaledValue` running directly in JS on a
|
|
117
|
+
// successful run's already-returned value -- it never crosses back
|
|
118
|
+
// through Lua as a thrown, tagged error on that path, so this case
|
|
119
|
+
// was unreached before `./json-table` existed. `json.encode` reuses
|
|
120
|
+
// `finalizeMarshaledValue` itself (the same JS-side check, the same
|
|
121
|
+
// reason) but calls it from inside a host function invoked FROM Lua,
|
|
122
|
+
// so its rejection DOES round-trip through a thrown Lua error and
|
|
123
|
+
// land here -- this case keeps that reused reason classified
|
|
124
|
+
// identically to the return-value path, instead of falling through
|
|
125
|
+
// to the generic 'type' default.
|
|
126
|
+
return 'non-finite-number';
|
|
112
127
|
case 'type':
|
|
113
128
|
return 'type';
|
|
114
129
|
default:
|
|
@@ -179,7 +194,10 @@ function classifyRuntimeError(err) {
|
|
|
179
194
|
* the real `require` global, sharing the same `bundle`/denial-recording
|
|
180
195
|
* wiring (§8's bundle-local and pack-namespaced module sources).
|
|
181
196
|
* 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
|
|
182
|
-
* the wrapped user code's return value is piped through
|
|
197
|
+
* the wrapped user code's return value is piped through, then
|
|
198
|
+
* `./json-table` — the `json.decode`/`json.encode` table, unconditional
|
|
199
|
+
* and ungated (pure computation, no capability grant), reusing that
|
|
200
|
+
* same marshal walk and `./json-decode`'s existing decoder.
|
|
183
201
|
* 5. A dedicated child thread (NOT `engine.doString`, which creates its
|
|
184
202
|
* own internal thread we'd have no handle to — see `./limits`'s "hooks
|
|
185
203
|
* are per-thread" note) gets the instruction/wall-clock hook installed,
|
|
@@ -261,6 +279,19 @@ export async function runScript(options) {
|
|
|
261
279
|
throw new Error(`sandbox assembly left a code-loading primitive reachable (${String(loadResidue)}); refusing to run`);
|
|
262
280
|
}
|
|
263
281
|
await engine.doString(buildMarshalPrelude(marshalLimits));
|
|
282
|
+
// ./json-table: the `json` table (GitHub issue #40, slice 3a). Pure
|
|
283
|
+
// computation, no I/O -- injected unconditionally, for every tier, with
|
|
284
|
+
// no capability grant, same as the marshal/doc preludes below and
|
|
285
|
+
// above it. Wired AFTER the marshal prelude because `json.encode`
|
|
286
|
+
// reuses `__smd_marshal_root`, defined by that prelude.
|
|
287
|
+
const jsonTable = buildJsonTable({
|
|
288
|
+
limits: marshalLimits,
|
|
289
|
+
maxFetchBytes: options.maxFetchBytes ?? DEFAULT_MAX_FETCH_BYTES,
|
|
290
|
+
});
|
|
291
|
+
for (const [name, fn] of Object.entries(jsonTable.rawGlobals)) {
|
|
292
|
+
engine.global.set(name, fn);
|
|
293
|
+
}
|
|
294
|
+
await engine.doString(jsonTable.preludeLua);
|
|
264
295
|
// ./doc: the note-scoped read-only view (GitHub issue #33). Wired
|
|
265
296
|
// AFTER the marshal prelude and, like `require`, wired unconditionally
|
|
266
297
|
// — with no `options.doc` it is an empty listing, never an absent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/lua",
|
|
3
|
-
"version": "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.
|
|
48
|
-
"@markii/runtime": "^0.
|
|
47
|
+
"@markii/bundle": "^0.14.0",
|
|
48
|
+
"@markii/runtime": "^0.14.0",
|
|
49
49
|
"wasmoon": "^1.16.0"
|
|
50
50
|
}
|
|
51
51
|
}
|