@markii/lua 0.10.0 → 0.11.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/doc.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { DocView } from '@markii/runtime';
2
+ import { type MarshalLimits } from './marshal.js';
3
+ /**
4
+ * The `doc` table (GitHub issue #33): a script's read-only view of the
5
+ * note it is written in.
6
+ *
7
+ * `doc.directives(filter)` lists the note's directives in document order,
8
+ * and `doc.value(name)` reads what a script ABOVE this one produced. Both
9
+ * are pure reads of content the note already holds, so unlike `net`,
10
+ * `cache` and `bundle` this table is NOT tier-gated and needs no grant: it
11
+ * is wired identically for a manual run and an auto/scheduled one. There
12
+ * is no write side to gate.
13
+ *
14
+ * ## Why the listing crosses as JSON text
15
+ *
16
+ * For the same reason `net.fetch_json` does (see `./json-decode`): a plain
17
+ * JS object handed to `global.set` arrives in Lua as a wasmoon `js_proxy`
18
+ * userdata, not a genuine table, so `type()`, `#`, `ipairs` and marshaling
19
+ * a piece of it back out all misbehave. A string is a scalar and crosses
20
+ * cleanly, and `__smd_json_decode` rebuilds it into an ordinary Lua table
21
+ * the script cannot tell from one it wrote itself.
22
+ *
23
+ * The decode is repeated on every `doc.directives()` call rather than
24
+ * cached in a Lua local, and that is deliberate: each call therefore hands
25
+ * back FRESH tables. A script that scribbles on an entry it was given
26
+ * cannot make the next call in the same script see its edits, and it never
27
+ * had a way to reach the next script at all, since each script runs in its
28
+ * own engine (`./sandbox`'s `runScript` builds and closes one per script).
29
+ * The JSON itself is built once, on the JS side, and reused.
30
+ *
31
+ * ## What a rejected read looks like
32
+ *
33
+ * Reading a name that belongs to a script further down the note is a
34
+ * script-authoring mistake, not a permission or resource problem. It is
35
+ * therefore NOT recorded on `./capabilities`' `CapabilityDenials` handle:
36
+ * recording it there would classify the run as `'capability-denied'` and
37
+ * the host would tell the user their note needs a permission it does not
38
+ * need. It stays a `'runtime'` failure, which `./executor` maps to
39
+ * `'script-error'`, so the marker reads "script error: reads "quiz", which
40
+ * runs later in the note".
41
+ *
42
+ * It does get its own out-of-band record ({@link DocRejections}), for the
43
+ * same reason the capability denials have one: the message that comes back
44
+ * OUT of Lua is wrapped ("Error: ..." plus a stack traceback) and may have
45
+ * been rewritten by the script's own `pcall`/`error` games, so the host
46
+ * would otherwise put a traceback in a tooltip. `./sandbox` reads the
47
+ * recorded sentence instead, which the script can neither see nor forge.
48
+ */
49
+ export interface DocConfig {
50
+ /**
51
+ * This script's view of its note, built by `@markii/runtime`'s
52
+ * `runDocumentScripts` from the listing the host handed it. Absent, the
53
+ * `doc` table is still wired, with an empty listing and a `value` that
54
+ * answers nil for every name: a script must never meet a `doc` that is
55
+ * nil, or the first thing an author writes fails as "attempt to index a
56
+ * nil value" with nothing to explain it.
57
+ */
58
+ doc?: DocView;
59
+ /**
60
+ * The depth/node budget a value read through `doc.value` is checked
61
+ * against before it is handed to Lua, exactly as `net.fetch_json` and
62
+ * `cache.get` check theirs. A stored value has already passed this same
63
+ * budget on its way OUT of the script that produced it, so this is a
64
+ * second, defensive reading rather than the first one.
65
+ */
66
+ marshalLimits?: MarshalLimits;
67
+ }
68
+ /**
69
+ * Non-spoofable record of the LAST `doc` read this run refused. A plain JS
70
+ * closure, exactly like `./capabilities`' `CapabilityDenials`: no Lua
71
+ * value, no metatable, nothing a script can read or write. `./sandbox`
72
+ * consults it (after the capability handle, which outranks it) to report
73
+ * the clean sentence rather than whatever came back through Lua.
74
+ */
75
+ export interface DocRejections {
76
+ last(): string | undefined;
77
+ }
78
+ /**
79
+ * Builds the raw host functions and the trusted Lua prelude defining the
80
+ * `doc` table. Shaped like `./capabilities`' `buildCapabilities` (raw flat
81
+ * globals captured into prelude locals, then nil'd out) so the two follow
82
+ * one pattern; see that function's doc comment for why the ergonomic
83
+ * wrappers must be Lua-native tables rather than JS objects.
84
+ */
85
+ export declare function buildDoc(config: DocConfig): {
86
+ rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
87
+ preludeLua: string;
88
+ rejections: DocRejections;
89
+ };
package/dist/doc.js ADDED
@@ -0,0 +1,131 @@
1
+ import { buildJsonDecodePrelude } from './json-decode.js';
2
+ import { checkJsonWithinLimits, DEFAULT_MARSHAL_LIMITS, } from './marshal.js';
3
+ /**
4
+ * Builds the raw host functions and the trusted Lua prelude defining the
5
+ * `doc` table. Shaped like `./capabilities`' `buildCapabilities` (raw flat
6
+ * globals captured into prelude locals, then nil'd out) so the two follow
7
+ * one pattern; see that function's doc comment for why the ergonomic
8
+ * wrappers must be Lua-native tables rather than JS objects.
9
+ */
10
+ export function buildDoc(config) {
11
+ const view = config.doc;
12
+ const limits = config.marshalLimits ?? DEFAULT_MARSHAL_LIMITS;
13
+ let lastRejection;
14
+ /** Records the clean sentence, THEN throws it — same order every denial site in `./capabilities` uses. */
15
+ function reject(message) {
16
+ lastRejection = message;
17
+ return new Error(message);
18
+ }
19
+ let listingJson;
20
+ const rawGlobals = {};
21
+ rawGlobals.__smd_doc_listing_raw = (async () => {
22
+ if (listingJson === undefined) {
23
+ // The listing is already capped, sanitized plain data
24
+ // (`@markii/runtime`'s `buildDirectiveListing`), so this cannot
25
+ // throw on a cycle or a non-serializable member. The `?? '[]'` is
26
+ // the belt for a host that supplied something stranger than a
27
+ // listing: an empty list is always a truthful answer, an exception
28
+ // never is.
29
+ try {
30
+ listingJson = JSON.stringify(view?.directives.directives ?? []) ?? '[]';
31
+ }
32
+ catch {
33
+ listingJson = '[]';
34
+ }
35
+ }
36
+ return listingJson;
37
+ });
38
+ rawGlobals.__smd_doc_value_raw = (async (name) => {
39
+ if (!view)
40
+ return undefined;
41
+ const read = view.value(typeof name === 'string' ? name : '');
42
+ if (!read.ok) {
43
+ // `@markii/runtime` owns this sentence; this module only carries it.
44
+ throw reject(read.message);
45
+ }
46
+ const value = read.value;
47
+ if (value === undefined || value === null)
48
+ return undefined;
49
+ const budget = checkJsonWithinLimits(value, limits);
50
+ if (!budget.ok) {
51
+ throw reject(`doc.value("${name}") ${budget.message}`);
52
+ }
53
+ try {
54
+ const encoded = JSON.stringify(value);
55
+ return encoded === undefined ? undefined : encoded;
56
+ }
57
+ catch {
58
+ throw reject(`doc.value("${name}") could not be read as a value`);
59
+ }
60
+ });
61
+ const truncated = view?.directives.truncated === true;
62
+ // `buildJsonDecodePrelude` is emitted here rather than assumed: this
63
+ // module is exercised standalone in its own tests, and `./capabilities`
64
+ // only emits it when `net`/`cache` are wired. Emitting it twice in one
65
+ // run simply redefines the same idempotent trusted function, which is
66
+ // the same reasoning `./capabilities`' own `ensureMarshalPrelude` uses.
67
+ //
68
+ // It defines `__smd_json_decode` as a GLOBAL, though, and `doc` is wired
69
+ // for every run, so emitting it unconditionally would leave that private
70
+ // name reachable from user code in runs that never wire `net`/`cache` --
71
+ // exactly the residue `require-pass3.probe.test.ts` fails the suite for.
72
+ // So the previous value is saved before and put back after: this module
73
+ // pins its own copy in a local and leaves the globals table exactly as
74
+ // it found it. Every other consumer pins its own copy at ITS prelude
75
+ // time too (`./capabilities`), so restoring a nil here can never take a
76
+ // decoder away from anyone.
77
+ const preludeLua = `local __smd_doc_prev_decode = __smd_json_decode
78
+ ${buildJsonDecodePrelude(limits)}
79
+ local __smd_doc_listing = __smd_doc_listing_raw
80
+ local __smd_doc_value = __smd_doc_value_raw
81
+ -- Every primitive this table needs is pinned HERE, at prelude-definition
82
+ -- time, never resolved as a global inside the wrappers -- the same
83
+ -- discipline \`./json-decode\` and \`./capabilities\` follow (adversarial
84
+ -- findings A1/A2). Without it, a script could rebind \`__smd_json_decode\`
85
+ -- or \`type\` before calling \`doc.directives\` and change what its own
86
+ -- guards do.
87
+ local __smd_doc_decode = __smd_json_decode
88
+ local __smd_doc_type, __smd_doc_error = type, error
89
+ __smd_json_decode = __smd_doc_prev_decode
90
+ __smd_doc_listing_raw = nil
91
+ __smd_doc_value_raw = nil
92
+
93
+ doc = {}
94
+ doc.truncated = ${truncated ? 'true' : 'false'}
95
+
96
+ doc.directives = function(filter)
97
+ local wanted = nil
98
+ if filter ~= nil then
99
+ if __smd_doc_type(filter) ~= "table" then
100
+ __smd_doc_error("doc.directives(filter): filter must be a table")
101
+ end
102
+ wanted = filter.name
103
+ if wanted ~= nil and __smd_doc_type(wanted) ~= "string" then
104
+ __smd_doc_error("doc.directives(filter): filter.name must be a string")
105
+ end
106
+ end
107
+ local entries = __smd_doc_decode(__smd_doc_listing():await())
108
+ if wanted == nil then return entries end
109
+ local out = {}
110
+ local n = 0
111
+ for i = 1, #entries do
112
+ local entry = entries[i]
113
+ if entry.name == wanted then
114
+ n = n + 1
115
+ out[n] = entry
116
+ end
117
+ end
118
+ return out
119
+ end
120
+
121
+ doc.value = function(name)
122
+ if __smd_doc_type(name) ~= "string" then
123
+ __smd_doc_error("doc.value(name): name must be a string")
124
+ end
125
+ local text = __smd_doc_value(name):await()
126
+ if text == nil then return nil end
127
+ return __smd_doc_decode(text)
128
+ end
129
+ `;
130
+ return { rawGlobals, preludeLua, rejections: { last: () => lastRejection } };
131
+ }
@@ -16,13 +16,16 @@ import { type RunScriptOptions } from './sandbox.js';
16
16
  * cycle.
