@markii/lua 0.1.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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/capabilities.d.ts +124 -0
- package/dist/capabilities.js +281 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.js +43 -0
- package/dist/executor.d.ts +39 -0
- package/dist/executor.js +26 -0
- package/dist/globals.d.ts +32 -0
- package/dist/globals.js +284 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +13 -0
- package/dist/limits.d.ts +146 -0
- package/dist/limits.js +172 -0
- package/dist/marshal.d.ts +126 -0
- package/dist/marshal.js +260 -0
- package/dist/require.d.ts +43 -0
- package/dist/require.js +49 -0
- package/dist/sandbox.d.ts +71 -0
- package/dist/sandbox.js +336 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sadigaxund
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @markii/lua
|
|
2
|
+
|
|
3
|
+
A sandboxed Lua 5.4 (via `wasmoon`) execution runtime for [Mark](https://github.com/sadigaxund/markii)'s document scripting: an empty-environment global whitelist, capability-gated net/cache/bundle access, instruction-count and wall-clock/memory limits, and depth/size-capped Lua↔JS value marshaling. No React, no markdown parsing.
|
|
4
|
+
|
|
5
|
+
See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ScriptView } from '@markii/bundle';
|
|
2
|
+
/** A GET/POST/PATCH result handed back to Lua as `{status=..., body=...}`. */
|
|
3
|
+
export interface NetResponse {
|
|
4
|
+
status: number;
|
|
5
|
+
body: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Host-provided network primitive. The runtime never imports a global
|
|
9
|
+
* `fetch` or reaches the network on its own — this is injected by the
|
|
10
|
+
* host, which is where SSRF/allowlist policy actually lives (spec §10).
|
|
11
|
+
* `net.fetch_json`/`net.post`/`net.patch` below are a thin, capability- and
|
|
12
|
+
* size-checked Lua-facing wrapper around whatever `provider` does.
|
|
13
|
+
*/
|
|
14
|
+
export interface NetProvider {
|
|
15
|
+
get(url: string): Promise<NetResponse>;
|
|
16
|
+
post?(url: string, body: string): Promise<NetResponse>;
|
|
17
|
+
patch?(url: string, body: string): Promise<NetResponse>;
|
|
18
|
+
}
|
|
19
|
+
/** One cached entry: the stored value plus when it was stored, for TTL comparison. */
|
|
20
|
+
export interface CacheEntry {
|
|
21
|
+
value: unknown;
|
|
22
|
+
storedAtMs: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Host-provided cache primitive backing `cache.get(key, ttl, fn)`. Real
|
|
26
|
+
* persistence (bundle `cache/`, IndexedDB, whatever the host uses) is the
|
|
27
|
+
* host's concern; this package only defines the read-if-fresh-else-run-fn
|
|
28
|
+
* contract.
|
|
29
|
+
*/
|
|
30
|
+
export interface CacheProvider {
|
|
31
|
+
get(key: string): Promise<CacheEntry | undefined>;
|
|
32
|
+
set(key: string, entry: CacheEntry): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** Spec §8's two-tier gate. `'manual'` = explicit run/run-all click, all grants apply. `'auto'` = on-open or scheduled, read-only regardless of what was granted. */
|
|
35
|
+
export type CapabilityTier = 'manual' | 'auto';
|
|
36
|
+
/**
|
|
37
|
+
* Hostnames this run may reach, ALREADY intersected by the caller (manifest
|
|
38
|
+
* ∩ user grant — same pattern as `@markii/bundle`'s `createScriptView`, DEFECT
|
|
39
|
+
* 10). This module does not re-derive grants from a manifest; it trusts
|
|
40
|
+
* `get`/`post` as the final, effective allowlist for this one run.
|
|
41
|
+
*/
|
|
42
|
+
export interface NetGrants {
|
|
43
|
+
get: readonly string[];
|
|
44
|
+
post: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
export interface CapabilityConfig {
|
|
47
|
+
tier: CapabilityTier;
|
|
48
|
+
net?: NetProvider;
|
|
49
|
+
netGrants?: NetGrants;
|
|
50
|
+
cache?: CacheProvider;
|
|
51
|
+
/** Bundle-scoped filesystem view (spec §11) — already capability-restricted by `@markii/bundle`'s `createScriptView`; this module delegates to it, never re-implements the path-jail or write policy. */
|
|
52
|
+
bundle?: ScriptView;
|
|
53
|
+
maxFetchBytes?: number;
|
|
54
|
+
}
|
|
55
|
+
export declare const DEFAULT_MAX_FETCH_BYTES = 2000000;
|
|
56
|
+
/**
|
|
57
|
+
* `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
|
|
58
|
+
* per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
|
|
59
|
+
* arrays, but wasmoon's own JS<->Lua marshaling for strings does NOT
|
|
60
|
+
* preserve embedded NUL (0x00) bytes end-to-end — verified empirically
|
|
61
|
+
* (wasmoon 1.16.0): a JS string or Lua `string.char(...)` value containing
|
|
62
|
+
* a `\0` is truncated at the first NUL by the time it crosses the
|
|
63
|
+
* boundary, in BOTH directions (`global.set`, and a Lua value passed as an
|
|
64
|
+
* argument to a host function). This is a real, currently-unclosed gap for
|
|
65
|
+
* binary asset data containing NUL bytes (some binary formats do; JSON
|
|
66
|
+
* cache payloads and Lua source — the two documented `bundle.*` use cases
|
|
67
|
+
* per spec §9/§11 — do not). Documented here rather than silently
|
|
68
|
+
* "handled": `bundle.read`/`bundle.write` should be treated as reliable
|
|
69
|
+
* for text/JSON payloads and NOT YET reliable for arbitrary binary
|
|
70
|
+
* containing NUL bytes. See the adversarial test asserting this exact
|
|
71
|
+
* (current, imperfect) behavior so a future wasmoon upgrade that fixes it
|
|
72
|
+
* is a visible, reviewed diff rather than a silent behavior change.
|
|
73
|
+
*/
|
|
74
|
+
export declare function bytesToLuaString(bytes: Uint8Array): string;
|
|
75
|
+
export declare function luaStringToBytes(s: string): Uint8Array;
|
|
76
|
+
/**
|
|
77
|
+
* Builds the raw, host-facing async functions to inject as flat globals,
|
|
78
|
+
* 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)`,
|
|
80
|
+
* `cache.get(key, ttl, fn)`, `bundle.read/write/exists(path)`).
|
|
81
|
+
*
|
|
82
|
+
* ## Why "raw flat globals + a Lua prelude" instead of `global.set('net', {...})`
|
|
83
|
+
*
|
|
84
|
+
* The natural-looking approach — `engine.global.set('net', { fetch_json:
|
|
85
|
+
* async (url) => ... })` — silently breaks async/await for any capability
|
|
86
|
+
* whose Lua-facing wrapper needs to be REPLACED with a Lua closure later
|
|
87
|
+
* (which `cache.get` and every `:await()`-wrapping function here need to
|
|
88
|
+
* be, since raw host functions return promises that must be explicitly
|
|
89
|
+
* awaited — see below). With wasmoon's default `enableProxy: true`, a
|
|
90
|
+
* plain JS object passed to `global.set` becomes a live PROXY table: Lua
|
|
91
|
+
* writes to it round-trip back through JS, and reading a Lua-defined
|
|
92
|
+
* function back OUT of that proxy re-wraps it as a synchronous JS-callable
|
|
93
|
+
* bridge (`lua_pcallk`), which cannot yield. Concretely: assigning
|
|
94
|
+
* `cache.get = function(...) ... end` onto a proxied table and then
|
|
95
|
+
* calling `cache.get(...)` from a script ends up invoking that Lua
|
|
96
|
+
* function through the SYNCHRONOUS bridge, and if it (or anything it
|
|
97
|
+
* calls) tries to `:await()` a promise, Lua raises "attempt to yield
|
|
98
|
+
* across a C-call boundary" — verified empirically. Using genuine,
|
|
99
|
+
* Lua-native tables (built with `{}` inside the prelude, never JS-backed)
|
|
100
|
+
* avoids this entirely: every read/write of `net`/`cache`/`bundle` after
|
|
101
|
+
* setup is a normal Lua table operation, no JS round trip involved.
|
|
102
|
+
*
|
|
103
|
+
* ## Why raw calls need `:await()` at all
|
|
104
|
+
*
|
|
105
|
+
* A JS async function called from Lua does NOT automatically suspend the
|
|
106
|
+
* calling coroutine — wasmoon marshals its Promise into Lua as a
|
|
107
|
+
* `js_promise` userdata with `:await()`/`:next()`/`:catch()` methods; the
|
|
108
|
+
* CALLER must explicitly invoke `:await()` to get the resolved value
|
|
109
|
+
* (verified empirically: without it, a script sees the raw promise
|
|
110
|
+
* userdata, not the awaited result). Since `DESIGN.md`'s example script
|
|
111
|
+
* (`local repo = net.fetch_json(url)`) is written as if this were
|
|
112
|
+
* synchronous, the awaiting is done for the author, once, HERE — inside
|
|
113
|
+
* the prelude's Lua wrapper — never exposed to the untrusted script.
|
|
114
|
+
*
|
|
115
|
+
* Each raw handle is captured into a `local` inside the prelude and the
|
|
116
|
+
* matching global is set to `nil` immediately after, so it is not
|
|
117
|
+
* reachable as a global by the untrusted script that runs afterward (only
|
|
118
|
+
* the ergonomic wrapper closures, which close over the local, remain
|
|
119
|
+
* callable).
|
|
120
|
+
*/
|
|
121
|
+
export declare function buildCapabilities(config: CapabilityConfig): {
|
|
122
|
+
rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
|
|
123
|
+
preludeLua: string;
|
|
124
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { CAPABILITY_ERROR_TAG } from './errors.js';
|
|
2
|
+
export const DEFAULT_MAX_FETCH_BYTES = 2_000_000;
|
|
3
|
+
function capabilityError(message) {
|
|
4
|
+
return new Error(`${CAPABILITY_ERROR_TAG}: ${message}`);
|
|
5
|
+
}
|
|
6
|
+
function describeThrown(err) {
|
|
7
|
+
return err instanceof Error ? err.message : String(err);
|
|
8
|
+
}
|
|
9
|
+
/** Bare hostname from a URL string, or `undefined` if the URL doesn't parse. */
|
|
10
|
+
function hostnameOf(url) {
|
|
11
|
+
try {
|
|
12
|
+
return new URL(url).hostname.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
|
|
20
|
+
* per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
|
|
21
|
+
* arrays, but wasmoon's own JS<->Lua marshaling for strings does NOT
|
|
22
|
+
* preserve embedded NUL (0x00) bytes end-to-end — verified empirically
|
|
23
|
+
* (wasmoon 1.16.0): a JS string or Lua `string.char(...)` value containing
|
|
24
|
+
* a `\0` is truncated at the first NUL by the time it crosses the
|
|
25
|
+
* boundary, in BOTH directions (`global.set`, and a Lua value passed as an
|
|
26
|
+
* argument to a host function). This is a real, currently-unclosed gap for
|
|
27
|
+
* binary asset data containing NUL bytes (some binary formats do; JSON
|
|
28
|
+
* cache payloads and Lua source — the two documented `bundle.*` use cases
|
|
29
|
+
* per spec §9/§11 — do not). Documented here rather than silently
|
|
30
|
+
* "handled": `bundle.read`/`bundle.write` should be treated as reliable
|
|
31
|
+
* for text/JSON payloads and NOT YET reliable for arbitrary binary
|
|
32
|
+
* containing NUL bytes. See the adversarial test asserting this exact
|
|
33
|
+
* (current, imperfect) behavior so a future wasmoon upgrade that fixes it
|
|
34
|
+
* is a visible, reviewed diff rather than a silent behavior change.
|
|
35
|
+
*/
|
|
36
|
+
export function bytesToLuaString(bytes) {
|
|
37
|
+
let out = '';
|
|
38
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
39
|
+
out += String.fromCharCode(bytes[i] ?? 0);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
export function luaStringToBytes(s) {
|
|
44
|
+
const out = new Uint8Array(s.length);
|
|
45
|
+
for (let i = 0; i < s.length; i++) {
|
|
46
|
+
out[i] = s.charCodeAt(i) & 0xff;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Builds the raw, host-facing async functions to inject as flat globals,
|
|
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)`,
|
|
54
|
+
* `cache.get(key, ttl, fn)`, `bundle.read/write/exists(path)`).
|
|
55
|
+
*
|
|
56
|
+
* ## Why "raw flat globals + a Lua prelude" instead of `global.set('net', {...})`
|
|
57
|
+
*
|
|
58
|
+
* The natural-looking approach — `engine.global.set('net', { fetch_json:
|
|
59
|
+
* async (url) => ... })` — silently breaks async/await for any capability
|
|
60
|
+
* whose Lua-facing wrapper needs to be REPLACED with a Lua closure later
|
|
61
|
+
* (which `cache.get` and every `:await()`-wrapping function here need to
|
|
62
|
+
* be, since raw host functions return promises that must be explicitly
|
|
63
|
+
* awaited — see below). With wasmoon's default `enableProxy: true`, a
|
|
64
|
+
* plain JS object passed to `global.set` becomes a live PROXY table: Lua
|
|
65
|
+
* writes to it round-trip back through JS, and reading a Lua-defined
|
|
66
|
+
* function back OUT of that proxy re-wraps it as a synchronous JS-callable
|
|
67
|
+
* bridge (`lua_pcallk`), which cannot yield. Concretely: assigning
|
|
68
|
+
* `cache.get = function(...) ... end` onto a proxied table and then
|
|
69
|
+
* calling `cache.get(...)` from a script ends up invoking that Lua
|
|
70
|
+
* function through the SYNCHRONOUS bridge, and if it (or anything it
|
|
71
|
+
* calls) tries to `:await()` a promise, Lua raises "attempt to yield
|
|
72
|
+
* across a C-call boundary" — verified empirically. Using genuine,
|
|
73
|
+
* Lua-native tables (built with `{}` inside the prelude, never JS-backed)
|
|
74
|
+
* avoids this entirely: every read/write of `net`/`cache`/`bundle` after
|
|
75
|
+
* setup is a normal Lua table operation, no JS round trip involved.
|
|
76
|
+
*
|
|
77
|
+
* ## Why raw calls need `:await()` at all
|
|
78
|
+
*
|
|
79
|
+
* A JS async function called from Lua does NOT automatically suspend the
|
|
80
|
+
* calling coroutine — wasmoon marshals its Promise into Lua as a
|
|
81
|
+
* `js_promise` userdata with `:await()`/`:next()`/`:catch()` methods; the
|
|
82
|
+
* CALLER must explicitly invoke `:await()` to get the resolved value
|
|
83
|
+
* (verified empirically: without it, a script sees the raw promise
|
|
84
|
+
* userdata, not the awaited result). Since `DESIGN.md`'s example script
|
|
85
|
+
* (`local repo = net.fetch_json(url)`) is written as if this were
|
|
86
|
+
* synchronous, the awaiting is done for the author, once, HERE — inside
|
|
87
|
+
* the prelude's Lua wrapper — never exposed to the untrusted script.
|
|
88
|
+
*
|
|
89
|
+
* Each raw handle is captured into a `local` inside the prelude and the
|
|
90
|
+
* matching global is set to `nil` immediately after, so it is not
|
|
91
|
+
* reachable as a global by the untrusted script that runs afterward (only
|
|
92
|
+
* the ergonomic wrapper closures, which close over the local, remain
|
|
93
|
+
* callable).
|
|
94
|
+
*/
|
|
95
|
+
export function buildCapabilities(config) {
|
|
96
|
+
const maxFetchBytes = config.maxFetchBytes ?? DEFAULT_MAX_FETCH_BYTES;
|
|
97
|
+
const rawGlobals = {};
|
|
98
|
+
const preludeParts = [];
|
|
99
|
+
// --- net --------------------------------------------------------------
|
|
100
|
+
// `fetch_json` and `post`/`patch` are gated INDEPENDENTLY of each other
|
|
101
|
+
// (a manifest can grant POST to a host without granting it GET, or vice
|
|
102
|
+
// versa), so the `net` table and each method are wired up separately
|
|
103
|
+
// rather than behind one combined condition — an earlier version of
|
|
104
|
+
// this function nested POST/PATCH wiring inside "if GET is granted",
|
|
105
|
+
// which silently produced no `net.post` at all for a POST-only grant.
|
|
106
|
+
const netGrants = config.netGrants ?? { get: [], post: [] };
|
|
107
|
+
const netTableNeeded = config.net !== undefined &&
|
|
108
|
+
(netGrants.get.length > 0 ||
|
|
109
|
+
(config.tier === 'manual' && netGrants.post.length > 0));
|
|
110
|
+
if (netTableNeeded) {
|
|
111
|
+
preludeParts.push('net = net or {}\n');
|
|
112
|
+
}
|
|
113
|
+
if (config.net && netGrants.get.length > 0) {
|
|
114
|
+
rawGlobals.__smd_net_get_raw = (async (url) => {
|
|
115
|
+
const host = hostnameOf(url);
|
|
116
|
+
if (!host || !netGrants.get.includes(host)) {
|
|
117
|
+
throw capabilityError(`net access to host "${host ?? url}" not granted for GET`);
|
|
118
|
+
}
|
|
119
|
+
const res = await config.net.get(url);
|
|
120
|
+
if (res.body.length > maxFetchBytes) {
|
|
121
|
+
throw capabilityError(`fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`);
|
|
122
|
+
}
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(res.body);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw capabilityError(`fetch response for "${url}" was not valid JSON`);
|
|
129
|
+
}
|
|
130
|
+
return parsed;
|
|
131
|
+
});
|
|
132
|
+
preludeParts.push(`
|
|
133
|
+
local __smd_net_get = __smd_net_get_raw
|
|
134
|
+
__smd_net_get_raw = nil
|
|
135
|
+
net.fetch_json = function(url) return __smd_net_get(url):await() end
|
|
136
|
+
`);
|
|
137
|
+
}
|
|
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
|
|
142
|
+
// effectful call under an auto trigger fails cleanly").
|
|
143
|
+
if (config.tier === 'manual' &&
|
|
144
|
+
config.net?.post &&
|
|
145
|
+
netGrants.post.length > 0) {
|
|
146
|
+
rawGlobals.__smd_net_post_raw = (async (url, body) => {
|
|
147
|
+
const host = hostnameOf(url);
|
|
148
|
+
if (!host || !netGrants.post.includes(host)) {
|
|
149
|
+
throw capabilityError(`net access to host "${host ?? url}" not granted for POST`);
|
|
150
|
+
}
|
|
151
|
+
return config.net.post(url, body);
|
|
152
|
+
});
|
|
153
|
+
preludeParts.push(`
|
|
154
|
+
local __smd_net_post = __smd_net_post_raw
|
|
155
|
+
__smd_net_post_raw = nil
|
|
156
|
+
net.post = function(url, body) return __smd_net_post(url, body):await() end
|
|
157
|
+
`);
|
|
158
|
+
}
|
|
159
|
+
if (config.tier === 'manual' &&
|
|
160
|
+
config.net?.patch &&
|
|
161
|
+
netGrants.post.length > 0) {
|
|
162
|
+
rawGlobals.__smd_net_patch_raw = (async (url, body) => {
|
|
163
|
+
const host = hostnameOf(url);
|
|
164
|
+
if (!host || !netGrants.post.includes(host)) {
|
|
165
|
+
throw capabilityError(`net access to host "${host ?? url}" not granted for PATCH`);
|
|
166
|
+
}
|
|
167
|
+
return config.net.patch(url, body);
|
|
168
|
+
});
|
|
169
|
+
preludeParts.push(`
|
|
170
|
+
local __smd_net_patch = __smd_net_patch_raw
|
|
171
|
+
__smd_net_patch_raw = nil
|
|
172
|
+
net.patch = function(url, body) return __smd_net_patch(url, body):await() end
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
// --- cache --------------------------------------------------------------
|
|
176
|
+
// cache.get is implemented ENTIRELY IN LUA (see the prelude below),
|
|
177
|
+
// calling the script-provided `fn` as a normal Lua-to-Lua call. This is
|
|
178
|
+
// deliberate, not just tidy: `fn` may itself call `net.fetch_json`
|
|
179
|
+
// (which needs to `:await()`), and a Lua function invoked FROM JS
|
|
180
|
+
// (rather than from Lua) goes through the same non-yieldable
|
|
181
|
+
// `lua_pcallk` bridge described above — so `cache.get`'s JS side only
|
|
182
|
+
// ever exposes plain read/write primitives (`__smd_cache_get_raw`,
|
|
183
|
+
// `__smd_cache_set_raw`); the read-if-fresh-else-run-fn CONTROL FLOW is
|
|
184
|
+
// Lua calling Lua, never JS calling Lua.
|
|
185
|
+
if (config.cache) {
|
|
186
|
+
rawGlobals.__smd_cache_get_raw = (async (key) => config.cache.get(key));
|
|
187
|
+
rawGlobals.__smd_cache_set_raw = (async (key, value, storedAtMs) => {
|
|
188
|
+
await config.cache.set(key, { value, storedAtMs });
|
|
189
|
+
return true;
|
|
190
|
+
});
|
|
191
|
+
// `now` (for TTL freshness) is computed in JS, once per cache.get
|
|
192
|
+
// call, and handed to Lua as a plain number argument — there is no
|
|
193
|
+
// `os.time()` in this sandbox (§10: no `os` library at all), so the
|
|
194
|
+
// clock is a host-provided value, not a Lua-reachable ambient
|
|
195
|
+
// capability.
|
|
196
|
+
rawGlobals.__smd_now_ms_raw = (async () => Date.now());
|
|
197
|
+
preludeParts.push(`
|
|
198
|
+
local __smd_cache_get = __smd_cache_get_raw
|
|
199
|
+
local __smd_cache_set = __smd_cache_set_raw
|
|
200
|
+
local __smd_now_ms = __smd_now_ms_raw
|
|
201
|
+
__smd_cache_get_raw = nil
|
|
202
|
+
__smd_cache_set_raw = nil
|
|
203
|
+
__smd_now_ms_raw = nil
|
|
204
|
+
cache = cache or {}
|
|
205
|
+
cache.get = function(key, ttl, fn)
|
|
206
|
+
local existing = __smd_cache_get(key):await()
|
|
207
|
+
if existing ~= nil then
|
|
208
|
+
local now = __smd_now_ms():await()
|
|
209
|
+
if (now - existing.storedAtMs) < (ttl * 1000) then
|
|
210
|
+
return existing.value
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
local value = fn()
|
|
214
|
+
__smd_cache_set(key, value, __smd_now_ms():await()):await()
|
|
215
|
+
return value
|
|
216
|
+
end
|
|
217
|
+
`);
|
|
218
|
+
}
|
|
219
|
+
// --- bundle -------------------------------------------------------------
|
|
220
|
+
// Delegates entirely to the injected `ScriptView` (`@markii/bundle`), which
|
|
221
|
+
// already enforces the path-jail and the read/write:cache/ split (spec
|
|
222
|
+
// §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.
|
|
225
|
+
if (config.bundle) {
|
|
226
|
+
const view = config.bundle;
|
|
227
|
+
// `ScriptView` (@markii/bundle) throws its own `ScriptCapabilityError` /
|
|
228
|
+
// `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.
|
|
234
|
+
rawGlobals.__smd_bundle_read_raw = (async (path) => {
|
|
235
|
+
let data;
|
|
236
|
+
try {
|
|
237
|
+
data = await view.read(path);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
throw capabilityError(describeThrown(err));
|
|
241
|
+
}
|
|
242
|
+
return data === undefined ? null : bytesToLuaString(data);
|
|
243
|
+
});
|
|
244
|
+
rawGlobals.__smd_bundle_exists_raw = (async (path) => {
|
|
245
|
+
try {
|
|
246
|
+
return await view.exists(path);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
throw capabilityError(describeThrown(err));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
preludeParts.push(`
|
|
253
|
+
local __smd_bundle_read = __smd_bundle_read_raw
|
|
254
|
+
local __smd_bundle_exists = __smd_bundle_exists_raw
|
|
255
|
+
__smd_bundle_read_raw = nil
|
|
256
|
+
__smd_bundle_exists_raw = nil
|
|
257
|
+
bundle = bundle or {}
|
|
258
|
+
bundle.read = function(path) return __smd_bundle_read(path):await() end
|
|
259
|
+
bundle.exists = function(path) return __smd_bundle_exists(path):await() end
|
|
260
|
+
`);
|
|
261
|
+
if (config.tier === 'manual') {
|
|
262
|
+
rawGlobals.__smd_bundle_write_raw = (async (path, data) => {
|
|
263
|
+
try {
|
|
264
|
+
await view.write(path, luaStringToBytes(data));
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
throw capabilityError(describeThrown(err));
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
});
|
|
271
|
+
preludeParts.push(`
|
|
272
|
+
local __smd_bundle_write = __smd_bundle_write_raw
|
|
273
|
+
__smd_bundle_write_raw = nil
|
|
274
|
+
bundle.write = function(path, data) return __smd_bundle_write(path, data):await() end
|
|
275
|
+
`);
|
|
276
|
+
}
|
|
277
|
+
// Under 'auto', `bundle.write` is simply never defined — spec §8's
|
|
278
|
+
// read-only tier table: "bundle/cache reads, cache writes only".
|
|
279
|
+
}
|
|
280
|
+
return { rawGlobals, preludeLua: preludeParts.join('\n') };
|
|
281
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failure taxonomy for `runScript` (see `./sandbox`). Running hostile
|
|
3
|
+
* Lua must never throw a raw/opaque exception out of this package — every
|
|
4
|
+
* failure mode a script can trigger (resource limits, capability denial,
|
|
5
|
+
* an unmarshalable return value, or an ordinary Lua runtime error) is
|
|
6
|
+
* classified into one of these kinds before it reaches the caller.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT CONSTRAINT this module works around: wasmoon does not preserve
|
|
9
|
+
* JS `Error` subclass identity across a round trip through the Lua VM. A
|
|
10
|
+
* custom `Error` thrown from a host-provided async capability function (see
|
|
11
|
+
* `./capabilities`) comes back out of `thread.run()` as a plain `Error`
|
|
12
|
+
* whose `.message` is the *stringified* original error — `instanceof`
|
|
13
|
+
* checks on the far side are useless (verified empirically against
|
|
14
|
+
* wasmoon 1.16.0: `new MyError('x')` thrown inside an injected async
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
/** Prefix tag for a capability (permission) denial raised into Lua from a host-provided function. */
|
|
25
|
+
export declare const CAPABILITY_ERROR_TAG = "MARK_CAPABILITY";
|
|
26
|
+
/** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
|
|
27
|
+
export declare const MARSHAL_ERROR_TAG = "MARK_MARSHAL";
|
|
28
|
+
/** The limits a run can breach; see `./limits`. */
|
|
29
|
+
export type ScriptLimitKind = 'instructions' | 'timeout' | 'memory';
|
|
30
|
+
/** Why a return value was rejected by the marshaller; see `./marshal`. */
|
|
31
|
+
export type ScriptMarshalReason = 'depth' | 'nodes' | 'cycle' | 'type' | 'key-type' | 'non-finite-number';
|
|
32
|
+
/**
|
|
33
|
+
* The full discriminated failure shape `runScript` returns. `kind`:
|
|
34
|
+
* - `'limit'` — a resource limit was breached (instruction count, wall
|
|
35
|
+
* clock, or memory). `limit` says which.
|
|
36
|
+
* - `'capability'` — the script attempted something its granted
|
|
37
|
+
* capabilities don't allow (ungranted host, effectful op under an
|
|
38
|
+
* auto-run tier, disallowed bundle path/write).
|
|
39
|
+
* - `'marshal'` — the script's return value could not be safely converted
|
|
40
|
+
* 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.
|
|
43
|
+
* - `'runtime'` — an ordinary Lua error (syntax error, `error()` call,
|
|
44
|
+
* type error, stack overflow, etc.) not covered by the above.
|
|
45
|
+
*/
|
|
46
|
+
export interface ScriptFailure {
|
|
47
|
+
kind: 'limit' | 'capability' | 'marshal' | 'runtime';
|
|
48
|
+
message: string;
|
|
49
|
+
limit?: ScriptLimitKind;
|
|
50
|
+
reason?: ScriptMarshalReason;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Thrown by the instruction-count/wall-clock hook installed in `./limits`
|
|
54
|
+
* when this package needs to surface a limit breach as a JS-level
|
|
55
|
+
* exception (e.g. from the outer `Promise.race` wall-clock guard in
|
|
56
|
+
* `sandbox.ts`, for the async-hang case a Lua-level hook can't observe).
|
|
57
|
+
* `runScript` always catches this itself — it is not part of the public
|
|
58
|
+
* throwing surface, only an internal signal.
|
|
59
|
+
*/
|
|
60
|
+
export declare class ScriptLimitError extends Error {
|
|
61
|
+
readonly limitKind: ScriptLimitKind;
|
|
62
|
+
constructor(limitKind: ScriptLimitKind, message: string);
|
|
63
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failure taxonomy for `runScript` (see `./sandbox`). Running hostile
|
|
3
|
+
* Lua must never throw a raw/opaque exception out of this package — every
|
|
4
|
+
* failure mode a script can trigger (resource limits, capability denial,
|
|
5
|
+
* an unmarshalable return value, or an ordinary Lua runtime error) is
|
|
6
|
+
* classified into one of these kinds before it reaches the caller.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT CONSTRAINT this module works around: wasmoon does not preserve
|
|
9
|
+
* JS `Error` subclass identity across a round trip through the Lua VM. A
|
|
10
|
+
* custom `Error` thrown from a host-provided async capability function (see
|
|
11
|
+
* `./capabilities`) comes back out of `thread.run()` as a plain `Error`
|
|
12
|
+
* whose `.message` is the *stringified* original error — `instanceof`
|
|
13
|
+
* checks on the far side are useless (verified empirically against
|
|
14
|
+
* wasmoon 1.16.0: `new MyError('x')` thrown inside an injected async
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
/** Prefix tag for a capability (permission) denial raised into Lua from a host-provided function. */
|
|
25
|
+
export const CAPABILITY_ERROR_TAG = 'MARK_CAPABILITY';
|
|
26
|
+
/** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
|
|
27
|
+
export const MARSHAL_ERROR_TAG = 'MARK_MARSHAL';
|
|
28
|
+
/**
|
|
29
|
+
* Thrown by the instruction-count/wall-clock hook installed in `./limits`
|
|
30
|
+
* when this package needs to surface a limit breach as a JS-level
|
|
31
|
+
* exception (e.g. from the outer `Promise.race` wall-clock guard in
|
|
32
|
+
* `sandbox.ts`, for the async-hang case a Lua-level hook can't observe).
|
|
33
|
+
* `runScript` always catches this itself — it is not part of the public
|
|
34
|
+
* throwing surface, only an internal signal.
|
|
35
|
+
*/
|
|
36
|
+
export class ScriptLimitError extends Error {
|
|
37
|
+
limitKind;
|
|
38
|
+
constructor(limitKind, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'ScriptLimitError';
|
|
41
|
+
this.limitKind = limitKind;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ScriptExecutor } from '@markii/runtime';
|
|
2
|
+
import { type RunScriptOptions } from './sandbox.js';
|
|
3
|
+
/**
|
|
4
|
+
* Slice 2 of the scripting-usability layer (DESIGN.md §8): the reusable
|
|
5
|
+
* adapter from this package's `runScript` to `@markii/runtime`'s
|
|
6
|
+
* language-agnostic `ScriptExecutor` shape, so `runDocumentScripts` can run
|
|
7
|
+
* a document's script blocks through the real Lua sandbox without
|
|
8
|
+
* `@markii/runtime` ever depending on `@markii/lua`, wasmoon, or any
|
|
9
|
+
* particular language runtime.
|
|
10
|
+
*
|
|
11
|
+
* Dependency direction: `@markii/lua -> @markii/runtime` (this file), TYPE
|
|
12
|
+
* ONLY (`ScriptExecutor`/`ExecuteResult` are `import type`, so nothing from
|
|
13
|
+
* `@markii/runtime` is bundled or evaluated at runtime by this package).
|
|
14
|
+
* `@markii/runtime` never imports `@markii/lua` — see `run.ts` there,
|
|
15
|
+
* which only knows about the `ScriptExecutor` function shape. There is no
|
|
16
|
+
* cycle.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Everything `runScript` accepts except `code`/`tier` — those two are
|
|
20
|
+
* supplied per call by `runDocumentScripts` (`@markii/runtime`); everything
|
|
21
|
+
* else (net/netGrants/cache/bundle/maxFetchBytes/limits/marshalLimits) is
|
|
22
|
+
* this note's fixed capability/resource configuration, closed over once at
|
|
23
|
+
* executor-construction time.
|
|
24
|
+
*/
|
|
25
|
+
export type LuaExecutorConfig = Omit<RunScriptOptions, 'code' | 'tier'>;
|
|
26
|
+
/**
|
|
27
|
+
* Builds a `ScriptExecutor` (`@markii/runtime`) backed by this package's
|
|
28
|
+
* `runScript`. `config` is captured once and reused for every script the
|
|
29
|
+
* returned executor runs; the returned function's only per-call inputs are
|
|
30
|
+
* `code` and `tier`, matching `ScriptExecutor`'s provider-agnostic
|
|
31
|
+
* signature. `runScript` never throws (see `./sandbox`'s doc comment), so
|
|
32
|
+
* this adapter doesn't need its own try/catch — it only reshapes the
|
|
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).
|
|
38
|
+
*/
|
|
39
|
+
export declare function createLuaExecutor(config?: LuaExecutorConfig): ScriptExecutor;
|
package/dist/executor.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { runScript } from './sandbox.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a `ScriptExecutor` (`@markii/runtime`) backed by this package's
|
|
4
|
+
* `runScript`. `config` is captured once and reused for every script the
|
|
5
|
+
* returned executor runs; the returned function's only per-call inputs are
|
|
6
|
+
* `code` and `tier`, matching `ScriptExecutor`'s provider-agnostic
|
|
7
|
+
* signature. `runScript` never throws (see `./sandbox`'s doc comment), so
|
|
8
|
+
* this adapter doesn't need its own try/catch — it only reshapes the
|
|
9
|
+
* 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).
|
|
14
|
+
*/
|
|
15
|
+
export function createLuaExecutor(config = {}) {
|
|
16
|
+
return async ({ code, tier }) => {
|
|
17
|
+
const result = await runScript({ ...config, code, tier });
|
|
18
|
+
if (result.ok) {
|
|
19
|
+
return { ok: true, value: result.value };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: { kind: result.error.kind, message: result.error.message },
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type LuaEngine } from 'wasmoon';
|
|
2
|
+
/** The Base-library names this sandbox intentionally keeps reachable. */
|
|
3
|
+
export declare const ALLOWED_GLOBALS: readonly string[];
|
|
4
|
+
/** Names this sandbox verifies are absent after scrubbing (see the adversarial test suite). */
|
|
5
|
+
export declare const DENIED_GLOBALS: readonly string[];
|
|
6
|
+
export interface CreateEmptyLuaEngineOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Forwarded verbatim as wasmoon's `LuaFactory` first constructor argument
|
|
9
|
+
* (`customWasmUri` — confirmed against `node_modules/wasmoon/dist/
|
|
10
|
+
* factory.d.ts`). Left `undefined` (the default), `LuaFactory` keeps its
|
|
11
|
+
* own built-in resolution: the local `node_modules/wasmoon/dist/glue.wasm`
|
|
12
|
+
* file in Node (used by this package's own Vitest suite), or — in a
|
|
13
|
+
* browser bundle with no bundler-provided URL — a fetch to
|
|
14
|
+
* `https://unpkg.com/wasmoon@<version>/dist/glue.wasm` at runtime
|
|
15
|
+
* (confirmed in `node_modules/wasmoon/dist/index.js`). That CDN fetch is
|
|
16
|
+
* exactly what makes an unconfigured browser host non-offline-capable: no
|
|
17
|
+
* network to unpkg means no script can ever run. A host that wants to
|
|
18
|
+
* avoid it (e.g. the playground, via a Vite `?url` asset import so the
|
|
19
|
+
* wasm ships in its own bundle) passes its own local URL here instead.
|
|
20
|
+
* Passing `undefined` is IDENTICAL to omitting this options object
|
|
21
|
+
* entirely — this parameter only ever narrows behavior, never changes it
|
|
22
|
+
* by default.
|
|
23
|
+
*/
|
|
24
|
+
wasmUri?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates a fresh wasmoon engine with the curated, empty environment
|
|
28
|
+
* described above. `traceAllocations: true` is required for the memory cap
|
|
29
|
+
* (`./limits` / `./sandbox` call `engine.global.setMemoryMax`) — without it
|
|
30
|
+
* wasmoon uses the plain, uncapped allocator.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createEmptyLuaEngine(options?: CreateEmptyLuaEngineOptions): Promise<LuaEngine>;
|