@questpie/sandbox 3.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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @questpie/sandbox
2
+
3
+ Hardened sandboxed code execution for QUESTPIE executor workloads. The package ships the HTTP adapter used by a QUESTPIE app and the Deno supervisor source that executes untrusted code in a separate process with capability-scoped network, import, file, and app bindings.
4
+
5
+ ## Features
6
+
7
+ - **HTTP executor adapter** - connect `ctx.executor.run()` to a standalone sandbox service.
8
+ - **Deno supervisor** - run guest code outside the main Bun/Node app process.
9
+ - **Capability-scoped bindings** - expose only declared collections, stores, files, and fetch access.
10
+ - **SSRF protection** - validate literal IPs, DNS rebinding, redirects, and private/metadata ranges.
11
+ - **Network isolation support** - Linux network namespace/firewall planning for defense in depth.
12
+ - **Structured failures** - timeouts, non-JSON responses, and network failures return predictable results.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bun add @questpie/sandbox
18
+ ```
19
+
20
+ ## Configure The Adapter
21
+
22
+ ```ts
23
+ // config/app.ts
24
+ import { httpSandboxAdapter } from "@questpie/sandbox/adapter";
25
+ import { appConfig } from "questpie";
26
+
27
+ export default appConfig({
28
+ executor: {
29
+ sandboxed: httpSandboxAdapter({
30
+ url: process.env.SANDBOX_URL,
31
+ }),
32
+ },
33
+ });
34
+ ```
35
+
36
+ Guest bindings require a host-side broker route in the app or control-plane layer. Compute-only sandboxed execution only needs the adapter above.
37
+
38
+ ## Run The Supervisor
39
+
40
+ The Deno supervisor source is published with the package:
41
+
42
+ ```bash
43
+ deno run --allow-net --allow-env --allow-run --allow-read --allow-write=$TMPDIR \
44
+ node_modules/@questpie/sandbox/src/sandbox-server.ts
45
+ ```
46
+
47
+ Set `SANDBOX_URL` in the app to the supervisor URL.
48
+
49
+ ## Exports
50
+
51
+ | Entry Point | Purpose |
52
+ | --------------------------- | ------------------------------------------ |
53
+ | `@questpie/sandbox` | Adapter, validation, and binding utilities |
54
+ | `@questpie/sandbox/adapter` | `httpSandboxAdapter()` only |
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,43 @@
1
+ import { ExecutorAdapter, ExecutorRunOptions, ExecutorRunResult } from "questpie/executor";
2
+
3
+ //#region src/adapter-http.d.ts
4
+ interface HttpSandboxAdapterOptions {
5
+ /**
6
+ * Base URL of the standalone Deno sandbox service (`sandbox-server.ts`).
7
+ * Defaults to `process.env.SANDBOX_URL`. Throws on `run` if neither is set.
8
+ */
9
+ url?: string;
10
+ /**
11
+ * Per-request HTTP fetch timeout (ms). A safety margin above the guest
12
+ * wall-timeout so the server's own kill fires first; if the server is
13
+ * unreachable this bounds the client wait. Default: guest timeout + 10s.
14
+ */
15
+ fetchTimeoutMs?: number;
16
+ /** Optional custom `fetch` (e.g. for auth headers / mTLS). */
17
+ fetch?: typeof fetch;
18
+ /**
19
+ * Validate net/import hosts against the private-IP policy BEFORE sending to
20
+ * the sandbox (defense-in-depth; the server validates too). Default: true.
21
+ */
22
+ validateEgress?: boolean;
23
+ }
24
+ /**
25
+ * Production sandboxed `ExecutorAdapter` — POSTs the run to the standalone Deno
26
+ * sandbox service over HTTP. The main app (Bun/Node) holds NO Deno; it only
27
+ * speaks this JSON contract.
28
+ *
29
+ * Use as the `sandboxed` adapter in the executor config:
30
+ * executor: { sandboxed: httpSandboxAdapter({ url: process.env.SANDBOX_URL }) }
31
+ */
32
+ declare class HttpSandboxAdapter implements ExecutorAdapter {
33
+ private readonly options;
34
+ private readonly fetchImpl;
35
+ private readonly validateEgress;
36
+ constructor(options?: HttpSandboxAdapterOptions);
37
+ private resolveUrl;
38
+ run(options: ExecutorRunOptions): Promise<ExecutorRunResult>;
39
+ }
40
+ /** Factory for the production sandboxed adapter. */
41
+ declare function httpSandboxAdapter(options?: HttpSandboxAdapterOptions): HttpSandboxAdapter;
42
+ //#endregion
43
+ export { HttpSandboxAdapterOptions as n, httpSandboxAdapter as r, HttpSandboxAdapter as t };
@@ -0,0 +1,298 @@
1
+ //#region src/net-validation.ts
2
+ /**
3
+ * Parse a `host[:port]` allowlist entry (Deno `--allow-net` syntax).
4
+ * Bracketed IPv6 (`[::1]:443`) and bare IPv6 (`::1`) are both handled.
5
+ */
6
+ function parseHostEntry(entry) {
7
+ const trimmed = entry.trim();
8
+ if (trimmed.startsWith("[")) {
9
+ const close = trimmed.indexOf("]");
10
+ if (close === -1) return { host: trimmed };
11
+ const host = trimmed.slice(1, close);
12
+ const rest = trimmed.slice(close + 1);
13
+ if (rest.startsWith(":")) {
14
+ const port = Number(rest.slice(1));
15
+ return Number.isInteger(port) ? {
16
+ host,
17
+ port
18
+ } : { host };
19
+ }
20
+ return { host };
21
+ }
22
+ if ((trimmed.match(/:/g)?.length ?? 0) > 1) return { host: trimmed };
23
+ const lastColon = trimmed.lastIndexOf(":");
24
+ if (lastColon !== -1) {
25
+ const maybePort = Number(trimmed.slice(lastColon + 1));
26
+ if (Number.isInteger(maybePort)) return {
27
+ host: trimmed.slice(0, lastColon),
28
+ port: maybePort
29
+ };
30
+ }
31
+ return { host: trimmed };
32
+ }
33
+ /** True if `s` is a syntactically-valid dotted-quad IPv4 literal. */
34
+ function isIpv4Literal(s) {
35
+ const parts = s.split(".");
36
+ if (parts.length !== 4) return false;
37
+ return parts.every((p) => {
38
+ if (!/^\d{1,3}$/.test(p)) return false;
39
+ const n = Number(p);
40
+ return n >= 0 && n <= 255;
41
+ });
42
+ }
43
+ /** True if `s` looks like an IPv6 literal (contains a colon, hex/`:`/`.` only). */
44
+ function isIpv6Literal(s) {
45
+ if (!s.includes(":")) return false;
46
+ return /^[0-9a-fA-F:.]+$/.test(s);
47
+ }
48
+ /**
49
+ * Classify an IPv4 literal as private/link-local/loopback/metadata/CGNAT.
50
+ * Ranges (all per the task + cloud-metadata convention):
51
+ * 0.0.0.0/8 (this-host), 127/8 (loopback), 10/8, 172.16/12, 192.168/16 (RFC-1918),
52
+ * 169.254/16 (link-local, incl. 169.254.169.254 metadata), 100.64/10 (CGNAT/RFC-6598).
53
+ */
54
+ function isPrivateIpv4(ip) {
55
+ const [a, b] = ip.split(".").map(Number);
56
+ if (a === 0) return true;
57
+ if (a === 127) return true;
58
+ if (a === 10) return true;
59
+ if (a === 172 && b >= 16 && b <= 31) return true;
60
+ if (a === 192 && b === 168) return true;
61
+ if (a === 169 && b === 254) return true;
62
+ if (a === 100 && b >= 64 && b <= 127) return true;
63
+ return false;
64
+ }
65
+ /** Normalize an IPv6 literal to lowercase, stripping a `%zone` suffix. */
66
+ function normalizeIpv6(ip) {
67
+ const pct = ip.indexOf("%");
68
+ return (pct === -1 ? ip : ip.slice(0, pct)).toLowerCase();
69
+ }
70
+ /**
71
+ * Classify an IPv6 literal as private/link-local/loopback/metadata.
72
+ * Covers: ::1 (loopback), :: (unspecified), fc00::/7 (unique-local),
73
+ * fe80::/10 (link-local), and IPv4-mapped/translated forms (::ffff:a.b.c.d,
74
+ * ::a.b.c.d) by delegating their embedded IPv4 to `isPrivateIpv4`.
75
+ */
76
+ function isPrivateIpv6(raw) {
77
+ const ip = normalizeIpv6(raw);
78
+ if (ip === "::1") return true;
79
+ if (ip === "::" || ip === "") return true;
80
+ const v4Tail = ip.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
81
+ if (v4Tail && isIpv4Literal(v4Tail[1])) {
82
+ if (ip.startsWith("::")) return isPrivateIpv4(v4Tail[1]);
83
+ }
84
+ const mapped = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
85
+ if (mapped) {
86
+ const hi = Number.parseInt(mapped[1], 16) & 65535;
87
+ const lo = Number.parseInt(mapped[2], 16) & 65535;
88
+ return isPrivateIpv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
89
+ }
90
+ if (/^f[cd][0-9a-f]{0,2}:/.test(ip) || ip.startsWith("fc") || ip.startsWith("fd")) {
91
+ const first = ip.split(":")[0];
92
+ if (first.length > 0) {
93
+ if ((Number.parseInt(first.padStart(4, "0").slice(0, 2), 16) & 254) === 252) return true;
94
+ }
95
+ }
96
+ if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) return true;
97
+ return false;
98
+ }
99
+ /**
100
+ * Classify a bare IP literal (v4 or v6) as a blocked egress target.
101
+ * Returns `{ ok:false, reason }` when the IP is private/link-local/loopback/metadata.
102
+ */
103
+ function classifyIpLiteral(ip) {
104
+ if (isIpv4Literal(ip)) return isPrivateIpv4(ip) ? {
105
+ ok: false,
106
+ reason: `private/link-local/loopback IPv4: ${ip}`
107
+ } : { ok: true };
108
+ if (isIpv6Literal(ip)) return isPrivateIpv6(ip) ? {
109
+ ok: false,
110
+ reason: `private/link-local/loopback IPv6: ${ip}`
111
+ } : { ok: true };
112
+ return { ok: true };
113
+ }
114
+ /** Bound each DNS lookup so a slow/hanging resolver can't stall validation. */
115
+ const DNS_TIMEOUT_MS = 3e3;
116
+ function withDnsTimeout(p) {
117
+ return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`DNS lookup timed out (${DNS_TIMEOUT_MS}ms)`)), DNS_TIMEOUT_MS))]);
118
+ }
119
+ /** Lazily load `node:dns/promises` `resolve` (works on both Bun and Deno). */
120
+ let cachedResolver;
121
+ async function defaultResolver(hostname) {
122
+ if (!cachedResolver) {
123
+ const dns = await import("node:dns/promises");
124
+ cachedResolver = async (h) => {
125
+ const [v4, v6] = await Promise.allSettled([withDnsTimeout(dns.resolve4(h)), withDnsTimeout(dns.resolve6(h))]);
126
+ const out = [];
127
+ if (v4.status === "fulfilled") out.push(...v4.value);
128
+ if (v6.status === "fulfilled") out.push(...v6.value);
129
+ if (out.length === 0 && v4.status === "rejected" && v6.status === "rejected") throw new Error(`DNS resolution failed (v4: ${reasonOf(v4)}, v6: ${reasonOf(v6)})`);
130
+ return out;
131
+ };
132
+ }
133
+ return cachedResolver(hostname);
134
+ }
135
+ function reasonOf(settled) {
136
+ const r = settled.reason;
137
+ return r instanceof Error ? r.message : String(r);
138
+ }
139
+ /**
140
+ * Validate a single `host[:port]` allowlist entry against the private-IP policy.
141
+ *
142
+ * - IP literals are classified directly (no DNS).
143
+ * - Hostnames are DNS-resolved; if ANY resolved address is private/link-local/
144
+ * loopback/metadata, the host is REJECTED (DNS-rebind defense at manifest time).
145
+ * - `localhost` and the `.local` mDNS suffix are rejected up-front.
146
+ *
147
+ * `resolve` is injectable for tests / Deno; defaults to `node:dns/promises`.
148
+ */
149
+ async function validateHostEgress(entry, opts = {}) {
150
+ const { host } = parseHostEntry(entry);
151
+ const lower = host.toLowerCase();
152
+ if (lower.length === 0) return {
153
+ ok: false,
154
+ reason: "empty host"
155
+ };
156
+ if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) return {
157
+ ok: false,
158
+ reason: `loopback/mDNS hostname not allowed: ${host}`
159
+ };
160
+ const literal = classifyIpLiteral(host);
161
+ if (!literal.ok) return literal;
162
+ if (isIpv4Literal(host) || isIpv6Literal(host)) return { ok: true };
163
+ const resolver = opts.resolve ?? defaultResolver;
164
+ let addrs;
165
+ try {
166
+ addrs = await resolver(host);
167
+ } catch (err) {
168
+ return {
169
+ ok: false,
170
+ reason: `DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`
171
+ };
172
+ }
173
+ if (addrs.length === 0) return {
174
+ ok: false,
175
+ reason: `DNS resolution returned no addresses for ${host}`
176
+ };
177
+ for (const addr of addrs) {
178
+ const c = classifyIpLiteral(addr);
179
+ if (!c.ok) return {
180
+ ok: false,
181
+ reason: `${host} resolves to a blocked address (${c.reason})`
182
+ };
183
+ }
184
+ return { ok: true };
185
+ }
186
+ /**
187
+ * Validate ALL `net` + `import` hosts in a capability set.
188
+ * Returns the first rejection found, or `{ ok:true }` if every host passes.
189
+ */
190
+ async function validateEgressHosts(hosts, opts = {}) {
191
+ for (const entry of hosts) {
192
+ const r = await validateHostEgress(entry, opts);
193
+ if (!r.ok) return {
194
+ ok: false,
195
+ reason: `host "${entry}": ${r.reason}`
196
+ };
197
+ }
198
+ return { ok: true };
199
+ }
200
+
201
+ //#endregion
202
+ //#region src/adapter-http.ts
203
+ const DEFAULT_GUEST_TIMEOUT_MS = 5e3;
204
+ /**
205
+ * Production sandboxed `ExecutorAdapter` — POSTs the run to the standalone Deno
206
+ * sandbox service over HTTP. The main app (Bun/Node) holds NO Deno; it only
207
+ * speaks this JSON contract.
208
+ *
209
+ * Use as the `sandboxed` adapter in the executor config:
210
+ * executor: { sandboxed: httpSandboxAdapter({ url: process.env.SANDBOX_URL }) }
211
+ */
212
+ var HttpSandboxAdapter = class {
213
+ fetchImpl;
214
+ validateEgress;
215
+ constructor(options = {}) {
216
+ this.options = options;
217
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
218
+ this.validateEgress = options.validateEgress ?? true;
219
+ }
220
+ resolveUrl() {
221
+ const url = this.options.url ?? (typeof process !== "undefined" ? process.env?.SANDBOX_URL : void 0);
222
+ if (!url) throw new Error("HttpSandboxAdapter: no sandbox URL configured. Pass `url` or set SANDBOX_URL.");
223
+ return url.replace(/\/$/, "");
224
+ }
225
+ async run(options) {
226
+ const base = this.resolveUrl();
227
+ const caps = options.capabilities ?? {};
228
+ const net = caps.net ?? [];
229
+ const importHosts = caps.import ?? [];
230
+ const timeoutMs = caps.timeoutMs ?? DEFAULT_GUEST_TIMEOUT_MS;
231
+ if (this.validateEgress) {
232
+ const egress = await validateEgressHosts([...net, ...importHosts]);
233
+ if (!egress.ok) return {
234
+ ok: false,
235
+ error: `egress blocked: ${egress.reason}`,
236
+ logs: []
237
+ };
238
+ }
239
+ const sandboxCaps = {
240
+ net,
241
+ import: importHosts,
242
+ timeoutMs,
243
+ memoryMb: caps.memoryMb ?? 128
244
+ };
245
+ const fetchTimeoutMs = this.options.fetchTimeoutMs ?? timeoutMs + 1e4;
246
+ const controller = new AbortController();
247
+ const abortTimer = setTimeout(() => controller.abort(), fetchTimeoutMs);
248
+ try {
249
+ const bindings = options.sandboxBindings;
250
+ const res = await this.fetchImpl(`${base}/run`, {
251
+ method: "POST",
252
+ headers: { "content-type": "application/json" },
253
+ body: JSON.stringify({
254
+ source: options.source,
255
+ input: options.input ?? null,
256
+ capabilities: sandboxCaps,
257
+ secrets: options.secrets ?? {},
258
+ ...bindings ? { bindings } : {}
259
+ }),
260
+ signal: controller.signal
261
+ });
262
+ const text = await res.text();
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(text);
266
+ } catch {
267
+ return {
268
+ ok: false,
269
+ error: `sandbox returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
270
+ logs: []
271
+ };
272
+ }
273
+ return {
274
+ ok: !!parsed.ok,
275
+ output: parsed.output,
276
+ error: parsed.error,
277
+ logs: Array.isArray(parsed.logs) ? parsed.logs : [],
278
+ timedOut: parsed.timedOut,
279
+ ms: parsed.ms
280
+ };
281
+ } catch (err) {
282
+ return {
283
+ ok: false,
284
+ error: err instanceof Error && err.name === "AbortError" ? `sandbox request timed out after ${fetchTimeoutMs}ms` : `sandbox request failed: ${err instanceof Error ? err.message : String(err)}`,
285
+ logs: []
286
+ };
287
+ } finally {
288
+ clearTimeout(abortTimer);
289
+ }
290
+ }
291
+ };
292
+ /** Factory for the production sandboxed adapter. */
293
+ function httpSandboxAdapter(options = {}) {
294
+ return new HttpSandboxAdapter(options);
295
+ }
296
+
297
+ //#endregion
298
+ export { validateEgressHosts as a, parseHostEntry as i, httpSandboxAdapter as n, validateHostEgress as o, classifyIpLiteral as r, HttpSandboxAdapter as t };
@@ -0,0 +1,2 @@
1
+ import { n as HttpSandboxAdapterOptions, r as httpSandboxAdapter, t as HttpSandboxAdapter } from "./adapter-http-7ypWHBck.mjs";
2
+ export { HttpSandboxAdapter, type HttpSandboxAdapterOptions, httpSandboxAdapter };
@@ -0,0 +1,3 @@
1
+ import { n as httpSandboxAdapter, t as HttpSandboxAdapter } from "./adapter-http-w7SjAfcC.mjs";
2
+
3
+ export { HttpSandboxAdapter, httpSandboxAdapter };
@@ -0,0 +1,280 @@
1
+ import { n as HttpSandboxAdapterOptions, r as httpSandboxAdapter, t as HttpSandboxAdapter } from "./adapter-http-7ypWHBck.mjs";
2
+
3
+ //#region src/net-validation.d.ts
4
+
5
+ /**
6
+ * Network egress validation — SSRF / DNS-rebind defense for sandboxed guests.
7
+ *
8
+ * The sandbox grants each guest a per-request `net`/`import` allowlist of
9
+ * `host[:port]` entries (driving Deno's `--allow-net` / `--allow-import`).
10
+ * Deno's allowlist is a hostname-STRING match with NO IP validation, so an
11
+ * allowlisted domain that resolves to a private/link-local/loopback/metadata
12
+ * address (e.g. `169.254.169.254`, `127.0.0.1`, RFC-1918) would still connect.
13
+ * This module rejects those at MANIFEST-VALIDATION time.
14
+ *
15
+ * Runtime-agnostic: the DNS resolver is injectable so this same code runs under
16
+ * Bun/Node (manifest validation in the adapter) and Deno (the sandbox server).
17
+ * It defaults to `node:dns/promises`, which both Bun and Deno implement.
18
+ *
19
+ * SCOPE (M2): manifest-time rejection of private-IP hosts is implemented and
20
+ * enforced here. Full connect-time DNS-rebind PINNING (re-resolving and
21
+ * re-checking the exact IP the socket connects to, including across redirects)
22
+ * is NOT yet implemented — see `TODO(security)` in `sandbox-server.ts`.
23
+ */
24
+ /** A single parsed `host[:port]` allowlist entry. */
25
+ interface ParsedHost {
26
+ /** Bare hostname or IP literal (IPv6 without brackets). */
27
+ host: string;
28
+ /** Port if specified, else undefined. */
29
+ port?: number;
30
+ }
31
+ interface IpValidationResult {
32
+ ok: boolean;
33
+ /** Reason the host was rejected (only set when `ok` is false). */
34
+ reason?: string;
35
+ }
36
+ /** Async DNS resolver shape (subset of `node:dns/promises`). */
37
+ type DnsResolver = (hostname: string) => Promise<string[]>;
38
+ /**
39
+ * Parse a `host[:port]` allowlist entry (Deno `--allow-net` syntax).
40
+ * Bracketed IPv6 (`[::1]:443`) and bare IPv6 (`::1`) are both handled.
41
+ */
42
+ declare function parseHostEntry(entry: string): ParsedHost;
43
+ /**
44
+ * Classify a bare IP literal (v4 or v6) as a blocked egress target.
45
+ * Returns `{ ok:false, reason }` when the IP is private/link-local/loopback/metadata.
46
+ */
47
+ declare function classifyIpLiteral(ip: string): IpValidationResult;
48
+ /**
49
+ * Validate a single `host[:port]` allowlist entry against the private-IP policy.
50
+ *
51
+ * - IP literals are classified directly (no DNS).
52
+ * - Hostnames are DNS-resolved; if ANY resolved address is private/link-local/
53
+ * loopback/metadata, the host is REJECTED (DNS-rebind defense at manifest time).
54
+ * - `localhost` and the `.local` mDNS suffix are rejected up-front.
55
+ *
56
+ * `resolve` is injectable for tests / Deno; defaults to `node:dns/promises`.
57
+ */
58
+ declare function validateHostEgress(entry: string, opts?: {
59
+ resolve?: DnsResolver;
60
+ }): Promise<IpValidationResult>;
61
+ /**
62
+ * Validate ALL `net` + `import` hosts in a capability set.
63
+ * Returns the first rejection found, or `{ ok:true }` if every host passes.
64
+ */
65
+ declare function validateEgressHosts(hosts: readonly string[], opts?: {
66
+ resolve?: DnsResolver;
67
+ }): Promise<IpValidationResult>;
68
+ //#endregion
69
+ //#region src/types.d.ts
70
+ /**
71
+ * Wire contract between the `executor` adapter (Bun/Node, main app) and the
72
+ * standalone Deno sandbox server. Kept intentionally minimal: the main app
73
+ * never imports Deno; it only speaks this JSON over HTTP.
74
+ */
75
+ /**
76
+ * Per-request guest capabilities. `net` and `import` are INDEPENDENT axes
77
+ * (Deno enforces them separately — `--allow-net` gates `fetch`, `--allow-import`
78
+ * gates module imports). The richer app-binding capabilities (`data`, `services`,
79
+ * etc.) live on the core `ExecutorCapabilities` type and are NOT enforced by the
80
+ * sandbox server — they are the later bindings-broker's job.
81
+ */
82
+ interface SandboxCapabilities {
83
+ /** Runtime `fetch()` allowlist as `host[:port]`. Empty = no network. */
84
+ net: string[];
85
+ /** Module import allowlist as `host[:port]` (e.g. `esm.sh:443`). Empty = no remote imports. */
86
+ import: string[];
87
+ /** Hard wall-clock timeout; the subprocess is force-killed on exceed. */
88
+ timeoutMs: number;
89
+ /** Hard per-guest memory bound (`--v8-flags=--max-old-space-size`). */
90
+ memoryMb: number;
91
+ }
92
+ /**
93
+ * App-bindings wiring for the UNTRUSTED path. When present, the supervisor
94
+ * exposes a capability-scoped `globalThis.questpie` proxy to the guest whose
95
+ * methods RPC (over STDIO, never the network) to the host broker at {@link url},
96
+ * authenticated by the per-run scoped {@link token}.
97
+ *
98
+ * The token is OPAQUE and is held by the SUPERVISOR only — it is never written
99
+ * into the guest subprocess, so guest code cannot read or forge it to widen
100
+ * scope. The guest only ever sees the typed proxy.
101
+ *
102
+ * Critically, the broker is reached SERVER-TO-SERVER (supervisor → app), NOT by
103
+ * the guest's `fetch`: the guest's egress allowlist rejects loopback/private IPs
104
+ * (the SSRF defense), so a localhost broker is unreachable from guest code by
105
+ * design — we do not poke a hole in that.
106
+ */
107
+ interface SandboxBindings {
108
+ /** Absolute URL of the host broker endpoint (e.g. `https://app/sandbox/rpc`). */
109
+ url: string;
110
+ /** Per-run opaque scoped token. Supervisor-only; never exposed to the guest. */
111
+ token: string;
112
+ }
113
+ /** POST /run request body. */
114
+ interface SandboxRunRequest {
115
+ /** Guest TypeScript source. Must `export default` a `function(input)`. */
116
+ source: string;
117
+ /** Payload passed to the guest entry function. */
118
+ input: unknown;
119
+ capabilities: SandboxCapabilities;
120
+ /**
121
+ * Secrets injected into the guest (NOT in source). Surfaced to the guest as
122
+ * `globalThis.__secrets`. The sandbox never persists these.
123
+ */
124
+ secrets?: Record<string, string>;
125
+ /**
126
+ * App-bindings broker wiring. When omitted, the guest gets no `questpie`
127
+ * proxy (the legacy compute-only path). When present, the supervisor brokers
128
+ * the guest's binding RPCs to the host. See {@link SandboxBindings}.
129
+ */
130
+ bindings?: SandboxBindings;
131
+ }
132
+ /** POST /run response body — also the `ExecutorAdapter.run` result shape. */
133
+ interface SandboxRunResult {
134
+ ok: boolean;
135
+ output?: unknown;
136
+ logs: string[];
137
+ error?: string;
138
+ /** True when the run was killed by the wall-clock timeout. */
139
+ timedOut?: boolean;
140
+ /** Wall-clock duration in ms (server-measured). */
141
+ ms?: number;
142
+ }
143
+ /** Line prefix marking a framed protocol message on guest stdout. */
144
+ declare const FRAME_MARKER = "__QP_SANDBOX_MSG__";
145
+ /**
146
+ * Header carrying the per-run scoped token on the supervisor → broker request.
147
+ * Mirrors `questpie`'s `BINDINGS_TOKEN_HEADER`. Supervisor-only; never set by or
148
+ * exposed to guest code.
149
+ */
150
+ declare const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
151
+ /** Structured error returned by the broker (mirrors `questpie` `BindingError`). */
152
+ interface SandboxBindingError {
153
+ code: string;
154
+ message: string;
155
+ }
156
+ /** Guest → supervisor: a binding RPC awaiting a brokered result. */
157
+ interface GuestRpcMessage {
158
+ type: "rpc";
159
+ id: number;
160
+ method: string;
161
+ args: unknown;
162
+ }
163
+ /** Guest → supervisor: the run's final result. */
164
+ interface GuestResultMessage {
165
+ type: "result";
166
+ ok: boolean;
167
+ output?: unknown;
168
+ error?: string;
169
+ logs: string[];
170
+ }
171
+ /** Any message the guest emits on stdout (framed). */
172
+ type GuestMessage = GuestRpcMessage | GuestResultMessage;
173
+ /** Supervisor → guest: the brokered outcome of a prior {@link GuestRpcMessage}. */
174
+ interface HostRpcResultMessage {
175
+ type: "rpc-result";
176
+ id: number;
177
+ ok: boolean;
178
+ value?: unknown;
179
+ error?: SandboxBindingError;
180
+ }
181
+ //#endregion
182
+ //#region src/guest-bindings.d.ts
183
+ /**
184
+ * Guest bindings proxy — the UNTRUSTED-side typed surface a sandboxed mini-app
185
+ * uses to call the app's primitives WITHOUT importing the app.
186
+ *
187
+ * Design: `.private/knowledge-miniapps-mvp.md` §13 (TRUSTED vs UNTRUSTED) + §14
188
+ * (the primitive surface). Mirrors the server `ctx` names (consistent-naming
189
+ * principle) so guest code reads like a server handler:
190
+ *
191
+ * const orders = await globalThis.questpie.collections.orders.find({ where });
192
+ * const note = await globalThis.questpie.files.read({ path });
193
+ *
194
+ * Every method just does `await hostCall(method, args)`. `hostCall` is INJECTED
195
+ * (it writes a framed RPC to the supervisor over stdio and awaits the brokered
196
+ * result) — this module holds NO transport and NO secrets, so it is pure and
197
+ * unit-testable with a fake `hostCall`. The guest never sees the per-run token
198
+ * or a broker URL; the only thing it can do is call these methods, which the
199
+ * trusted host broker enforces per call (default-deny).
200
+ *
201
+ * This file is plain TS (no Deno globals) so it typechecks + unit-tests in Bun;
202
+ * `guest-entry.ts` (Deno) imports it and supplies the real stdio `hostCall`.
203
+ */
204
+ /** Transport: send one binding RPC, resolve with its brokered value or reject. */
205
+ type HostCall = (method: string, args: unknown) => Promise<unknown>;
206
+ /**
207
+ * Per-collection access surface. Reads (`find`/`findOne`) are always available;
208
+ * writes (`create`/`update`/`delete`) are dispatched ONLY where the host wires a
209
+ * safe handler (the `document_store` store-grant clamp, or a collection with an
210
+ * explicit write rule — Decision 8 / §7). An ungranted/unwired call rejects host-side.
211
+ */
212
+ interface GuestCollection {
213
+ find(args?: unknown): Promise<unknown>;
214
+ findOne(args?: unknown): Promise<unknown>;
215
+ create(args?: unknown): Promise<unknown>;
216
+ update(args?: unknown): Promise<unknown>;
217
+ delete(args?: unknown): Promise<unknown>;
218
+ }
219
+ /**
220
+ * Sugar surface for ONE `document_store` namespace. Every op auto-injects
221
+ * `store: <name>`; the host still clamps to the run's granted stores (the sugar is
222
+ * a convenience, NOT a second enforcement path — `questpie.store.posts.create(x)`
223
+ * is exactly `questpie.collections.document_store.create({ ...x, store:"posts" })`).
224
+ */
225
+ interface GuestStore {
226
+ find(args?: Record<string, unknown>): Promise<unknown>;
227
+ findOne(args?: Record<string, unknown>): Promise<unknown>;
228
+ create(args: {
229
+ key: string;
230
+ data?: unknown;
231
+ }): Promise<unknown>;
232
+ update(args: {
233
+ where?: unknown;
234
+ data?: Record<string, unknown>;
235
+ }): Promise<unknown>;
236
+ delete(args?: {
237
+ where?: unknown;
238
+ }): Promise<unknown>;
239
+ }
240
+ /** The `globalThis.questpie` surface handed to untrusted guest code. */
241
+ interface GuestBindings {
242
+ /** Files (file-as-DB), scoped by the run's `capabilities.files`. */
243
+ files: {
244
+ read(args: {
245
+ path: string;
246
+ scope?: unknown;
247
+ }): Promise<unknown>;
248
+ write(args: {
249
+ path: string;
250
+ content: string;
251
+ title?: string;
252
+ mime_type?: string;
253
+ scope?: unknown;
254
+ }): Promise<unknown>;
255
+ list(args?: {
256
+ path?: string;
257
+ scope?: unknown;
258
+ }): Promise<unknown>;
259
+ };
260
+ /** Per-collection read/write access, scoped by `capabilities.data.collections`. */
261
+ collections: Record<string, GuestCollection>;
262
+ /**
263
+ * Per-namespace `document_store` sugar, scoped by `capabilities.data.stores`.
264
+ * `questpie.store.<name>.<op>` injects `store:<name>` onto the underlying
265
+ * `collections.document_store.<op>` call.
266
+ */
267
+ store: Record<string, GuestStore>;
268
+ }
269
+ /**
270
+ * Build the guest bindings proxy over an injected {@link HostCall}.
271
+ *
272
+ * `collections` is a Proxy so any collection NAME resolves to a `{ find,
273
+ * findOne }` shim — capability enforcement for the specific collection happens
274
+ * HOST-SIDE in the broker (the guest can't pre-know which names are granted, and
275
+ * the proxy must not leak that). An out-of-scope collection call simply rejects
276
+ * when the host denies it.
277
+ */
278
+ declare function buildGuestBindings(hostCall: HostCall): GuestBindings;
279
+ //#endregion
280
+ export { BINDINGS_TOKEN_HEADER, type DnsResolver, FRAME_MARKER, type GuestBindings, type GuestCollection, type GuestMessage, type GuestResultMessage, type GuestRpcMessage, type HostCall, type HostRpcResultMessage, HttpSandboxAdapter, type HttpSandboxAdapterOptions, type IpValidationResult, type ParsedHost, type SandboxBindingError, type SandboxBindings, type SandboxCapabilities, type SandboxRunRequest, type SandboxRunResult, buildGuestBindings, classifyIpLiteral, httpSandboxAdapter, parseHostEntry, validateEgressHosts, validateHostEgress };