@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ScriptMarshalReason } from './errors.js';
|
|
2
|
+
/** Marshal limits: caps on the shape of a script's return value. */
|
|
3
|
+
export interface MarshalLimits {
|
|
4
|
+
/** Max table nesting depth. Default 32. */
|
|
5
|
+
maxDepth: number;
|
|
6
|
+
/** Max total scalar+table nodes visited. Default 20,000. */
|
|
7
|
+
maxNodes: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_MARSHAL_LIMITS: MarshalLimits;
|
|
10
|
+
/**
|
|
11
|
+
* Why marshaling is done IN LUA, before the value ever reaches wasmoon's
|
|
12
|
+
* own JS conversion:
|
|
13
|
+
*
|
|
14
|
+
* wasmoon's table type extension (`getValue` for `LuaType.Table`) eagerly
|
|
15
|
+
* and unconditionally deep-converts an ENTIRE Lua table into a JS
|
|
16
|
+
* object/array the moment `thread.run()` resolves — there is no hook to
|
|
17
|
+
* intercept or cap that conversion from the JS side, and no way to abort
|
|
18
|
+
* it partway through. Verified empirically: a returned Lua table with
|
|
19
|
+
* 1,000,000 sequential integer keys took ~12 seconds to convert on
|
|
20
|
+
* ordinary hardware, fully materializing a million-element JS array before
|
|
21
|
+
* our own code ever got a chance to look at it or reject it. A node-count
|
|
22
|
+
* cap applied AFTER that conversion would correctly reject the value, but
|
|
23
|
+
* only after already paying the full CPU/memory cost of building it — that
|
|
24
|
+
* fails the "reject quickly, not hang" requirement.
|
|
25
|
+
*
|
|
26
|
+
* So instead: the returned value is walked and re-shaped by a small
|
|
27
|
+
* trusted LUA function (defined by `MARSHAL_PRELUDE` below, injected once
|
|
28
|
+
* per engine — see `sandbox.ts`) that runs INSIDE the sandboxed VM, under
|
|
29
|
+
* the exact same instruction-count hook (`./limits`) as the rest of the
|
|
30
|
+
* script. It counts nodes and depth as it goes and raises a Lua error the
|
|
31
|
+
* INSTANT either cap is exceeded — for a 1,000,000-entry table with the
|
|
32
|
+
* default 20,000-node cap, that's ~20,000 `pairs` steps, not 1,000,000,
|
|
33
|
+
* and it never reaches wasmoon's JS conversion step at all. Only the
|
|
34
|
+
* already-capped, already-shaped result gets handed to wasmoon's
|
|
35
|
+
* `getValue`, so that conversion is now cheap by construction.
|
|
36
|
+
*
|
|
37
|
+
* This also gives us cycle detection for free: Lua tables can be used as
|
|
38
|
+
* table KEYS (by identity), so a `seen[t] = true` map, cleared on the way
|
|
39
|
+
* back out of each table (so the same table appearing twice in DIFFERENT
|
|
40
|
+
* branches — not a cycle — is still allowed), detects a true cycle
|
|
41
|
+
* (`t.self = t`) in native Lua before wasmoon's converter would otherwise
|
|
42
|
+
* have to (wasmoon's converter also handles cycles correctly by reusing a
|
|
43
|
+
* seen JS object per Lua table pointer — verified empirically it does NOT
|
|
44
|
+
* hang or stack-overflow on `t.self = t` — but a cycle is still not
|
|
45
|
+
* JSON-serializable, so we reject it explicitly rather than silently
|
|
46
|
+
* handing the host a self-referential object).
|
|
47
|
+
*
|
|
48
|
+
* Array vs. object: a table is treated as an array iff its keys are
|
|
49
|
+
* exactly `1..n` for some `n` (checked by counting entries during the
|
|
50
|
+
* `pairs` walk and comparing against `#value`) — same "sequential integer
|
|
51
|
+
* keys" rule wasmoon's own converter uses for the DISPLAY shape, applied
|
|
52
|
+
* here so the pre-shaped table wasmoon receives is unambiguous. Any other
|
|
53
|
+
* key type is rejected (`'key-type'`) — JSON object keys are strings only,
|
|
54
|
+
* and allowing e.g. a table keyed by `true`/`false` would silently produce
|
|
55
|
+
* something JSON can't represent. Two edge cases fall out of this and are
|
|
56
|
+
* both intentionally rejected as `'key-type'` rather than guessed at: a
|
|
57
|
+
* table mixing integer and string keys (e.g. `{1, 2, x = "y"}` — not
|
|
58
|
+
* cleanly a JSON array OR object), and a sparse array with holes (e.g.
|
|
59
|
+
* `{[1]=1, [3]=3}`, where Lua's `#` length operator is itself undefined at
|
|
60
|
+
* the border) — both have no faithful JSON shape, so we refuse to guess
|
|
61
|
+
* rather than silently drop or reorder data. An empty table `{}` has no
|
|
62
|
+
* keys to disambiguate and is treated as an empty array (`[]`), matching
|
|
63
|
+
* the "array unless proven otherwise" default the walk falls back to.
|
|
64
|
+
*
|
|
65
|
+
* Numbers: Lua 5.4's `number` type covers both what would be a JSON number
|
|
66
|
+
* AND non-finite IEEE-754 doubles (`0/0`, `1/0`, `-1/0`), which Lua
|
|
67
|
+
* computes and returns natively with no distinct type tag. The Lua-side
|
|
68
|
+
* walk lets any number through unchanged (rejecting here would need a
|
|
69
|
+
* `nan`/`huge` check reimplemented in Lua, which is redundant since we
|
|
70
|
+
* already do it in JS); `finalizeMarshaledValue` below does the actual
|
|
71
|
+
* NaN/Infinity check, in JS, on the final (already depth/node-capped)
|
|
72
|
+
* value.
|
|
73
|
+
*
|
|
74
|
+
* Functions, userdata, and threads are rejected outright (`'type'`) — none
|
|
75
|
+
* of the exposed stdlib or capability surface should ever hand a script a
|
|
76
|
+
* live function/userdata/thread to return in the first place, but this is
|
|
77
|
+
* the backstop in case one leaks through.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildMarshalPrelude(limits: MarshalLimits): string;
|
|
80
|
+
/**
|
|
81
|
+
* Wraps the user's script source so its return value is piped through
|
|
82
|
+
* `__smd_marshal_root` before ever leaving the Lua VM. `__smd_marshal_root`
|
|
83
|
+
* must already be defined as a global in this engine (via
|
|
84
|
+
* `buildMarshalPrelude`, run once per engine in `sandbox.ts`) — it isn't
|
|
85
|
+
* redefined per-call, only referenced.
|
|
86
|
+
*
|
|
87
|
+
* The user code runs inside its own function scope (`__smd_user_chunk`) so
|
|
88
|
+
* a bare top-level `return` in the script behaves exactly as it would if
|
|
89
|
+
* run standalone. `__smd_marshal_root` is a genuine global (see the "NOT
|
|
90
|
+
* local" note on its definition above — it has to be, to survive being
|
|
91
|
+
* defined in a separate chunk from the one that calls it), so it is also
|
|
92
|
+
* directly visible to the untrusted script itself, not just to this
|
|
93
|
+
* wrapper — this is an accepted, deliberate trade-off: a script COULD call
|
|
94
|
+
* `__smd_marshal_root(x)` itself out of curiosity, but that function is
|
|
95
|
+
* pure (no side effects, no capability access, no ambient authority) and
|
|
96
|
+
* its worst case is raising one of the tagged errors above, so exposing it
|
|
97
|
+
* this way is harmless. Avoiding it entirely would require a much heavier
|
|
98
|
+
* mechanism (a custom `_ENV` per chunk) that this phase doesn't need.
|
|
99
|
+
*/
|
|
100
|
+
export declare function wrapUserCode(code: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* Final JS-side pass over the value wasmoon already converted from the
|
|
103
|
+
* Lua-side-capped table: strips the `__smd_is_array` marker (converting
|
|
104
|
+
* the marked object into a real JS array, since wasmoon's own array/object
|
|
105
|
+
* detection — "keys are exactly 1..n" — doesn't apply once we've added a
|
|
106
|
+
* non-numeric marker key to what should read as an array) and rejects
|
|
107
|
+
* non-finite numbers (`NaN`, `Infinity`, `-Infinity`).
|
|
108
|
+
*
|
|
109
|
+
* Judgment call on NaN/Infinity: REJECTED, not silently coerced to
|
|
110
|
+
* `null`. JSON has no representation for them, and a script that computed
|
|
111
|
+
* one is far more likely to have hit `0/0` or a runaway `1/(x-x)` by
|
|
112
|
+
* mistake than to have intended it as a value a consuming component should
|
|
113
|
+
* render — silently turning that into `null` would hide the bug. A caller
|
|
114
|
+
* that wants "stale/empty" semantics for that case already gets it for
|
|
115
|
+
* free: a `'marshal'` failure is a normal missing-value case per spec §8
|
|
116
|
+
* ("If the value is missing or the script hasn't run, the component
|
|
117
|
+
* renders its empty/stale state").
|
|
118
|
+
*/
|
|
119
|
+
export declare function finalizeMarshaledValue(value: unknown): {
|
|
120
|
+
ok: true;
|
|
121
|
+
value: unknown;
|
|
122
|
+
} | {
|
|
123
|
+
ok: false;
|
|
124
|
+
reason: ScriptMarshalReason;
|
|
125
|
+
message: string;
|
|
126
|
+
};
|
package/dist/marshal.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { MARSHAL_ERROR_TAG } from './errors.js';
|
|
2
|
+
export const DEFAULT_MARSHAL_LIMITS = {
|
|
3
|
+
maxDepth: 32,
|
|
4
|
+
maxNodes: 20_000,
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Why marshaling is done IN LUA, before the value ever reaches wasmoon's
|
|
8
|
+
* own JS conversion:
|
|
9
|
+
*
|
|
10
|
+
* wasmoon's table type extension (`getValue` for `LuaType.Table`) eagerly
|
|
11
|
+
* and unconditionally deep-converts an ENTIRE Lua table into a JS
|
|
12
|
+
* object/array the moment `thread.run()` resolves — there is no hook to
|
|
13
|
+
* intercept or cap that conversion from the JS side, and no way to abort
|
|
14
|
+
* it partway through. Verified empirically: a returned Lua table with
|
|
15
|
+
* 1,000,000 sequential integer keys took ~12 seconds to convert on
|
|
16
|
+
* ordinary hardware, fully materializing a million-element JS array before
|
|
17
|
+
* our own code ever got a chance to look at it or reject it. A node-count
|
|
18
|
+
* cap applied AFTER that conversion would correctly reject the value, but
|
|
19
|
+
* only after already paying the full CPU/memory cost of building it — that
|
|
20
|
+
* fails the "reject quickly, not hang" requirement.
|
|
21
|
+
*
|
|
22
|
+
* So instead: the returned value is walked and re-shaped by a small
|
|
23
|
+
* trusted LUA function (defined by `MARSHAL_PRELUDE` below, injected once
|
|
24
|
+
* per engine — see `sandbox.ts`) that runs INSIDE the sandboxed VM, under
|
|
25
|
+
* the exact same instruction-count hook (`./limits`) as the rest of the
|
|
26
|
+
* script. It counts nodes and depth as it goes and raises a Lua error the
|
|
27
|
+
* INSTANT either cap is exceeded — for a 1,000,000-entry table with the
|
|
28
|
+
* default 20,000-node cap, that's ~20,000 `pairs` steps, not 1,000,000,
|
|
29
|
+
* and it never reaches wasmoon's JS conversion step at all. Only the
|
|
30
|
+
* already-capped, already-shaped result gets handed to wasmoon's
|
|
31
|
+
* `getValue`, so that conversion is now cheap by construction.
|
|
32
|
+
*
|
|
33
|
+
* This also gives us cycle detection for free: Lua tables can be used as
|
|
34
|
+
* table KEYS (by identity), so a `seen[t] = true` map, cleared on the way
|
|
35
|
+
* back out of each table (so the same table appearing twice in DIFFERENT
|
|
36
|
+
* branches — not a cycle — is still allowed), detects a true cycle
|
|
37
|
+
* (`t.self = t`) in native Lua before wasmoon's converter would otherwise
|
|
38
|
+
* have to (wasmoon's converter also handles cycles correctly by reusing a
|
|
39
|
+
* seen JS object per Lua table pointer — verified empirically it does NOT
|
|
40
|
+
* hang or stack-overflow on `t.self = t` — but a cycle is still not
|
|
41
|
+
* JSON-serializable, so we reject it explicitly rather than silently
|
|
42
|
+
* handing the host a self-referential object).
|
|
43
|
+
*
|
|
44
|
+
* Array vs. object: a table is treated as an array iff its keys are
|
|
45
|
+
* exactly `1..n` for some `n` (checked by counting entries during the
|
|
46
|
+
* `pairs` walk and comparing against `#value`) — same "sequential integer
|
|
47
|
+
* keys" rule wasmoon's own converter uses for the DISPLAY shape, applied
|
|
48
|
+
* here so the pre-shaped table wasmoon receives is unambiguous. Any other
|
|
49
|
+
* key type is rejected (`'key-type'`) — JSON object keys are strings only,
|
|
50
|
+
* and allowing e.g. a table keyed by `true`/`false` would silently produce
|
|
51
|
+
* something JSON can't represent. Two edge cases fall out of this and are
|
|
52
|
+
* both intentionally rejected as `'key-type'` rather than guessed at: a
|
|
53
|
+
* table mixing integer and string keys (e.g. `{1, 2, x = "y"}` — not
|
|
54
|
+
* cleanly a JSON array OR object), and a sparse array with holes (e.g.
|
|
55
|
+
* `{[1]=1, [3]=3}`, where Lua's `#` length operator is itself undefined at
|
|
56
|
+
* the border) — both have no faithful JSON shape, so we refuse to guess
|
|
57
|
+
* rather than silently drop or reorder data. An empty table `{}` has no
|
|
58
|
+
* keys to disambiguate and is treated as an empty array (`[]`), matching
|
|
59
|
+
* the "array unless proven otherwise" default the walk falls back to.
|
|
60
|
+
*
|
|
61
|
+
* Numbers: Lua 5.4's `number` type covers both what would be a JSON number
|
|
62
|
+
* AND non-finite IEEE-754 doubles (`0/0`, `1/0`, `-1/0`), which Lua
|
|
63
|
+
* computes and returns natively with no distinct type tag. The Lua-side
|
|
64
|
+
* walk lets any number through unchanged (rejecting here would need a
|
|
65
|
+
* `nan`/`huge` check reimplemented in Lua, which is redundant since we
|
|
66
|
+
* already do it in JS); `finalizeMarshaledValue` below does the actual
|
|
67
|
+
* NaN/Infinity check, in JS, on the final (already depth/node-capped)
|
|
68
|
+
* value.
|
|
69
|
+
*
|
|
70
|
+
* Functions, userdata, and threads are rejected outright (`'type'`) — none
|
|
71
|
+
* of the exposed stdlib or capability surface should ever hand a script a
|
|
72
|
+
* live function/userdata/thread to return in the first place, but this is
|
|
73
|
+
* the backstop in case one leaks through.
|
|
74
|
+
*/
|
|
75
|
+
export function buildMarshalPrelude(limits) {
|
|
76
|
+
return `
|
|
77
|
+
local function __smd_marshal(value, seen, depth, budget)
|
|
78
|
+
local t = type(value)
|
|
79
|
+
if t == "nil" or t == "boolean" or t == "string" or t == "number" then
|
|
80
|
+
budget.n = budget.n + 1
|
|
81
|
+
if budget.n > budget.maxNodes then error("${MARSHAL_ERROR_TAG}:nodes") end
|
|
82
|
+
return value
|
|
83
|
+
elseif t == "table" then
|
|
84
|
+
if depth >= budget.maxDepth then error("${MARSHAL_ERROR_TAG}:depth") end
|
|
85
|
+
if seen[value] then error("${MARSHAL_ERROR_TAG}:cycle") end
|
|
86
|
+
seen[value] = true
|
|
87
|
+
budget.n = budget.n + 1
|
|
88
|
+
if budget.n > budget.maxNodes then error("${MARSHAL_ERROR_TAG}:nodes") end
|
|
89
|
+
local count = 0
|
|
90
|
+
local isArray = true
|
|
91
|
+
for k, _ in pairs(value) do
|
|
92
|
+
count = count + 1
|
|
93
|
+
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
|
|
94
|
+
isArray = false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
local out = {}
|
|
98
|
+
if isArray and count == #value then
|
|
99
|
+
for i = 1, count do
|
|
100
|
+
out[i] = __smd_marshal(value[i], seen, depth + 1, budget)
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
isArray = false
|
|
104
|
+
for k, v in pairs(value) do
|
|
105
|
+
if type(k) ~= "string" then
|
|
106
|
+
error("${MARSHAL_ERROR_TAG}:key-type")
|
|
107
|
+
end
|
|
108
|
+
out[k] = __smd_marshal(v, seen, depth + 1, budget)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
seen[value] = nil
|
|
112
|
+
if isArray then
|
|
113
|
+
out.__smd_is_array = true
|
|
114
|
+
end
|
|
115
|
+
return out
|
|
116
|
+
else
|
|
117
|
+
error("${MARSHAL_ERROR_TAG}:type:" .. t)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
-- Deliberately NOT "local": this prelude and the wrapped user code (see
|
|
122
|
+
-- \`wrapUserCode\` below) are compiled as SEPARATE chunks via separate
|
|
123
|
+
-- \`loadString\` calls, and a Lua \`local\` never survives past the chunk
|
|
124
|
+
-- that declared it. \`__smd_marshal_root\` must be a global so the
|
|
125
|
+
-- wrapper chunk can call it. \`__smd_marshal\` itself stays local -- it is
|
|
126
|
+
-- only ever called from within THIS chunk (from \`__smd_marshal_root\`,
|
|
127
|
+
-- which closes over it as an upvalue regardless of who calls
|
|
128
|
+
-- \`__smd_marshal_root\` later), so it never needs to be reachable by name
|
|
129
|
+
-- from anywhere else.
|
|
130
|
+
function __smd_marshal_root(value)
|
|
131
|
+
local budget = { n = 0, maxNodes = ${limits.maxNodes}, maxDepth = ${limits.maxDepth} }
|
|
132
|
+
return __smd_marshal(value, {}, 0, budget)
|
|
133
|
+
end
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Wraps the user's script source so its return value is piped through
|
|
138
|
+
* `__smd_marshal_root` before ever leaving the Lua VM. `__smd_marshal_root`
|
|
139
|
+
* must already be defined as a global in this engine (via
|
|
140
|
+
* `buildMarshalPrelude`, run once per engine in `sandbox.ts`) — it isn't
|
|
141
|
+
* redefined per-call, only referenced.
|
|
142
|
+
*
|
|
143
|
+
* The user code runs inside its own function scope (`__smd_user_chunk`) so
|
|
144
|
+
* a bare top-level `return` in the script behaves exactly as it would if
|
|
145
|
+
* run standalone. `__smd_marshal_root` is a genuine global (see the "NOT
|
|
146
|
+
* local" note on its definition above — it has to be, to survive being
|
|
147
|
+
* defined in a separate chunk from the one that calls it), so it is also
|
|
148
|
+
* directly visible to the untrusted script itself, not just to this
|
|
149
|
+
* wrapper — this is an accepted, deliberate trade-off: a script COULD call
|
|
150
|
+
* `__smd_marshal_root(x)` itself out of curiosity, but that function is
|
|
151
|
+
* pure (no side effects, no capability access, no ambient authority) and
|
|
152
|
+
* its worst case is raising one of the tagged errors above, so exposing it
|
|
153
|
+
* this way is harmless. Avoiding it entirely would require a much heavier
|
|
154
|
+
* mechanism (a custom `_ENV` per chunk) that this phase doesn't need.
|
|
155
|
+
*/
|
|
156
|
+
export function wrapUserCode(code) {
|
|
157
|
+
return `local function __smd_user_chunk()\n${code}\nend\nreturn __smd_marshal_root(__smd_user_chunk())`;
|
|
158
|
+
}
|
|
159
|
+
/** True array marker set by the Lua-side marshal walk (see `buildMarshalPrelude`). */
|
|
160
|
+
const ARRAY_MARKER = '__smd_is_array';
|
|
161
|
+
/**
|
|
162
|
+
* Final JS-side pass over the value wasmoon already converted from the
|
|
163
|
+
* Lua-side-capped table: strips the `__smd_is_array` marker (converting
|
|
164
|
+
* the marked object into a real JS array, since wasmoon's own array/object
|
|
165
|
+
* detection — "keys are exactly 1..n" — doesn't apply once we've added a
|
|
166
|
+
* non-numeric marker key to what should read as an array) and rejects
|
|
167
|
+
* non-finite numbers (`NaN`, `Infinity`, `-Infinity`).
|
|
168
|
+
*
|
|
169
|
+
* Judgment call on NaN/Infinity: REJECTED, not silently coerced to
|
|
170
|
+
* `null`. JSON has no representation for them, and a script that computed
|
|
171
|
+
* one is far more likely to have hit `0/0` or a runaway `1/(x-x)` by
|
|
172
|
+
* mistake than to have intended it as a value a consuming component should
|
|
173
|
+
* render — silently turning that into `null` would hide the bug. A caller
|
|
174
|
+
* that wants "stale/empty" semantics for that case already gets it for
|
|
175
|
+
* free: a `'marshal'` failure is a normal missing-value case per spec §8
|
|
176
|
+
* ("If the value is missing or the script hasn't run, the component
|
|
177
|
+
* renders its empty/stale state").
|
|
178
|
+
*/
|
|
179
|
+
export function finalizeMarshaledValue(value) {
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
function walk(v) {
|
|
182
|
+
if (v === null || v === undefined)
|
|
183
|
+
return { ok: true, value: null };
|
|
184
|
+
const t = typeof v;
|
|
185
|
+
if (t === 'string' || t === 'boolean')
|
|
186
|
+
return { ok: true, value: v };
|
|
187
|
+
if (t === 'number') {
|
|
188
|
+
if (!Number.isFinite(v)) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: 'non-finite-number',
|
|
192
|
+
message: `script returned a non-finite number (${String(v)})`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, value: v };
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(v)) {
|
|
198
|
+
if (seen.has(v)) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: 'cycle',
|
|
202
|
+
message: 'script return value contains a cycle',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
seen.add(v);
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const item of v) {
|
|
208
|
+
const r = walk(item);
|
|
209
|
+
if (!r.ok)
|
|
210
|
+
return r;
|
|
211
|
+
out.push(r.value);
|
|
212
|
+
}
|
|
213
|
+
seen.delete(v);
|
|
214
|
+
return { ok: true, value: out };
|
|
215
|
+
}
|
|
216
|
+
if (t === 'object') {
|
|
217
|
+
const obj = v;
|
|
218
|
+
if (seen.has(obj)) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: 'cycle',
|
|
222
|
+
message: 'script return value contains a cycle',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
seen.add(obj);
|
|
226
|
+
const isArray = obj[ARRAY_MARKER] === true;
|
|
227
|
+
if (isArray) {
|
|
228
|
+
const keys = Object.keys(obj)
|
|
229
|
+
.filter((k) => k !== ARRAY_MARKER)
|
|
230
|
+
.sort((a, b) => Number(a) - Number(b));
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const k of keys) {
|
|
233
|
+
const r = walk(obj[k]);
|
|
234
|
+
if (!r.ok)
|
|
235
|
+
return r;
|
|
236
|
+
out.push(r.value);
|
|
237
|
+
}
|
|
238
|
+
seen.delete(obj);
|
|
239
|
+
return { ok: true, value: out };
|
|
240
|
+
}
|
|
241
|
+
const out = {};
|
|
242
|
+
for (const [k, val] of Object.entries(obj)) {
|
|
243
|
+
const r = walk(val);
|
|
244
|
+
if (!r.ok)
|
|
245
|
+
return r;
|
|
246
|
+
out[k] = r.value;
|
|
247
|
+
}
|
|
248
|
+
seen.delete(obj);
|
|
249
|
+
return { ok: true, value: out };
|
|
250
|
+
}
|
|
251
|
+
// functions/other host objects should never reach here — the Lua-side
|
|
252
|
+
// walk already rejects them before they're returned at all.
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
reason: 'type',
|
|
256
|
+
message: `script return value contained an unsupported JS type: ${t}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return walk(value);
|
|
260
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed `require` (spec §8 "Script placement, long scripts, and
|
|
3
|
+
* modules"): the eventual design is exactly two sources — bundle-local
|
|
4
|
+
* modules (`require "scripts/util"`, same path-jail as `bundle.read`) and
|
|
5
|
+
* pack-namespaced pure-Lua modules (`require "ana/http"`) — pure Lua
|
|
6
|
+
* source only, no C, no bytecode, no network, module cache per run.
|
|
7
|
+
*
|
|
8
|
+
* DESIGN NOTE / explicit deferral: this phase (the sandbox primitive
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
export declare const NOT_YET_SUPPORTED_MESSAGE = "modules not yet supported (require \"%s\")";
|
|
35
|
+
/**
|
|
36
|
+
* Not wired into `sandbox.ts` in this phase (see module doc comment
|
|
37
|
+
* above) — provided so a host that wants an explicit, friendlier error
|
|
38
|
+
* message instead of Lua's default "attempt to call a nil value (global
|
|
39
|
+
* 'require')" can opt in without this package needing to change shape
|
|
40
|
+
* later. Deliberately does not touch the filesystem, network, or `load` —
|
|
41
|
+
* it only ever raises.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildRequireStub(): string;
|
package/dist/require.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed `require` (spec §8 "Script placement, long scripts, and
|
|
3
|
+
* modules"): the eventual design is exactly two sources — bundle-local
|
|
4
|
+
* modules (`require "scripts/util"`, same path-jail as `bundle.read`) and
|
|
5
|
+
* pack-namespaced pure-Lua modules (`require "ana/http"`) — pure Lua
|
|
6
|
+
* source only, no C, no bytecode, no network, module cache per run.
|
|
7
|
+
*
|
|
8
|
+
* DESIGN NOTE / explicit deferral: this phase (the sandbox primitive
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
export const NOT_YET_SUPPORTED_MESSAGE = 'modules not yet supported (require "%s")';
|
|
35
|
+
/**
|
|
36
|
+
* Not wired into `sandbox.ts` in this phase (see module doc comment
|
|
37
|
+
* above) — provided so a host that wants an explicit, friendlier error
|
|
38
|
+
* message instead of Lua's default "attempt to call a nil value (global
|
|
39
|
+
* 'require')" can opt in without this package needing to change shape
|
|
40
|
+
* later. Deliberately does not touch the filesystem, network, or `load` —
|
|
41
|
+
* it only ever raises.
|
|
42
|
+
*/
|
|
43
|
+
export function buildRequireStub() {
|
|
44
|
+
return `
|
|
45
|
+
require = function(name)
|
|
46
|
+
error(string.format(${JSON.stringify(NOT_YET_SUPPORTED_MESSAGE)}, tostring(name)))
|
|
47
|
+
end
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ScriptView } from '@markii/bundle';
|
|
2
|
+
import { type CacheProvider, type CapabilityTier, type NetGrants, type NetProvider } from './capabilities.js';
|
|
3
|
+
import type { ScriptFailure } from './errors.js';
|
|
4
|
+
import { type ScriptLimits } from './limits.js';
|
|
5
|
+
import { type MarshalLimits } from './marshal.js';
|
|
6
|
+
export interface RunScriptOptions {
|
|
7
|
+
code: string;
|
|
8
|
+
/** Spec §8's trigger tier: 'manual' unlocks effectful ops, 'auto' is read-only regardless of what's granted. */
|
|
9
|
+
tier: CapabilityTier;
|
|
10
|
+
net?: NetProvider;
|
|
11
|
+
netGrants?: NetGrants;
|
|
12
|
+
cache?: CacheProvider;
|
|
13
|
+
/** Bundle-scoped filesystem (spec §11), already capability-restricted — see `@markii/bundle`'s `createScriptView`. */
|
|
14
|
+
bundle?: ScriptView;
|
|
15
|
+
maxFetchBytes?: number;
|
|
16
|
+
limits?: Partial<ScriptLimits>;
|
|
17
|
+
marshalLimits?: Partial<MarshalLimits>;
|
|
18
|
+
/**
|
|
19
|
+
* Forwarded to `./globals`' `createEmptyLuaEngine` as its
|
|
20
|
+
* `wasmUri` option (wasmoon's `customWasmUri`). Left `undefined` (the
|
|
21
|
+
* default), engine creation is byte-for-byte the same as before this
|
|
22
|
+
* option existed: local `node_modules` resolution in Node, the unpkg CDN
|
|
23
|
+
* default in an unconfigured browser bundle. A host that bundles its own
|
|
24
|
+
* copy of wasmoon's `glue.wasm` (e.g. the playground, to avoid a runtime
|
|
25
|
+
* dependency on the unpkg CDN) passes that local URL here instead — see
|
|
26
|
+
* `createEmptyLuaEngine`'s doc comment for the full rationale.
|
|
27
|
+
*/
|
|
28
|
+
wasmUri?: string;
|
|
29
|
+
}
|
|
30
|
+
export type RunScriptResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
value: unknown;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
error: ScriptFailure;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Runs one Lua script in a fresh, fully isolated sandbox and always tears
|
|
39
|
+
* the engine down before returning — a run never leaves state (globals,
|
|
40
|
+
* memory, hooks) for a later run to inherit. Never throws: every way
|
|
41
|
+
* hostile code can fail comes back as `{ ok: false, error }`, never a raw
|
|
42
|
+
* exception (see `./errors`).
|
|
43
|
+
*
|
|
44
|
+
* Orchestration, in order:
|
|
45
|
+
* 1. `./globals` — fresh engine, curated empty environment (no `os`/`io`/
|
|
46
|
+
* `require`/etc; see that module for exactly what's kept and why).
|
|
47
|
+
* 2. Memory cap (`engine.global.setMemoryMax`, backed by the
|
|
48
|
+
* `traceAllocations: true` custom allocator `./globals` requests).
|
|
49
|
+
* 3. `./capabilities` — build the `net`/`cache`/`bundle` Lua tables from
|
|
50
|
+
* whatever providers/grants/tier this call was given.
|
|
51
|
+
* 4. `./marshal` — inject the trusted node/depth-capped marshal walk that
|
|
52
|
+
* the wrapped user code's return value is piped through.
|
|
53
|
+
* 5. A dedicated child thread (NOT `engine.doString`, which creates its
|
|
54
|
+
* own internal thread we'd have no handle to — see `./limits`'s "hooks
|
|
55
|
+
* are per-thread" note) gets the instruction/wall-clock hook installed,
|
|
56
|
+
* then runs the wrapped user code.
|
|
57
|
+
* 6. The out-of-band breach flag from step 5's hook is checked
|
|
58
|
+
* UNCONDITIONALLY and, if set, wins over whatever the run otherwise
|
|
59
|
+
* reported — see `./limits`'s doc comment for why this is the actual
|
|
60
|
+
* enforcement point for "not swallowed by the script's own `pcall`".
|
|
61
|
+
* 7. Otherwise, a thrown error is classified by three non-spoofable
|
|
62
|
+
* out-of-band signals, in order, before ever falling back to
|
|
63
|
+
* `classifyRuntimeError`'s message-based path: the wall-clock guard's
|
|
64
|
+
* own `ScriptLimitError` sentinel (`instanceof`, Defect 3), then the
|
|
65
|
+
* raw `LuaReturn.ErrorMem` status code (Defect 2). A successful return
|
|
66
|
+
* goes through `finalizeMarshaledValue` for the final NaN/Infinity
|
|
67
|
+
* check and marker cleanup.
|
|
68
|
+
* 8. `finally`: hook removed, thread popped, engine closed — every path,
|
|
69
|
+
* including every early return above.
|
|
70
|
+
*/
|
|
71
|
+
export declare function runScript(options: RunScriptOptions): Promise<RunScriptResult>;
|