17
17
  */
18
18
  /**
19
- * Everything `runScript` accepts except `code`/`tier` — those two are
20
- * supplied per call by `runDocumentScripts` (`@markii/runtime`); everything
19
+ * Everything `runScript` accepts except `code`/`tier`/`doc` — those three
20
+ * are supplied per call by `runDocumentScripts` (`@markii/runtime`): the
21
+ * first two per the `ScriptExecutor` contract, and `doc` because a note
22
+ * view is per-SCRIPT (it says which values are already available), so it
23
+ * can never be captured once at construction time; everything
21
24
  * else (net/netGrants/cache/bundle/maxFetchBytes/limits/marshalLimits) is
22
25
  * this note's fixed capability/resource configuration, closed over once at
23
26
  * executor-construction time.
24
27
  */
25
- export type LuaExecutorConfig = Omit<RunScriptOptions, 'code' | 'tier'>;
28
+ export type LuaExecutorConfig = Omit<RunScriptOptions, 'code' | 'tier' | 'doc'>;
26
29
  /**
27
30
  * Builds a `ScriptExecutor` (`@markii/runtime`) backed by this package's
28
31
  * `runScript`. `config` is captured once and reused for every script the
package/dist/executor.js CHANGED
@@ -42,8 +42,13 @@ function toRuntimeFailureKind(failure) {
42
42
  * the stored error).
43
43
  */
