@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 +58 -0
- package/dist/adapter-http-7ypWHBck.d.mts +43 -0
- package/dist/adapter-http-w7SjAfcC.mjs +298 -0
- package/dist/adapter.d.mts +2 -0
- package/dist/adapter.mjs +3 -0
- package/dist/index.d.mts +280 -0
- package/dist/index.mjs +69 -0
- package/package.json +71 -0
- package/src/egress-firewall.ts +330 -0
- package/src/guest-bindings.ts +146 -0
- package/src/guest-entry.ts +446 -0
- package/src/net-validation.ts +359 -0
- package/src/sandbox-server.ts +682 -0
- package/src/server-internals.ts +127 -0
- package/src/types.ts +132 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure supervisor helpers extracted from `sandbox-server.ts`.
|
|
3
|
+
*
|
|
4
|
+
* This file contains NO Deno globals and NO top-level side effects, so it is
|
|
5
|
+
* importable by BOTH the Deno sandbox server (over a `./server-internals.ts`
|
|
6
|
+
* specifier) AND `bun:test` (over `../src/server-internals.js`). It lands inside
|
|
7
|
+
* the package's `tsconfig` `include` and MUST typecheck clean — keep it plain TS.
|
|
8
|
+
*
|
|
9
|
+
* Behavior is identical to the closures these were lifted from: the two functions
|
|
10
|
+
* that previously read module-level env-derived state (`brokerUrlRejection` →
|
|
11
|
+
* `EXPECTED_BROKER_URL`, `extractResult` → `RESULT_MARKER`) now take that value as
|
|
12
|
+
* a parameter, and the server's single call sites pass the SAME values.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Marker prefixed on the guest's final JSON result line on stdout. */
|
|
16
|
+
export const RESULT_MARKER = "__QP_SANDBOX_RESULT__";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Deno's BUILT-IN default `--allow-import` allowlist, applied when the flag is
|
|
20
|
+
* OMITTED. Omitting `--allow-import` does NOT mean "deny" — it silently grants
|
|
21
|
+
* these 7 hosts (incl. esm.sh). So when a guest's import allowlist is empty we
|
|
22
|
+
* must EXPLICITLY `--deny-import` these, or the guest could import from them.
|
|
23
|
+
* (Verified on Deno 2.7.8: bare `--deny-import` is a no-op; the value form works.)
|
|
24
|
+
*/
|
|
25
|
+
export const DENO_DEFAULT_IMPORT_HOSTS = [
|
|
26
|
+
"deno.land:443",
|
|
27
|
+
"jsr.io:443",
|
|
28
|
+
"esm.sh:443",
|
|
29
|
+
"raw.esm.sh:443",
|
|
30
|
+
"cdn.jsdelivr.net:443",
|
|
31
|
+
"raw.githubusercontent.com:443",
|
|
32
|
+
"gist.githubusercontent.com:443",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run outcome (the result shape WITHOUT the server-measured `ms`). Defined here
|
|
37
|
+
* as a single local interface so the server can import ONE definition. It is
|
|
38
|
+
* structurally `Omit<SandboxRunResult, "ms">`.
|
|
39
|
+
*/
|
|
40
|
+
export interface RunOutcome {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
output?: unknown;
|
|
43
|
+
logs: string[];
|
|
44
|
+
error?: string;
|
|
45
|
+
timedOut?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Normalize a broker URL for comparison (drop a trailing slash; ignore case of origin). */
|
|
49
|
+
export function normalizeBrokerUrl(raw: string): string | null {
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(raw);
|
|
52
|
+
// Compare origin (lowercased by URL) + pathname without a trailing slash.
|
|
53
|
+
const path = u.pathname.replace(/\/$/, "");
|
|
54
|
+
return `${u.origin}${path}`;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reject a guest's bindings target URL unless it matches the supervisor's
|
|
62
|
+
* configured expected broker URL. Returns an error string to surface, or
|
|
63
|
+
* `null` when the URL is acceptable (or no expectation is configured).
|
|
64
|
+
*/
|
|
65
|
+
export function brokerUrlRejection(url: unknown, expected: string | undefined): string | null {
|
|
66
|
+
if (!expected) return null; // not configured → skip (back-compat)
|
|
67
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
68
|
+
return "bindings.url is missing";
|
|
69
|
+
}
|
|
70
|
+
const got = normalizeBrokerUrl(url);
|
|
71
|
+
const want = normalizeBrokerUrl(expected);
|
|
72
|
+
if (got === null) return `bindings.url is not a valid URL: ${url}`;
|
|
73
|
+
if (got !== want) {
|
|
74
|
+
return `bindings.url does not match the configured broker URL`;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function clampInt(v: unknown, def: number, min: number, max: number): number {
|
|
80
|
+
const n = Number(v);
|
|
81
|
+
if (!Number.isFinite(n)) return def;
|
|
82
|
+
return Math.min(Math.max(Math.round(n), min), max);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the `--allow-net` flag for a guest's net allowlist.
|
|
87
|
+
* Omitting it = no network (verified: NotCapable), so empty → no flag.
|
|
88
|
+
*/
|
|
89
|
+
export function netFlag(hosts: string[]): string[] {
|
|
90
|
+
const cleaned = hosts.map((h) => h.trim()).filter((h) => h.length > 0);
|
|
91
|
+
return cleaned.length ? [`--allow-net=${cleaned.join(",")}`] : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the import-permission flags for a guest's import allowlist.
|
|
96
|
+
* - Non-empty: `--allow-import=<hosts>` — an explicit value REPLACES Deno's
|
|
97
|
+
* defaults (verified), so only these hosts are importable.
|
|
98
|
+
* - Empty: omit `--allow-import` AND `--deny-import=<defaults>` to neutralize
|
|
99
|
+
* the implicit default hosts → no remote imports at all.
|
|
100
|
+
*/
|
|
101
|
+
export function importFlags(hosts: string[]): string[] {
|
|
102
|
+
const cleaned = hosts.map((h) => h.trim()).filter((h) => h.length > 0);
|
|
103
|
+
if (cleaned.length) {
|
|
104
|
+
return [`--allow-import=${cleaned.join(",")}`];
|
|
105
|
+
}
|
|
106
|
+
return [`--deny-import=${DENO_DEFAULT_IMPORT_HOSTS.join(",")}`];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Pull the marked JSON result line out of the guest's stdout. */
|
|
110
|
+
export function extractResult(stdout: string, marker: string): RunOutcome | null {
|
|
111
|
+
const idx = stdout.lastIndexOf(marker);
|
|
112
|
+
if (idx === -1) return null;
|
|
113
|
+
const after = stdout.slice(idx + marker.length);
|
|
114
|
+
const newline = after.indexOf("\n");
|
|
115
|
+
const json = newline === -1 ? after : after.slice(0, newline);
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(json);
|
|
118
|
+
return {
|
|
119
|
+
ok: !!parsed.ok,
|
|
120
|
+
output: parsed.output,
|
|
121
|
+
error: parsed.error,
|
|
122
|
+
logs: Array.isArray(parsed.logs) ? parsed.logs : [],
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract between the `executor` adapter (Bun/Node, main app) and the
|
|
3
|
+
* standalone Deno sandbox server. Kept intentionally minimal: the main app
|
|
4
|
+
* never imports Deno; it only speaks this JSON over HTTP.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-request guest capabilities. `net` and `import` are INDEPENDENT axes
|
|
9
|
+
* (Deno enforces them separately — `--allow-net` gates `fetch`, `--allow-import`
|
|
10
|
+
* gates module imports). The richer app-binding capabilities (`data`, `services`,
|
|
11
|
+
* etc.) live on the core `ExecutorCapabilities` type and are NOT enforced by the
|
|
12
|
+
* sandbox server — they are the later bindings-broker's job.
|
|
13
|
+
*/
|
|
14
|
+
export interface SandboxCapabilities {
|
|
15
|
+
/** Runtime `fetch()` allowlist as `host[:port]`. Empty = no network. */
|
|
16
|
+
net: string[];
|
|
17
|
+
/** Module import allowlist as `host[:port]` (e.g. `esm.sh:443`). Empty = no remote imports. */
|
|
18
|
+
import: string[];
|
|
19
|
+
/** Hard wall-clock timeout; the subprocess is force-killed on exceed. */
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
/** Hard per-guest memory bound (`--v8-flags=--max-old-space-size`). */
|
|
22
|
+
memoryMb: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* App-bindings wiring for the UNTRUSTED path. When present, the supervisor
|
|
27
|
+
* exposes a capability-scoped `globalThis.questpie` proxy to the guest whose
|
|
28
|
+
* methods RPC (over STDIO, never the network) to the host broker at {@link url},
|
|
29
|
+
* authenticated by the per-run scoped {@link token}.
|
|
30
|
+
*
|
|
31
|
+
* The token is OPAQUE and is held by the SUPERVISOR only — it is never written
|
|
32
|
+
* into the guest subprocess, so guest code cannot read or forge it to widen
|
|
33
|
+
* scope. The guest only ever sees the typed proxy.
|
|
34
|
+
*
|
|
35
|
+
* Critically, the broker is reached SERVER-TO-SERVER (supervisor → app), NOT by
|
|
36
|
+
* the guest's `fetch`: the guest's egress allowlist rejects loopback/private IPs
|
|
37
|
+
* (the SSRF defense), so a localhost broker is unreachable from guest code by
|
|
38
|
+
* design — we do not poke a hole in that.
|
|
39
|
+
*/
|
|
40
|
+
export interface SandboxBindings {
|
|
41
|
+
/** Absolute URL of the host broker endpoint (e.g. `https://app/sandbox/rpc`). */
|
|
42
|
+
url: string;
|
|
43
|
+
/** Per-run opaque scoped token. Supervisor-only; never exposed to the guest. */
|
|
44
|
+
token: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** POST /run request body. */
|
|
48
|
+
export interface SandboxRunRequest {
|
|
49
|
+
/** Guest TypeScript source. Must `export default` a `function(input)`. */
|
|
50
|
+
source: string;
|
|
51
|
+
/** Payload passed to the guest entry function. */
|
|
52
|
+
input: unknown;
|
|
53
|
+
capabilities: SandboxCapabilities;
|
|
54
|
+
/**
|
|
55
|
+
* Secrets injected into the guest (NOT in source). Surfaced to the guest as
|
|
56
|
+
* `globalThis.__secrets`. The sandbox never persists these.
|
|
57
|
+
*/
|
|
58
|
+
secrets?: Record<string, string>;
|
|
59
|
+
/**
|
|
60
|
+
* App-bindings broker wiring. When omitted, the guest gets no `questpie`
|
|
61
|
+
* proxy (the legacy compute-only path). When present, the supervisor brokers
|
|
62
|
+
* the guest's binding RPCs to the host. See {@link SandboxBindings}.
|
|
63
|
+
*/
|
|
64
|
+
bindings?: SandboxBindings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** POST /run response body — also the `ExecutorAdapter.run` result shape. */
|
|
68
|
+
export interface SandboxRunResult {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
output?: unknown;
|
|
71
|
+
logs: string[];
|
|
72
|
+
error?: string;
|
|
73
|
+
/** True when the run was killed by the wall-clock timeout. */
|
|
74
|
+
timedOut?: boolean;
|
|
75
|
+
/** Wall-clock duration in ms (server-measured). */
|
|
76
|
+
ms?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// FRAMED stdio protocol (guest subprocess ⇄ supervisor) for the bindings path.
|
|
81
|
+
//
|
|
82
|
+
// Mirrors `questpie`'s executor `protocol.ts` (the sandbox package ships as Deno
|
|
83
|
+
// source and must NOT import the framework — same reason `SandboxCapabilities`
|
|
84
|
+
// is duplicated here). When `bindings` is present the guest emits ONE JSON line
|
|
85
|
+
// per message on stdout, prefixed by FRAME_MARKER; the supervisor demuxes by
|
|
86
|
+
// `type` and replies to "rpc" with an "rpc-result" on the guest's stdin.
|
|
87
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** Line prefix marking a framed protocol message on guest stdout. */
|
|
90
|
+
export const FRAME_MARKER = "__QP_SANDBOX_MSG__";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Header carrying the per-run scoped token on the supervisor → broker request.
|
|
94
|
+
* Mirrors `questpie`'s `BINDINGS_TOKEN_HEADER`. Supervisor-only; never set by or
|
|
95
|
+
* exposed to guest code.
|
|
96
|
+
*/
|
|
97
|
+
export const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
|
|
98
|
+
|
|
99
|
+
/** Structured error returned by the broker (mirrors `questpie` `BindingError`). */
|
|
100
|
+
export interface SandboxBindingError {
|
|
101
|
+
code: string;
|
|
102
|
+
message: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Guest → supervisor: a binding RPC awaiting a brokered result. */
|
|
106
|
+
export interface GuestRpcMessage {
|
|
107
|
+
type: "rpc";
|
|
108
|
+
id: number;
|
|
109
|
+
method: string;
|
|
110
|
+
args: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Guest → supervisor: the run's final result. */
|
|
114
|
+
export interface GuestResultMessage {
|
|
115
|
+
type: "result";
|
|
116
|
+
ok: boolean;
|
|
117
|
+
output?: unknown;
|
|
118
|
+
error?: string;
|
|
119
|
+
logs: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Any message the guest emits on stdout (framed). */
|
|
123
|
+
export type GuestMessage = GuestRpcMessage | GuestResultMessage;
|
|
124
|
+
|
|
125
|
+
/** Supervisor → guest: the brokered outcome of a prior {@link GuestRpcMessage}. */
|
|
126
|
+
export interface HostRpcResultMessage {
|
|
127
|
+
type: "rpc-result";
|
|
128
|
+
id: number;
|
|
129
|
+
ok: boolean;
|
|
130
|
+
value?: unknown;
|
|
131
|
+
error?: SandboxBindingError;
|
|
132
|
+
}
|