@markii/lua 0.4.0 → 0.5.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.
@@ -85,6 +85,10 @@ export interface CapabilityDenial {
85
85
  export interface CapabilityDenials {
86
86
  last(): CapabilityDenial | undefined;
87
87
  }
88
+ /** Construct an `Error` a `NetProvider` throws for a policy denial — see {@link NET_PROVIDER_DENIAL}. */
89
+ export declare function netProviderDenial(message: string): Error;
90
+ /** True when `err` was built by {@link netProviderDenial} — a provider's policy refusal, not a transport failure. */
91
+ export declare function isNetProviderDenial(err: unknown): err is Error;
88
92
  /**
89
93
  * `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
90
94
  * per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
@@ -6,6 +6,37 @@ export const DEFAULT_MAX_FETCH_BYTES = 2_000_000;
6
6
  function capabilityError(message) {
7
7
  return new Error(`${CAPABILITY_ERROR_TAG}: ${message}`);
8
8
  }
9
+ /**
10
+ * Brand marking an `Error` a {@link NetProvider} throws for a POLICY denial
11
+ * (an ungranted redirect hop, an over-size body, too many hops, a
12
+ * credential-bearing redirect) as opposed to a genuine transport failure
13
+ * (DNS, connection refused). A provider is one layer below `buildCapabilities`'
14
+ * own grant check, so its throws would otherwise surface as an ordinary
15
+ * runtime error and be classified `'script-error'`. Marking the throw lets
16
+ * this module record it on the non-spoofable {@link CapabilityDenials} handle
17
+ * (see `callNetProvider` below) instead of relying on a message string a
18
+ * script could read and forge.
19
+ *
20
+ * This closes P2-c (PENTEST-REPORT-2026-08-23.md §9.3): the earlier host-side
21
+ * fix embedded a per-run random tag in the thrown message so the host could
22
+ * reclassify the failure, but that message crosses back into Lua, where a
23
+ * script's own `pcall`/`tostring` reads the tag and then forges it. The brand
24
+ * is a JS `Symbol` checked on the JS side, BEFORE the error ever crosses the
25
+ * Lua boundary, so nothing a script can observe distinguishes a denial, and
26
+ * classification no longer depends on any Lua-visible string.
27
+ */
28
+ const NET_PROVIDER_DENIAL = Symbol('markii.net.provider-denial');
29
+ /** Construct an `Error` a `NetProvider` throws for a policy denial — see {@link NET_PROVIDER_DENIAL}. */
30
+ export function netProviderDenial(message) {
31
+ return Object.assign(new Error(message), {
32
+ [NET_PROVIDER_DENIAL]: true,
33
+ });
34
+ }
35
+ /** True when `err` was built by {@link netProviderDenial} — a provider's policy refusal, not a transport failure. */
36
+ export function isNetProviderDenial(err) {
37
+ return (err instanceof Error &&
38
+ err[NET_PROVIDER_DENIAL] === true);
39
+ }
9
40
  function describeThrown(err) {
10
41
  return err instanceof Error ? err.message : String(err);
11
42
  }
@@ -138,6 +169,28 @@ export function buildCapabilities(config) {
138
169
  lastDenial = { reason, message };
139
170
  }
140
171
  const denials = { last: () => lastDenial };
172
+ /**
173
+ * Invokes a `NetProvider` method, turning a POLICY denial the provider
174
+ * throws (marked via `netProviderDenial`) into the same non-spoofable
175
+ * outcome an in-house grant check produces: recorded on `recordDenial` and
176
+ * re-thrown as a `capabilityError` carrying the provider's own plain
177
+ * message. A transport failure (unmarked) is left to propagate untouched,
178
+ * so it stays a `'script-error'`. This is what lets the host provider stop
179
+ * smuggling a secret classification tag through the Lua-visible error text
180
+ * (P2-c — see `netProviderDenial`).
181
+ */
182
+ async function callNetProvider(op) {
183
+ try {
184
+ return await op();
185
+ }
186
+ catch (err) {
187
+ if (isNetProviderDenial(err)) {
188
+ recordDenial('denied', err.message);
189
+ throw capabilityError(err.message);
190
+ }
191
+ throw err;
192
+ }
193
+ }
141
194
  // --- net --------------------------------------------------------------
142
195
  // `fetch_json` and `post`/`patch` are gated INDEPENDENTLY of each other
143
196
  // (a manifest can grant POST to a host without granting it GET, or vice
@@ -163,7 +216,7 @@ export function buildCapabilities(config) {
163
216
  recordDenial('denied', message);
164
217
  throw capabilityError(message);
165
218
  }
166
- const res = await config.net.get(url);
219
+ const res = await callNetProvider(() => config.net.get(url));
167
220
  if (res.body.length > maxFetchBytes) {
168
221
  const message = `fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`;
169
222
  recordDenial('denied', message);
@@ -235,7 +288,7 @@ net.fetch_json = function(url) return __smd_net_get_json_decode(__smd_net_get(ur
235
288
  recordDenial('denied', message);
236
289
  throw capabilityError(message);
237
290
  }
238
- const res = await config.net.post(url, body);
291
+ const res = await callNetProvider(() => config.net.post(url, body));
239
292
  // As with `net.fetch_json` above (GitHub issue #6): a plain JS object
240
293
  // (even one this shallow) crosses into Lua as a `js_proxy` userdata,
241
294
  // not a genuine table. `status`/`body` are both scalars, so instead
@@ -289,7 +342,7 @@ net.post = function(url, body) return __smd_net_post_blocked(url, body):await()
289
342
  recordDenial('denied', message);
290
343
  throw capabilityError(message);
291
344
  }
292
- const res = await config.net.patch(url, body);
345
+ const res = await callNetProvider(() => config.net.patch(url, body));
293
346
  // Same fix as `net.post` above (GitHub issue #6) — see that block's
294
347
  // comment for the full mechanism.
295
348
  return LuaMultiReturn.of(res.status, res.body);
@@ -528,7 +581,17 @@ end
528
581
  recordDenial('denied', message);
529
582
  throw capabilityError(message);
530
583
  }
531
- return data === undefined ? null : bytesToLuaString(data);
584
+ // Resolve with `undefined`, NOT `null`, for a missing path. wasmoon's
585
+ // `Thread.pushValue` special-cases `typeof target === 'undefined'`
586
+ // with a direct `lua_pushnil` BEFORE it ever reaches its
587
+ // type-extension dispatch loop (`dist/index.js`'s `pushValue`
588
+ // `switch`); `null` is `typeof 'object'`, so it instead falls into
589
+ // that loop, where `PromiseTypeExtension.pushValue` unconditionally
590
+ // reads `decoration.target.then` and throws `Cannot read properties
591
+ // of null (reading 'then')` — verified empirically (wasmoon 1.16.0).
592
+ // This is exactly the failure this fixes (GitHub issue #9): a
593
+ // missing bundle path must resolve to Lua `nil`, not throw.
594
+ return data === undefined ? undefined : bytesToLuaString(data);
532
595
  });
533
596
  rawGlobals.__smd_bundle_exists_raw = (async (path) => {
534
597
  try {
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './global
5
5
  export type { LimitHandle, ScriptLimits } from './limits.js';
6
6
  export { DEFAULT_LIMITS, installLimits } from './limits.js';
7
7
  export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityDenial, CapabilityDenials, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
8
- export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
8
+ export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
9
9
  export type { MarshalLimits } from './marshal.js';
10
10
  export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
11
11
  export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  export { CAPABILITY_ERROR_TAG, FETCH_DECODE_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
7
7
  export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
8
8
  export { DEFAULT_LIMITS, installLimits } from './limits.js';
9
- export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
9
+ export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
10
10
  export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
11
11
  export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
12
12
  export { runScript } from './sandbox.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/lua",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.4.0",
48
- "@markii/runtime": "0.4.0",
47
+ "@markii/bundle": "0.5.0",
48
+ "@markii/runtime": "0.5.0",
49
49
  "wasmoon": "^1.16.0"
50
50
  }
51
51
  }