44
44
  export function createLuaExecutor(config = {}) {
45
- return async ({ code, tier }) => {
46
- const result = await runScript({ ...config, code, tier });
45
+ return async ({ code, tier, doc }) => {
46
+ const result = await runScript({
47
+ ...config,
48
+ code,
49
+ tier,
50
+ ...(doc ? { doc } : {}),
51
+ });
47
52
  if (result.ok) {
48
53
  return { ok: true, value: result.value };
49
54
  }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ 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
8
  export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
9
+ export type { DocConfig } from './doc.js';
10
+ export { buildDoc } from './doc.js';
9
11
  export type { MarshalLimits } from './marshal.js';
10
12
  export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
11
13
  export type { PackModuleResolver, RequireConfig } from './require.js';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export { CAPABILITY_ERROR_TAG, FETCH_DECODE_ERROR_TAG, MARSHAL_ERROR_TAG, Script
7
7
  export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
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
+ export { buildDoc } from './doc.js';
10
11
  export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
11
12
  export { buildRequire } from './require.js';
12
13
  export { runScript } from './sandbox.js';
package/dist/sandbox.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ScriptView } from '@markii/bundle';
2
+ import type { DocView } from '@markii/runtime';
2
3
  import { type CacheProvider, type CapabilityTier, type NetGrants, type NetProvider } from './capabilities.js';
3
4
  import type { ScriptFailure } from './errors.js';
4
5
  import { type ScriptLimits } from './limits.js';
@@ -13,6 +14,14 @@ export interface RunScriptOptions {
13
14
  cache?: CacheProvider;
14
15
  /** Bundle-scoped filesystem (spec §11), already capability-restricted — see `@markii/bundle`'s `createScriptView`. Also backs bundle-local `require "scripts/..."` (`./require`) — the SAME `ScriptView`, so a module require goes through the identical path-jail and read-permission check as `bundle.read`. */
15
16
  bundle?: ScriptView;
17
+ /**
18
+ * This script's read-only view of the note it runs in (GitHub issue #33,
19
+ * `./doc`), supplied per script by `@markii/runtime`'s
20
+ * `runDocumentScripts`. Omitted (a standalone `runScript` call), the
21
+ * `doc` table is still defined, with an empty listing and a `value` that
22
+ * answers nil: a script never meets a nil `doc`.
23
+ */
24
+ doc?: DocView;
16
25
  /** Optional pack-module `require` seam (`./require`'s `PackModuleResolver`) — resolves `require "packName/modulePath"`. Omitted (the default: no host wires packs yet), every pack-namespaced `require` fails as a clean capability denial, never a crash. */
17
26
  packModuleResolver?: PackModuleResolver;
18
27
  maxFetchBytes?: number;
package/dist/sandbox.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LuaReturn } from 'wasmoon';
2
2
  import { buildCapabilities, } from './capabilities.js';
3
+ import { buildDoc } from './doc.js';
3
4
  import { MARSHAL_ERROR_TAG, ScriptLimitError } from './errors.js';
4
5
  import { createEmptyLuaEngine } from './globals.js';
5
6
  import { DEFAULT_LIMITS, installLimits } from './limits.js';
@@ -260,6 +261,19 @@ export async function runScript(options) {
260
261
  throw new Error(`sandbox assembly left a code-loading primitive reachable (${String(loadResidue)}); refusing to run`);
261
262
  }
262
263
  await engine.doString(buildMarshalPrelude(marshalLimits));
264
+ // ./doc: the note-scoped read-only view (GitHub issue #33). Wired
265
+ // AFTER the marshal prelude and, like `require`, wired unconditionally
266
+ // — with no `options.doc` it is an empty listing, never an absent
267
+ // global. It records nothing on `denials`: nothing here is a
268
+ // capability, so nothing here may classify a run as capability-denied.
269
+ const docBuild = buildDoc({
270
+ ...(options.doc ? { doc: options.doc } : {}),
271
+ marshalLimits,
272
+ });
273
+ for (const [name, fn] of Object.entries(docBuild.rawGlobals)) {
274
+ engine.global.set(name, fn);
275
+ }
276
+ await engine.doString(docBuild.preludeLua);
263
277
  thread = engine.global.newThread();
264
278
  threadStackIndex = engine.global.getTop();
265
279
  limitHandle = installLimits(thread, limits);
@@ -386,6 +400,21 @@ export async function runScript(options) {
386
400
  },
387
401
  };
388
402
  }
403
+ // A refused `doc` read (`./doc`, GitHub issue #33), recorded the
404
+ // same out-of-band way and checked AFTER the capability handle,
405
+ // which outranks it: a genuine denial is the more important thing to
406
+ // tell the user about. This is NOT a capability failure, so it stays
407
+ // a `'runtime'` kind (`'script-error'` once `./executor` maps it);
408
+ // the handle exists only so the reported MESSAGE is the clean
409
+ // sentence rather than the Lua-wrapped one, which arrives prefixed
410
+ // with "Error: " and trailing a stack traceback. It carries the same
411
+ // accepted edge as the denial handle above: a script that provokes a
412
+ // refusal, swallows it with its own `pcall`, and then fails for an
413
+ // unrelated reason is still reported against the refusal.
414
+ const rejection = docBuild.rejections.last();
415
+ if (rejection) {
416
+ return { ok: false, error: { kind: 'runtime', message: rejection } };
417
+ }
389
418
  return { ok: false, error: classifyRuntimeError(runResult.err) };
390
419
  }
391
420
  const finalized = finalizeMarshaledValue(runResult.value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/lua",
3
- "version": "0.10.0",
3
+ "version": "0.11.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.10.0",
48
- "@markii/runtime": "0.10.0",
47
+ "@markii/bundle": "0.11.0",
48
+ "@markii/runtime": "0.11.0",
49
49
  "wasmoon": "^1.16.0"
50
50
  }
51
51
  }