@markii/lua 0.6.0 → 0.7.1
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 +14 -0
- package/dist/capabilities.js +6 -1
- package/dist/globals.d.ts +10 -0
- package/dist/globals.js +26 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/require.d.ts +109 -36
- package/dist/require.js +177 -41
- package/dist/sandbox.d.ts +7 -2
- package/dist/sandbox.js +37 -2
- package/package.json +3 -3
package/dist/capabilities.d.ts
CHANGED
|
@@ -158,4 +158,18 @@ export declare function buildCapabilities(config: CapabilityConfig): {
|
|
|
158
158
|
rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
|
|
159
159
|
preludeLua: string;
|
|
160
160
|
denials: CapabilityDenials;
|
|
161
|
+
/**
|
|
162
|
+
* The same recording function `denials` reads from, exposed so a SIBLING
|
|
163
|
+
* capability builder assembled in the same `runScript` call (currently
|
|
164
|
+
* only `./require`'s `buildRequire`) can record onto this identical,
|
|
165
|
+
* per-run `CapabilityDenials` handle instead of maintaining its own —
|
|
166
|
+
* `sandbox.ts`'s classification logic only ever consults ONE such handle
|
|
167
|
+
* per run, so a require-triggered denial (an ungranted/absent bundle
|
|
168
|
+
* capability, a path-jail rejection surfaced through the same `ScriptView`
|
|
169
|
+
* `bundle.read` uses, a rejected pack-module request) must land on this
|
|
170
|
+
* one, not a second, unconsulted one. Not part of the public API surface
|
|
171
|
+
* consumers reason about (`denials.last()` remains read-only) — this is
|
|
172
|
+
* wiring between this package's own modules.
|
|
173
|
+
*/
|
|
174
|
+
recordDenial: (reason: CapabilityDenial['reason'], message: string) => void;
|
|
161
175
|
};
|
package/dist/capabilities.js
CHANGED
|
@@ -650,5 +650,10 @@ bundle.write = function(path, data) return __smd_bundle_write_blocked(path, data
|
|
|
650
650
|
`);
|
|
651
651
|
}
|
|
652
652
|
}
|
|
653
|
-
return {
|
|
653
|
+
return {
|
|
654
|
+
rawGlobals,
|
|
655
|
+
preludeLua: preludeParts.join('\n'),
|
|
656
|
+
denials,
|
|
657
|
+
recordDenial,
|
|
658
|
+
};
|
|
654
659
|
}
|
package/dist/globals.d.ts
CHANGED
|
@@ -28,5 +28,15 @@ export interface CreateEmptyLuaEngineOptions {
|
|
|
28
28
|
* described above. `traceAllocations: true` is required for the memory cap
|
|
29
29
|
* (`./limits` / `./sandbox` call `engine.global.setMemoryMax`) — without it
|
|
30
30
|
* wasmoon uses the plain, uncapped allocator.
|
|
31
|
+
*
|
|
32
|
+
* NOT A COMPLETE SANDBOX ON ITS OWN (issue #3, slice 4). `SCRUB_PRELUDE`
|
|
33
|
+
* removes every dangerous global, but it also captures the genuine `load`
|
|
34
|
+
* primitive into the private global `__smd_load_raw` so `./require`'s prelude
|
|
35
|
+
* can consume it — and leaves that global set. `runScript` closes the window:
|
|
36
|
+
* it runs `./require`'s prelude (which nils `__smd_load_raw` back out) and
|
|
37
|
+
* then asserts, fail-closed, that no code-loading primitive remains before
|
|
38
|
+
* any user code runs. Do NOT run untrusted code on an engine straight from
|
|
39
|
+
* this function without first running that prelude; use `runScript`, which is
|
|
40
|
+
* the only supported way to execute a note's script.
|
|
31
41
|
*/
|
|
32
42
|
export declare function createEmptyLuaEngine(options?: CreateEmptyLuaEngineOptions): Promise<LuaEngine>;
|
package/dist/globals.js
CHANGED
|
@@ -87,6 +87,21 @@ const LIBRARIES = [
|
|
|
87
87
|
* validation — bytecode is not sandboxed the way source is (it can
|
|
88
88
|
* encode out-of-range opcodes that crash or exploit the VM), which is
|
|
89
89
|
* also why `string.dump` (bytecode *production*) is removed below.
|
|
90
|
+
* The real `load` function object is captured into a private global,
|
|
91
|
+
* `__smd_load_raw`, ONE LINE ABOVE where the public `load` name is
|
|
92
|
+
* nil'd out (see `SCRUB_PRELUDE` below) — mirroring the "capture the
|
|
93
|
+
* genuine primitive into a local/global at definition time, before a
|
|
94
|
+
* later script can rebind or lose it" discipline already used
|
|
95
|
+
* throughout this sandbox (e.g. `./marshal`'s `error`/`type`/`pairs`
|
|
96
|
+
* capture, `./capabilities`' `__smd_json_decode` capture). `./require`
|
|
97
|
+
* consumes `__smd_load_raw` from its own prelude (run later, still
|
|
98
|
+
* before any untrusted script code) to compile a fetched module's
|
|
99
|
+
* SOURCE TEXT ONLY — always called with Lua's `mode = "t"`, which
|
|
100
|
+
* makes `load` itself refuse a binary/bytecode chunk — and then nils
|
|
101
|
+
* `__smd_load_raw` back out once captured into its own local, so the
|
|
102
|
+
* raw primitive is never reachable as a global either before or after
|
|
103
|
+
* `require` claims it. No other code in this sandbox may read
|
|
104
|
+
* `__smd_load_raw`; it is not part of the documented host API.
|
|
90
105
|
* - `collectgarbage` — its `"count"` argument is a harmless memory query
|
|
91
106
|
* but other arguments (`"stop"`, `"generational"`, `"incremental"`) let
|
|
92
107
|
* a script retune the collector as a denial-of-service knob against the
|
|
@@ -205,6 +220,7 @@ do
|
|
|
205
220
|
end
|
|
206
221
|
`;
|
|
207
222
|
const SCRUB_PRELUDE = `
|
|
223
|
+
__smd_load_raw = load
|
|
208
224
|
load = nil
|
|
209
225
|
loadstring = nil
|
|
210
226
|
loadfile = nil
|
|
@@ -269,6 +285,16 @@ export const DENIED_GLOBALS = [
|
|
|
269
285
|
* described above. `traceAllocations: true` is required for the memory cap
|
|
270
286
|
* (`./limits` / `./sandbox` call `engine.global.setMemoryMax`) — without it
|
|
271
287
|
* wasmoon uses the plain, uncapped allocator.
|
|
288
|
+
*
|
|
289
|
+
* NOT A COMPLETE SANDBOX ON ITS OWN (issue #3, slice 4). `SCRUB_PRELUDE`
|
|
290
|
+
* removes every dangerous global, but it also captures the genuine `load`
|
|
291
|
+
* primitive into the private global `__smd_load_raw` so `./require`'s prelude
|
|
292
|
+
* can consume it — and leaves that global set. `runScript` closes the window:
|
|
293
|
+
* it runs `./require`'s prelude (which nils `__smd_load_raw` back out) and
|
|
294
|
+
* then asserts, fail-closed, that no code-loading primitive remains before
|
|
295
|
+
* any user code runs. Do NOT run untrusted code on an engine straight from
|
|
296
|
+
* this function without first running that prelude; use `runScript`, which is
|
|
297
|
+
* the only supported way to execute a note's script.
|
|
272
298
|
*/
|
|
273
299
|
export async function createEmptyLuaEngine(options) {
|
|
274
300
|
const factory = new LuaFactory(options?.wasmUri);
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +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 { MarshalLimits } from './marshal.js';
|
|
10
10
|
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
|
-
export {
|
|
11
|
+
export type { PackModuleResolver, RequireConfig } from './require.js';
|
|
12
|
+
export { buildRequire } from './require.js';
|
|
12
13
|
export type { RunScriptOptions, RunScriptResult } from './sandbox.js';
|
|
13
14
|
export { runScript } from './sandbox.js';
|
|
14
15
|
export type { LuaExecutorConfig } from './executor.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,6 @@ 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 { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
|
-
export {
|
|
11
|
+
export { buildRequire } from './require.js';
|
|
12
12
|
export { runScript } from './sandbox.js';
|
|
13
13
|
export { createLuaExecutor } from './executor.js';
|
package/dist/require.d.ts
CHANGED
|
@@ -1,43 +1,116 @@
|
|
|
1
|
+
import type { ScriptView } from '@markii/bundle';
|
|
1
2
|
/**
|
|
2
|
-
* Sandboxed `require` (spec §8 "
|
|
3
|
-
*
|
|
4
|
-
* modules
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Sandboxed `require` (spec §8 "Long scripts and shared code", docs/
|
|
4
|
+
* scripting.md): exactly two of the spec's three sources are implemented
|
|
5
|
+
* here — bundle-local modules and the SEAM for pack modules (the pack
|
|
6
|
+
* loader itself, and the third source, the vault library, are later
|
|
7
|
+
* phases: packs need the pack-installation machinery from packs.md, and
|
|
8
|
+
* the vault library needs a host-side namespace-to-folder mapping neither
|
|
9
|
+
* of which exists yet). Both implemented sources, and the not-yet-wired
|
|
10
|
+
* one, share ONE property: every require target this module resolves is
|
|
11
|
+
* PURE LUA SOURCE TEXT, loaded as a fresh PROTECTED CHUNK on the SAME
|
|
12
|
+
* thread as the rest of the run, so it shares that run's globals,
|
|
13
|
+
* capabilities, and instruction/wall-clock/memory budget — a module can
|
|
14
|
+
* never grant itself more than the script that required it already had.
|
|
7
15
|
*
|
|
8
|
-
*
|
|
9
|
-
* itself) does not wire that up yet. `bundle.read` (via the injected
|
|
10
|
-
* `ScriptView`, see `./capabilities`) already gives a script everything it
|
|
11
|
-
* needs to fetch a bundle-local module's SOURCE TEXT; what a real
|
|
12
|
-
* `require` adds on top is caching per module name and running the loaded
|
|
13
|
-
* source as a new protected chunk with the same globals/capabilities —
|
|
14
|
-
* both of those depend on the pack/namespace resolution rules (§8) that
|
|
15
|
-
* belong to a later phase (packs, §5/§12), not to this security primitive.
|
|
16
|
-
* Rather than build a real (but pack-less, cache-less) `require` now and
|
|
17
|
-
* having to change its resolution semantics later, `require` is left
|
|
18
|
-
* UNDEFINED — not stubbed as an always-erroring global — in this phase.
|
|
16
|
+
* ## Two sources, told apart by the first path segment (docs/scripting.md)
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
18
|
+
* - **Bundle-local**: `require "scripts/util"`. The bundle's structural
|
|
19
|
+
* directories — `scripts`, `assets`, `.cache` — are reserved: a name
|
|
20
|
+
* whose first `/`-separated segment is exactly one of those three
|
|
21
|
+
* ALWAYS resolves inside the bundle. The module's source is fetched via
|
|
22
|
+
* the SAME `ScriptView` `bundle.read` uses (`./capabilities`), so it is
|
|
23
|
+
* subject to the exact same path-jail (`@markii/bundle`'s
|
|
24
|
+
* `normalizeBundlePath`, enforced inside `ScriptView`/`BundleStorage` —
|
|
25
|
+
* never re-implemented here) and the exact same read-permission check.
|
|
26
|
+
* Reading is the ONLY operation `require` ever performs — it never
|
|
27
|
+
* writes — so it is automatically read-only under every tier; there is
|
|
28
|
+
* no separate tier gate to apply on top of what `bundle.read` already
|
|
29
|
+
* enforces.
|
|
30
|
+
* - **Pack-namespaced**: `require "ana/http"` (first segment anything
|
|
31
|
+
* else). This phase implements ONLY the seam: an optional injected
|
|
32
|
+
* `PackModuleResolver`. With no resolver configured (packs are not
|
|
33
|
+
* wired into any host yet), a pack-namespaced `require` fails cleanly
|
|
34
|
+
* as a capability denial — never a crash, never a fallthrough into
|
|
35
|
+
* filesystem or network access.
|
|
27
36
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
37
|
+
* ## Why a real `require` needs `load` back, carefully
|
|
38
|
+
*
|
|
39
|
+
* `./globals` removes `load` (and its bytecode-execution siblings) from
|
|
40
|
+
* every sandbox by never letting a script reach a way to compile new
|
|
41
|
+
* source at runtime — "the ONLY code that ever runs is the one chunk the
|
|
42
|
+
* host handed in" (spec §10). A real `require` necessarily breaks that
|
|
43
|
+
* absolute reading: the host is now explicitly choosing to run a SECOND
|
|
44
|
+
* chunk, one whose text came from a bundle or a pack, not from the note's
|
|
45
|
+
* own script block. This module does not reopen `load` as a general
|
|
46
|
+
* capability — no script can ever call `load` directly, under any name.
|
|
47
|
+
* Instead, `./globals` captures the genuine `load` primitive into a
|
|
48
|
+
* private global (`__smd_load_raw`) an instant before scrubbing the
|
|
49
|
+
* public name, and this module's own prelude is the ONLY consumer: it
|
|
50
|
+
* captures that global into a local and immediately nils the global back
|
|
51
|
+
* out, so it is reachable from nowhere else, for the rest of the run,
|
|
52
|
+
* under any name a script could type. Every call this prelude makes to
|
|
53
|
+
* it passes `mode = "t"` — Lua's own built-in "text chunks only" gate,
|
|
54
|
+
* which makes `load` itself refuse anything starting with the Lua
|
|
55
|
+
* bytecode signature (`\27Lua`) before compiling a single byte of it.
|
|
56
|
+
* `buildRequire` below ALSO rejects that same signature on the JS side,
|
|
57
|
+
* before the text ever reaches Lua at all — belt and suspenders, not
|
|
58
|
+
* because either check alone is known to be insufficient, but because
|
|
59
|
+
* this codebase's convention throughout (`captureAssertOkStatus`,
|
|
60
|
+
* `ScriptLimitError`, the `xpcall` reimplementation) is to make a
|
|
61
|
+
* security property hold for two independent reasons wherever the cost
|
|
62
|
+
* of the second one is low.
|
|
63
|
+
*
|
|
64
|
+
* ## Cache, cycle detection, and protected execution — done entirely in Lua
|
|
65
|
+
*
|
|
66
|
+
* The module-name -> source-text lookup is the only part that needs a
|
|
67
|
+
* host round trip (bundle-local: an async `ScriptView.read`; pack:
|
|
68
|
+
* synchronous, but still crosses the JS/Lua boundary the same way).
|
|
69
|
+
* Everything after that — the per-run cache, in-progress-stack cycle
|
|
70
|
+
* detection, compiling, and running the module body — is plain Lua
|
|
71
|
+
* control flow inside the prelude this module builds, calling Lua from
|
|
72
|
+
* Lua, never JS calling into Lua. This deliberately mirrors
|
|
73
|
+
* `./capabilities`' `cache.get`: a Lua function invoked FROM JS goes
|
|
74
|
+
* through wasmoon's synchronous, non-yieldable bridge, so if a module
|
|
75
|
+
* body itself calls `net.fetch_json` (which needs `:await()`), that call
|
|
76
|
+
* must happen as an ordinary Lua-to-Lua call, never as a host-driven one.
|
|
77
|
+
* The module body runs via `pcall(chunk)`, not `xpcall` — see
|
|
78
|
+
* `./globals`' `XPCALL_REIMPLEMENTATION` doc comment for why the STOCK C
|
|
79
|
+
* `xpcall` (which this sandbox never restores) can deadlock the host
|
|
80
|
+
* under the limits hook; plain `pcall` has no such hazard, and a limit
|
|
81
|
+
* breach during a module's execution still wins unconditionally via
|
|
82
|
+
* `./limits`' out-of-band JS flag regardless of what any Lua-level
|
|
83
|
+
* `pcall` around it reports.
|
|
33
84
|
*/
|
|
34
|
-
|
|
85
|
+
/** A pack-namespaced `require "packName/modulePath"` resolver, injected by a host that has actually wired up pack installation. Returns pure Lua SOURCE TEXT, or `undefined` if this pack/module isn't available — never throws for a routine "not found". */
|
|
86
|
+
export type PackModuleResolver = (packName: string, modulePath: string) => string | undefined;
|
|
87
|
+
export interface RequireConfig {
|
|
88
|
+
/** The SAME `ScriptView` `./capabilities` wires up for `bundle.read`/`bundle.write` — reused, never re-implemented, so bundle-local `require` is subject to the identical path-jail and read-permission check. */
|
|
89
|
+
bundle?: ScriptView;
|
|
90
|
+
/** Optional pack-module seam — see the module doc comment. Absent (the current default: no host wires packs yet) means every pack-namespaced `require` fails as a clean capability denial. */
|
|
91
|
+
packModuleResolver?: PackModuleResolver;
|
|
92
|
+
/**
|
|
93
|
+
* Records a genuine denial onto the SAME per-run `CapabilityDenials`
|
|
94
|
+
* handle `./capabilities`' `buildCapabilities` already returns (its
|
|
95
|
+
* `recordDenial` field) — so `sandbox.ts` classifies a require-triggered
|
|
96
|
+
* denial as `kind: 'capability'`, exactly like any other capability
|
|
97
|
+
* failure, rather than an unclassified `'runtime'` error. Optional only
|
|
98
|
+
* so this module stays independently testable/usable without the rest
|
|
99
|
+
* of `./capabilities`' wiring; `sandbox.ts` always supplies it.
|
|
100
|
+
*/
|
|
101
|
+
recordDenial?: (reason: 'denied' | 'tier-blocked', message: string) => void;
|
|
102
|
+
}
|
|
35
103
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
104
|
+
* Builds the raw host-facing module resolver and the trusted Lua prelude
|
|
105
|
+
* that turns it into the real `require` global — see the module doc
|
|
106
|
+
* comment for the full design. Always defines `require`, regardless of
|
|
107
|
+
* whether `config.bundle` or `config.packModuleResolver` is present, so a
|
|
108
|
+
* run with neither configured still gets a real, always-defined function
|
|
109
|
+
* that fails every request cleanly (a capability denial), matching this
|
|
110
|
+
* sandbox's "never a bare 'attempt to call a nil value'" posture for
|
|
111
|
+
* every other documented host API surface.
|
|
42
112
|
*/
|
|
43
|
-
export declare function
|
|
113
|
+
export declare function buildRequire(config: RequireConfig): {
|
|
114
|
+
rawGlobals: Record<string, (...args: never[]) => Promise<unknown>>;
|
|
115
|
+
preludeLua: string;
|
|
116
|
+
};
|
package/dist/require.js
CHANGED
|
@@ -1,49 +1,185 @@
|
|
|
1
|
+
import { bytesToLuaString } from './capabilities.js';
|
|
2
|
+
import { CAPABILITY_ERROR_TAG } from './errors.js';
|
|
3
|
+
/** The bundle's reserved structural directories (docs/scripting.md): a `require` name whose first `/`-segment is exactly one of these always resolves inside the bundle and can never be a pack namespace. */
|
|
4
|
+
const RESERVED_BUNDLE_DIRS = new Set([
|
|
5
|
+
'scripts',
|
|
6
|
+
'assets',
|
|
7
|
+
'.cache',
|
|
8
|
+
]);
|
|
9
|
+
/** First `/`-separated path segment of `name` (the whole string if there is no `/`). */
|
|
10
|
+
function firstSegment(name) {
|
|
11
|
+
const idx = name.indexOf('/');
|
|
12
|
+
return idx === -1 ? name : name.slice(0, idx);
|
|
13
|
+
}
|
|
14
|
+
/** True iff `name`'s first segment is one of the bundle's reserved structural directories. */
|
|
15
|
+
function isBundleLocalName(name) {
|
|
16
|
+
return RESERVED_BUNDLE_DIRS.has(firstSegment(name));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Maps a bundle-local `require` name to the bundle-relative path its
|
|
20
|
+
* source is read from. A `.lua` suffix is appended when the name doesn't
|
|
21
|
+
* already carry one — `require "scripts/util"` and `require
|
|
22
|
+
* "scripts/util.lua"` both resolve to `scripts/util.lua`. This mapping is
|
|
23
|
+
* this package's own convention (docs/scripting.md's `require` examples
|
|
24
|
+
* never show an extension); it does not affect the path-jail, which is
|
|
25
|
+
* enforced entirely by `ScriptView.read` on the resulting path regardless
|
|
26
|
+
* of how it was built.
|
|
27
|
+
*/
|
|
28
|
+
function bundleModulePath(name) {
|
|
29
|
+
return name.endsWith('.lua') ? name : `${name}.lua`;
|
|
30
|
+
}
|
|
31
|
+
/** First byte of Lua's bytecode chunk signature (`"\27Lua..."`, i.e. ESC followed by `Lua`). */
|
|
32
|
+
const LUA_BYTECODE_SIGNATURE_FIRST_BYTE = 0x1b;
|
|
1
33
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* itself) does not wire that up yet. `bundle.read` (via the injected
|
|
10
|
-
* `ScriptView`, see `./capabilities`) already gives a script everything it
|
|
11
|
-
* needs to fetch a bundle-local module's SOURCE TEXT; what a real
|
|
12
|
-
* `require` adds on top is caching per module name and running the loaded
|
|
13
|
-
* source as a new protected chunk with the same globals/capabilities —
|
|
14
|
-
* both of those depend on the pack/namespace resolution rules (§8) that
|
|
15
|
-
* belong to a later phase (packs, §5/§12), not to this security primitive.
|
|
16
|
-
* Rather than build a real (but pack-less, cache-less) `require` now and
|
|
17
|
-
* having to change its resolution semantics later, `require` is left
|
|
18
|
-
* UNDEFINED — not stubbed as an always-erroring global — in this phase.
|
|
19
|
-
*
|
|
20
|
-
* Concretely: `sandbox.ts` never calls anything from this module, and the
|
|
21
|
-
* curated environment (`./globals`) never sets a `require` global at all.
|
|
22
|
-
* `NOT_YET_SUPPORTED_MESSAGE` and `buildRequireStub` are kept here,
|
|
23
|
-
* disconnected from the sandbox wiring, as the landing point for that
|
|
24
|
-
* later phase — a future change only needs to call `buildRequireStub()`
|
|
25
|
-
* (or replace it with the real resolver) from `sandbox.ts`'s prelude
|
|
26
|
-
* assembly, next to `buildCapabilities`.
|
|
27
|
-
*
|
|
28
|
-
* Either way — absent entirely (current state) or stubbed to always error
|
|
29
|
-
* (the stub below, for a host that wants a friendlier error message than
|
|
30
|
-
* a bare "attempt to call a nil value") — the guarantee the adversarial
|
|
31
|
-
* suite checks holds: no raw Lua `require` is ever reachable, and no
|
|
32
|
-
* script can load arbitrary source or bytecode through it.
|
|
34
|
+
* Defense-in-depth bytecode rejection on the JS side, BEFORE a resolved
|
|
35
|
+
* module's source ever reaches Lua's own `load(text, name, "t")` call
|
|
36
|
+
* (which independently refuses the same signature — see the module doc
|
|
37
|
+
* comment's "belt and suspenders" note). `source` here is always a JS
|
|
38
|
+
* string built the same one-code-unit-per-byte way `./capabilities`'
|
|
39
|
+
* `bytesToLuaString` produces, so `charCodeAt(0)` reads the first raw
|
|
40
|
+
* byte, not a decoded Unicode code point.
|
|
33
41
|
*/
|
|
34
|
-
|
|
42
|
+
function looksLikeBytecode(source) {
|
|
43
|
+
return (source.length > 0 &&
|
|
44
|
+
source.charCodeAt(0) === LUA_BYTECODE_SIGNATURE_FIRST_BYTE);
|
|
45
|
+
}
|
|
35
46
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
47
|
+
* Builds the raw host-facing module resolver and the trusted Lua prelude
|
|
48
|
+
* that turns it into the real `require` global — see the module doc
|
|
49
|
+
* comment for the full design. Always defines `require`, regardless of
|
|
50
|
+
* whether `config.bundle` or `config.packModuleResolver` is present, so a
|
|
51
|
+
* run with neither configured still gets a real, always-defined function
|
|
52
|
+
* that fails every request cleanly (a capability denial), matching this
|
|
53
|
+
* sandbox's "never a bare 'attempt to call a nil value'" posture for
|
|
54
|
+
* every other documented host API surface.
|
|
42
55
|
*/
|
|
43
|
-
export function
|
|
44
|
-
|
|
56
|
+
export function buildRequire(config) {
|
|
57
|
+
const recordDenial = config.recordDenial ?? (() => undefined);
|
|
58
|
+
function denyAndThrow(message) {
|
|
59
|
+
recordDenial('denied', message);
|
|
60
|
+
throw new Error(`${CAPABILITY_ERROR_TAG}: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
const rawGlobals = {};
|
|
63
|
+
rawGlobals.__smd_require_resolve_raw = (async (name) => {
|
|
64
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
65
|
+
throw new Error('require: module name must be a non-empty string');
|
|
66
|
+
}
|
|
67
|
+
let source;
|
|
68
|
+
if (isBundleLocalName(name)) {
|
|
69
|
+
const view = config.bundle;
|
|
70
|
+
if (!view) {
|
|
71
|
+
denyAndThrow(`bundle capability is not available for this run (require "${name}")`);
|
|
72
|
+
}
|
|
73
|
+
const path = bundleModulePath(name);
|
|
74
|
+
let data;
|
|
75
|
+
try {
|
|
76
|
+
data = await view.read(path);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
denyAndThrow(err instanceof Error ? err.message : String(err));
|
|
80
|
+
}
|
|
81
|
+
if (data === undefined) {
|
|
82
|
+
// A genuine "no such module" — not a permission problem, so NOT
|
|
83
|
+
// recorded as a denial. Spec: "a require that can't resolve fails
|
|
84
|
+
// softly" — this propagates as an ordinary, uncaught Lua error
|
|
85
|
+
// (see the prelude below), the same graceful failure shape any
|
|
86
|
+
// other script-error already gets.
|
|
87
|
+
throw new Error(`require: no bundle module at "${path}"`);
|
|
88
|
+
}
|
|
89
|
+
source = bytesToLuaString(data);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const resolver = config.packModuleResolver;
|
|
93
|
+
if (!resolver) {
|
|
94
|
+
denyAndThrow(`pack modules are not supported in this run (require "${name}")`);
|
|
95
|
+
}
|
|
96
|
+
const segIdx = name.indexOf('/');
|
|
97
|
+
const packName = segIdx === -1 ? name : name.slice(0, segIdx);
|
|
98
|
+
const modulePath = segIdx === -1 ? '' : name.slice(segIdx + 1);
|
|
99
|
+
const resolved = resolver(packName, modulePath);
|
|
100
|
+
if (resolved === undefined) {
|
|
101
|
+
throw new Error(`require: no pack module "${modulePath}" in pack "${packName}"`);
|
|
102
|
+
}
|
|
103
|
+
source = resolved;
|
|
104
|
+
}
|
|
105
|
+
if (looksLikeBytecode(source)) {
|
|
106
|
+
denyAndThrow(`require "${name}" resolved to a precompiled/binary chunk, which is never permitted (pure Lua source only)`);
|
|
107
|
+
}
|
|
108
|
+
return source;
|
|
109
|
+
});
|
|
110
|
+
const preludeLua = `
|
|
111
|
+
-- Captured into locals HERE, at prelude-definition time, and the backing
|
|
112
|
+
-- globals nil'd out immediately after -- same discipline as every other
|
|
113
|
+
-- capability wrapper in ./capabilities (see its "Captured into a local
|
|
114
|
+
-- HERE" comments), and load-bearing for __smd_require_load specifically:
|
|
115
|
+
-- __smd_load_raw is the genuine Lua \`load\` primitive (./globals), and
|
|
116
|
+
-- leaving it reachable under any name for even one later statement would
|
|
117
|
+
-- reopen the exact "compile and run arbitrary text" hole §10 closes.
|
|
118
|
+
local __smd_require_resolve = __smd_require_resolve_raw
|
|
119
|
+
local __smd_require_load = __smd_load_raw
|
|
120
|
+
__smd_require_resolve_raw = nil
|
|
121
|
+
__smd_load_raw = nil
|
|
122
|
+
|
|
123
|
+
-- Per-run module cache and in-progress stack. Both are plain Lua locals
|
|
124
|
+
-- declared in THIS prelude chunk, which runs exactly once per fresh
|
|
125
|
+
-- engine (one runScript call) -- see the module doc comment. A fresh run
|
|
126
|
+
-- gets a fresh engine (./sandbox tears the whole engine down every call),
|
|
127
|
+
-- so neither table is ever visible to, or seeded by, a prior run.
|
|
128
|
+
local __smd_require_cache = {}
|
|
129
|
+
local __smd_require_stack = {}
|
|
130
|
+
|
|
45
131
|
require = function(name)
|
|
46
|
-
|
|
132
|
+
if type(name) ~= "string" then
|
|
133
|
+
error("require: module name must be a string")
|
|
134
|
+
end
|
|
135
|
+
local cached = __smd_require_cache[name]
|
|
136
|
+
if cached ~= nil then
|
|
137
|
+
return cached
|
|
138
|
+
end
|
|
139
|
+
if __smd_require_stack[name] then
|
|
140
|
+
error("require: circular require of module '" .. name .. "'")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
-- Resolution (bundle read or pack lookup) happens BEFORE the
|
|
144
|
+
-- in-progress stack is touched, so a resolution failure (an ungranted
|
|
145
|
+
-- bundle, a path-jail rejection, no pack resolver, a genuinely missing
|
|
146
|
+
-- module) never leaves a stale stack entry behind to clean up.
|
|
147
|
+
local text = __smd_require_resolve(name):await()
|
|
148
|
+
|
|
149
|
+
__smd_require_stack[name] = true
|
|
150
|
+
-- mode = "t": text chunks only -- Lua itself refuses to compile
|
|
151
|
+
-- anything starting with the bytecode signature under this mode (see
|
|
152
|
+
-- the module doc comment's "belt and suspenders" note; buildRequire
|
|
153
|
+
-- already rejected it once, on the JS side, before this point).
|
|
154
|
+
local chunk, loadErr = __smd_require_load(text, "=" .. name, "t")
|
|
155
|
+
if not chunk then
|
|
156
|
+
__smd_require_stack[name] = nil
|
|
157
|
+
error("require: module '" .. name .. "' failed to compile: " .. tostring(loadErr))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
-- Protected chunk: same thread, same globals/capabilities, same
|
|
161
|
+
-- instruction/wall-clock/memory budget as the rest of this run (see
|
|
162
|
+
-- ./limits -- the hook is installed on the thread, not per-chunk). A
|
|
163
|
+
-- limit breach during this call still forces the WHOLE run to a hard
|
|
164
|
+
-- failure via ./limits' out-of-band JS flag, regardless of what this
|
|
165
|
+
-- pcall reports -- see the module doc comment on why plain pcall (never
|
|
166
|
+
-- xpcall) is safe here.
|
|
167
|
+
local ok, result = pcall(chunk)
|
|
168
|
+
__smd_require_stack[name] = nil
|
|
169
|
+
if not ok then
|
|
170
|
+
error(result)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
-- Matches stock Lua's own require: a module that returns nothing caches
|
|
174
|
+
-- (and returns) \`true\` rather than \`nil\`, so a later \`require\` of the
|
|
175
|
+
-- same name is unambiguously a cache HIT (nil is never a valid cached
|
|
176
|
+
-- value, so it can never be mistaken for "not yet required").
|
|
177
|
+
if result == nil then
|
|
178
|
+
result = true
|
|
179
|
+
end
|
|
180
|
+
__smd_require_cache[name] = result
|
|
181
|
+
return result
|
|
47
182
|
end
|
|
48
183
|
`;
|
|
184
|
+
return { rawGlobals, preludeLua };
|
|
49
185
|
}
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type CacheProvider, type CapabilityTier, type NetGrants, type NetProvid
|
|
|
3
3
|
import type { ScriptFailure } from './errors.js';
|
|
4
4
|
import { type ScriptLimits } from './limits.js';
|
|
5
5
|
import { type MarshalLimits } from './marshal.js';
|
|
6
|
+
import { type PackModuleResolver } from './require.js';
|
|
6
7
|
export interface RunScriptOptions {
|
|
7
8
|
code: string;
|
|
8
9
|
/** Spec §8's trigger tier: 'manual' unlocks effectful ops, 'auto' is read-only regardless of what's granted. */
|
|
@@ -10,8 +11,10 @@ export interface RunScriptOptions {
|
|
|
10
11
|
net?: NetProvider;
|
|
11
12
|
netGrants?: NetGrants;
|
|
12
13
|
cache?: CacheProvider;
|
|
13
|
-
/** Bundle-scoped filesystem (spec §11), already capability-restricted — see `@markii/bundle`'s `createScriptView`. */
|
|
14
|
+
/** 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`. */
|
|
14
15
|
bundle?: ScriptView;
|
|
16
|
+
/** 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
|
+
packModuleResolver?: PackModuleResolver;
|
|
15
18
|
maxFetchBytes?: number;
|
|
16
19
|
limits?: Partial<ScriptLimits>;
|
|
17
20
|
marshalLimits?: Partial<MarshalLimits>;
|
|
@@ -47,7 +50,9 @@ export type RunScriptResult = {
|
|
|
47
50
|
* 2. Memory cap (`engine.global.setMemoryMax`, backed by the
|
|
48
51
|
* `traceAllocations: true` custom allocator `./globals` requests).
|
|
49
52
|
* 3. `./capabilities` — build the `net`/`cache`/`bundle` Lua tables from
|
|
50
|
-
* whatever providers/grants/tier this call was given
|
|
53
|
+
* whatever providers/grants/tier this call was given, then `./require` —
|
|
54
|
+
* the real `require` global, sharing the same `bundle`/denial-recording
|
|
55
|
+
* wiring (§8's bundle-local and pack-namespaced module sources).
|
|
51
56
|
* 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
|
|
52
57
|
* the wrapped user code's return value is piped through.
|
|
53
58
|
* 5. A dedicated child thread (NOT `engine.doString`, which creates its
|
package/dist/sandbox.js
CHANGED
|
@@ -4,6 +4,7 @@ import { MARSHAL_ERROR_TAG, ScriptLimitError } from './errors.js';
|
|
|
4
4
|
import { createEmptyLuaEngine } from './globals.js';
|
|
5
5
|
import { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
6
6
|
import { buildMarshalPrelude, DEFAULT_MARSHAL_LIMITS, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
7
|
+
import { buildRequire } from './require.js';
|
|
7
8
|
/**
|
|
8
9
|
* The wall-clock hard-kill in `./limits` only fires between Lua VM
|
|
9
10
|
* instructions — it cannot observe a script suspended on `:await()`-ing a
|
|
@@ -173,7 +174,9 @@ function classifyRuntimeError(err) {
|
|
|
173
174
|
* 2. Memory cap (`engine.global.setMemoryMax`, backed by the
|
|
174
175
|
* `traceAllocations: true` custom allocator `./globals` requests).
|
|
175
176
|
* 3. `./capabilities` — build the `net`/`cache`/`bundle` Lua tables from
|
|
176
|
-
* whatever providers/grants/tier this call was given
|
|
177
|
+
* whatever providers/grants/tier this call was given, then `./require` —
|
|
178
|
+
* the real `require` global, sharing the same `bundle`/denial-recording
|
|
179
|
+
* wiring (§8's bundle-local and pack-namespaced module sources).
|
|
177
180
|
* 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
|
|
178
181
|
* the wrapped user code's return value is piped through.
|
|
179
182
|
* 5. A dedicated child thread (NOT `engine.doString`, which creates its
|
|
@@ -209,7 +212,7 @@ export async function runScript(options) {
|
|
|
209
212
|
let limitHandle;
|
|
210
213
|
let guardTimer;
|
|
211
214
|
try {
|
|
212
|
-
const { rawGlobals, preludeLua, denials } = buildCapabilities({
|
|
215
|
+
const { rawGlobals, preludeLua, denials, recordDenial } = buildCapabilities({
|
|
213
216
|
tier: options.tier,
|
|
214
217
|
net: options.net,
|
|
215
218
|
netGrants: options.netGrants,
|
|
@@ -224,6 +227,38 @@ export async function runScript(options) {
|
|
|
224
227
|
if (preludeLua.trim().length > 0) {
|
|
225
228
|
await engine.doString(preludeLua);
|
|
226
229
|
}
|
|
230
|
+
// ./require: always wired, regardless of whether `options.bundle`/
|
|
231
|
+
// `options.packModuleResolver` are set — see `buildRequire`'s doc
|
|
232
|
+
// comment for why `require` must always be a real, defined function
|
|
233
|
+
// (never left absent for the sandbox to leave as a bare "attempt to
|
|
234
|
+
// call a nil value"), and shares `denials`/`recordDenial` with
|
|
235
|
+
// `buildCapabilities` above so a require-triggered denial classifies
|
|
236
|
+
// as `kind: 'capability'` the same way any other one does (see
|
|
237
|
+
// `classifyRuntimeError`'s doc comment below).
|
|
238
|
+
const requireBuild = buildRequire({
|
|
239
|
+
bundle: options.bundle,
|
|
240
|
+
packModuleResolver: options.packModuleResolver,
|
|
241
|
+
recordDenial,
|
|
242
|
+
});
|
|
243
|
+
for (const [name, fn] of Object.entries(requireBuild.rawGlobals)) {
|
|
244
|
+
engine.global.set(name, fn);
|
|
245
|
+
}
|
|
246
|
+
if (requireBuild.preludeLua.trim().length > 0) {
|
|
247
|
+
await engine.doString(requireBuild.preludeLua);
|
|
248
|
+
}
|
|
249
|
+
// Fail-closed defense in depth (issue #3, slice 4). `./globals`'
|
|
250
|
+
// SCRUB_PRELUDE captures the genuine `load` primitive into the private
|
|
251
|
+
// global `__smd_load_raw` so `./require`'s prelude (run just above) can
|
|
252
|
+
// consume it; that prelude nils the global back out immediately. This
|
|
253
|
+
// asserts the window is actually closed BEFORE any user code runs: if a
|
|
254
|
+
// future refactor of either prelude ever left `__smd_load_raw` (or the
|
|
255
|
+
// public `load`) reachable, this aborts the run rather than executing a
|
|
256
|
+
// script against an exposed compiler. On the sanctioned path this is
|
|
257
|
+
// always a no-op; it exists so the path can never silently regress.
|
|
258
|
+
const loadResidue = await engine.doString('return type(__smd_load_raw) .. "," .. type(load)');
|
|
259
|
+
if (loadResidue !== 'nil,nil') {
|
|
260
|
+
throw new Error(`sandbox assembly left a code-loading primitive reachable (${String(loadResidue)}); refusing to run`);
|
|
261
|
+
}
|
|
227
262
|
await engine.doString(buildMarshalPrelude(marshalLimits));
|
|
228
263
|
thread = engine.global.newThread();
|
|
229
264
|
threadStackIndex = engine.global.getTop();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/lua",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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.7.1",
|
|
48
|
+
"@markii/runtime": "0.7.1",
|
|
49
49
|
"wasmoon": "^1.16.0"
|
|
50
50
|
}
|
|
51
51
|
}
|