@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.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * Sandbox server — standalone Deno HTTP supervisor for UNTRUSTED code execution.
3
+ *
4
+ * Runs under Deno ONLY (the main QUESTPIE app, Bun/Node, calls it over HTTP via
5
+ * the `executor` adapter — the main app image needs NO Deno). Excluded from the
6
+ * package's tsc typecheck; shipped as source.
7
+ *
8
+ * SECURITY MODEL (hardened per M1 adversarial review — see decision
9
+ * `untrusted-exec-needs-process-isolation-not-in-process-workers`):
10
+ *
11
+ * Each untrusted request spawns a FRESH `deno run` SUBPROCESS (process-per-
12
+ * request), NOT a warm in-process Worker. This is the core fix: in-process
13
+ * Workers are a permission boundary but NOT a resource boundary —
14
+ * (1) `memoryMb` is unenforceable in-process (V8 heap is process-wide); a
15
+ * memory bomb OOM-kills the whole service + concurrent tenants.
16
+ * (2) `worker.terminate()` does not reap grandchild Workers; a nested
17
+ * `while(true)` Worker orphan pins a core forever.
18
+ * A per-request subprocess fixes BOTH: `--v8-flags=--max-old-space-size` is a
19
+ * REAL per-guest memory bound, and killing the process reaps every orphan.
20
+ *
21
+ * Per-subprocess flags:
22
+ * --v8-flags=--max-old-space-size=<memoryMb> hard memory bound
23
+ * --allow-net=<hosts> scoped fetch() allowlist (INDEPENDENT axis)
24
+ * --allow-import=<hosts> scoped module-import allowlist (INDEPENDENT axis)
25
+ * --no-prompt never block on a permission prompt
26
+ * (no --allow-read/write/env/run/ffi/sys → all DENIED by default)
27
+ * Plus in-guest hardening in `guest-entry.ts`: Worker=undefined,
28
+ * SharedArrayBuffer/Atomics removed.
29
+ *
30
+ * I/O: request envelope in via stdin, result line out via stdout.
31
+ * Hard kill on wall-timeout. Spawn failures → structured {ok:false,error}.
32
+ *
33
+ * EGRESS / SSRF: net + import hosts are validated against the private-IP policy
34
+ * (`net-validation.ts`) at request time — a host that IS or RESOLVES TO a
35
+ * private/link-local/loopback/metadata IP is rejected before spawn. The BASELINE
36
+ * runtime defense is the brokered `http.fetch` path (guest runs `--allow-net=[]`,
37
+ * so it cannot open sockets at all → no DNS-rebind surface).
38
+ *
39
+ * DEFENSE-IN-DEPTH (LINUX cloud workers only): on a capable Linux host the guest
40
+ * subprocess is additionally wrapped in a NETWORK NAMESPACE with an nftables
41
+ * egress firewall (`egress-firewall.ts`) that DROPS all traffic to private/link-
42
+ * local/loopback/metadata ranges at the KERNEL and permits only the resolved
43
+ * per-run allowlist — a second, independent boundary that also covers a
44
+ * hypothetical low-level escape. It is GRACEFULLY ABSENT off Linux (and on Linux
45
+ * without `unshare`/`nft`/`ip` or CAP_NET_ADMIN): a one-line notice is logged and
46
+ * the guest spawns exactly as before, with the baseline staying safe via the
47
+ * brokered fetch. The kernel-drop behavior is verified manually on a Linux worker
48
+ * (it cannot be exercised on macOS/Windows). See `egress-firewall.ts`.
49
+ *
50
+ * Run:
51
+ * deno run --allow-net --allow-env --allow-run --allow-read --allow-write=$TMPDIR \
52
+ * src/sandbox-server.ts
53
+ *
54
+ * (The SERVER needs: net to bind + spawn children that inherit nothing from it;
55
+ * run to spawn `deno` — and, on Linux, `unshare`/`nft`/`ip` for the egress
56
+ * firewall; read to load guest-entry.ts; env to read PORT/DENO_BIN; write to a
57
+ * temp dir to stage the per-run nft ruleset (Linux only — harmless elsewhere).
58
+ * These are the SUPERVISOR's perms — each GUEST subprocess gets only the
59
+ * scoped flags above and inherits none of the supervisor's grants.)
60
+ */
61
+
62
+ // @ts-nocheck — Deno runtime file; not part of the Bun/tsc typecheck graph.
63
+
64
+ import {
65
+ type EgressFirewallPlan,
66
+ planEgressFirewall,
67
+ wrapWithNetns,
68
+ } from "./egress-firewall.ts";
69
+ import { resolveAllowedEndpoints, validateEgressHosts } from "./net-validation.ts";
70
+ import {
71
+ RESULT_MARKER,
72
+ brokerUrlRejection,
73
+ clampInt,
74
+ extractResult,
75
+ importFlags,
76
+ netFlag,
77
+ type RunOutcome,
78
+ } from "./server-internals.ts";
79
+ import { BINDINGS_TOKEN_HEADER, FRAME_MARKER } from "./types.ts";
80
+ import type {
81
+ SandboxCapabilities,
82
+ SandboxRunRequest,
83
+ SandboxRunResult,
84
+ } from "./types.ts";
85
+
86
+ /** Bound on a single binding RPC round-trip to the host broker. */
87
+ const BROKER_FETCH_TIMEOUT_MS = 10_000;
88
+
89
+ const DEFAULT_TIMEOUT_MS = 5_000;
90
+ const MAX_TIMEOUT_MS = 30_000;
91
+ const DEFAULT_MEMORY_MB = 128;
92
+ const MIN_MEMORY_MB = 16;
93
+ const MAX_MEMORY_MB = 1024;
94
+ /** Grace period after wall-timeout before SIGKILL (SIGTERM first). */
95
+ const KILL_GRACE_MS = 250;
96
+
97
+ const GUEST_ENTRY_URL = new URL("./guest-entry.ts", import.meta.url);
98
+ const GUEST_ENTRY_PATH = GUEST_ENTRY_URL.pathname;
99
+ const DENO_BIN = Deno.env.get("DENO_BIN") ?? Deno.execPath();
100
+
101
+ /**
102
+ * DEFENSE-IN-DEPTH for the bindings path: the TRUSTED broker URL the supervisor
103
+ * is willing to relay a guest's binding RPCs to (carrying the per-run scoped
104
+ * token). The runner already sources this from CONFIG (never the request `Host`),
105
+ * but if a future/spoofed caller hands a stray `bindings.url`, the supervisor
106
+ * must NOT mail the token to it. Set this env to the SAME trusted internal broker
107
+ * URL the app uses; any `bindings.url` that does not match is rejected before any
108
+ * relay. When UNSET, this check is skipped (back-compat) — set it in production.
109
+ */
110
+ const EXPECTED_BROKER_URL = Deno.env.get("SANDBOX_BROKER_URL")?.trim();
111
+
112
+ // ──────────────────────────────────────────────────────────────────────────
113
+ // DEFENSE-IN-DEPTH: kernel egress firewall (LINUX-ONLY, cloud workers). S6 of
114
+ // spec `sandbox-brokered-http-egress`. The pure planning/ruleset/argv logic
115
+ // lives in `egress-firewall.ts` (typechecked + unit-tested under bun). The
116
+ // Deno-only PROBING (platform, tool presence, capabilities) + the real spawn
117
+ // wrap live here. On non-Linux — or Linux without the tools/caps — the firewall
118
+ // NO-OPS with a one-line notice and the guest spawns exactly as before, so the
119
+ // macOS/Windows local workers are untouched (baseline safe via brokered fetch).
120
+ //
121
+ // HONESTY: the kernel-drop guarantee is UNVERIFIABLE off Linux; it is verified
122
+ // manually on a Linux worker (see the repro in `egress-firewall.ts`).
123
+ //
124
+ // Off-switch: set SANDBOX_DISABLE_NETNS_FIREWALL=1 to force the no-op path even
125
+ // on a capable Linux host (e.g. when the orchestrator already provides netns).
126
+ // ──────────────────────────────────────────────────────────────────────────
127
+
128
+ const NETNS_FIREWALL_DISABLED = Deno.env.get("SANDBOX_DISABLE_NETNS_FIREWALL") === "1";
129
+
130
+ /** Probe PATH for a tool by exec'ing `<tool> --version` (no shell). Never throws. */
131
+ async function hasTool(tool: string, versionArg = "--version"): Promise<boolean> {
132
+ try {
133
+ const out = await new Deno.Command(tool, {
134
+ args: [versionArg],
135
+ stdin: "null",
136
+ stdout: "null",
137
+ stderr: "null",
138
+ }).output();
139
+ // `unshare --version` / `nft --version` / `ip -V` all exit 0 when present.
140
+ return out.success;
141
+ } catch {
142
+ return false; // ENOENT (not on PATH) or spawn denied
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Probe whether the supervisor can actually CREATE a filtered netns. We don't
148
+ * parse capability bitmasks — we just attempt the real operation cheaply:
149
+ * `unshare --net --map-root-user true`. If it exits 0 the host supports an
150
+ * (unprivileged-userns or root) network namespace; if it EPERMs, we no-op.
151
+ * This is the most honest probe: it tests the exact syscall path we'll use.
152
+ */
153
+ async function canCreateNetns(): Promise<boolean> {
154
+ try {
155
+ const out = await new Deno.Command("unshare", {
156
+ args: ["--net", "--map-root-user", "true"],
157
+ stdin: "null",
158
+ stdout: "null",
159
+ stderr: "null",
160
+ }).output();
161
+ return out.success;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Build the per-run egress-firewall plan: probe the platform/tools/caps and
169
+ * resolve the guest's allowlist (net+import hosts) to the PUBLIC IPs that become
170
+ * the kernel `accept` rules. Pure decision logic is delegated to
171
+ * `planEgressFirewall`; this only feeds it real probe results. Never throws.
172
+ */
173
+ async function buildEgressFirewallPlan(
174
+ netHosts: string[],
175
+ importHosts: string[],
176
+ ): Promise<EgressFirewallPlan> {
177
+ if (NETNS_FIREWALL_DISABLED) {
178
+ return { applied: false, reason: "disabled via SANDBOX_DISABLE_NETNS_FIREWALL=1" };
179
+ }
180
+ const os = Deno.build.os; // "linux" | "darwin" | "windows" — Deno's vocabulary
181
+ // Cheap OS gate FIRST: skip the (slowish) tool/cap probes entirely off Linux.
182
+ if (os !== "linux") {
183
+ return planEgressFirewall({ os, allow: [], tools: { unshare: false, nft: false, ip: false }, hasCaps: false });
184
+ }
185
+ const [unshare, nft, ip] = await Promise.all([
186
+ hasTool("unshare"),
187
+ hasTool("nft"),
188
+ hasTool("ip", "-V"),
189
+ ]);
190
+ const hasCaps = unshare ? await canCreateNetns() : false;
191
+ // Resolve the allowlist to public IPs (best-effort; never throws). For the
192
+ // brokered path netHosts is [] → pure default-deny netns.
193
+ const allow = await resolveAllowedEndpoints([...netHosts, ...importHosts]);
194
+ return planEgressFirewall({ os, allow, tools: { unshare, nft, ip }, hasCaps });
195
+ }
196
+
197
+ /**
198
+ * Write the per-run nft ruleset to a temp file and return `{ cmd, args, cleanup }`
199
+ * wrapping the guest argv with `unshare`. The temp file is read by `nft -f` inside
200
+ * the new netns, then removed by `cleanup()` after the run. Never throws — on any
201
+ * IO failure it returns `null` so the caller falls back to the un-wrapped spawn.
202
+ */
203
+ async function applyEgressFirewall(
204
+ guestArgv: string[],
205
+ ruleset: string,
206
+ ): Promise<{ cmd: string; args: string[]; cleanup: () => Promise<void> } | null> {
207
+ let rulesetPath: string;
208
+ try {
209
+ rulesetPath = await Deno.makeTempFile({ prefix: "qp-sandbox-egress-", suffix: ".nft" });
210
+ await Deno.writeTextFile(rulesetPath, ruleset);
211
+ } catch (err) {
212
+ console.warn(
213
+ `[sandbox] egress firewall: failed to stage ruleset (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
214
+ );
215
+ return null;
216
+ }
217
+ const { cmd, args } = wrapWithNetns(guestArgv, rulesetPath);
218
+ return {
219
+ cmd,
220
+ args,
221
+ cleanup: async () => {
222
+ try {
223
+ await Deno.remove(rulesetPath);
224
+ } catch {
225
+ /* best-effort */
226
+ }
227
+ },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Relay ONE binding RPC to the host broker (supervisor → app, server-to-server),
233
+ * carrying the per-run scoped token. NEVER throws — a broker/network failure is
234
+ * surfaced to the guest as a structured `rpc-result` error, so the guest's call
235
+ * rejects cleanly instead of hanging.
236
+ *
237
+ * This is the ONLY component that knows the token + broker URL; guest code never
238
+ * sees them. The broker is reached over the network HERE (trusted host↔host),
239
+ * which is distinct from the GUEST's egress (the guest cannot fetch the broker —
240
+ * its allowlist rejects loopback/private IPs by design).
241
+ */
242
+ async function brokerCall(
243
+ bindings: { url: string; token: string },
244
+ method: string,
245
+ args: unknown,
246
+ ): Promise<{ ok: boolean; value?: unknown; error?: { code: string; message: string } }> {
247
+ const controller = new AbortController();
248
+ const t = setTimeout(() => controller.abort(), BROKER_FETCH_TIMEOUT_MS);
249
+ try {
250
+ const res = await fetch(bindings.url, {
251
+ method: "POST",
252
+ headers: {
253
+ "content-type": "application/json",
254
+ [BINDINGS_TOKEN_HEADER]: bindings.token,
255
+ },
256
+ body: JSON.stringify({ method, args }),
257
+ signal: controller.signal,
258
+ });
259
+ const text = await res.text();
260
+ try {
261
+ return JSON.parse(text);
262
+ } catch {
263
+ return {
264
+ ok: false,
265
+ error: {
266
+ code: "execution_error",
267
+ message: `broker returned non-JSON (HTTP ${res.status})`,
268
+ },
269
+ };
270
+ }
271
+ } catch (err) {
272
+ return {
273
+ ok: false,
274
+ error: {
275
+ code: "execution_error",
276
+ message: `broker request failed: ${err instanceof Error ? err.message : String(err)}`,
277
+ },
278
+ };
279
+ } finally {
280
+ clearTimeout(t);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Drive a BINDINGS run: feed the framed envelope, stream the guest's stdout,
286
+ * demultiplex `rpc` (relay to the broker, reply on the guest's stdin) vs the
287
+ * final `result`. Returns the run outcome (without `ms`).
288
+ *
289
+ * Multiplexing contract: the guest writes one JSON line per message, each
290
+ * prefixed by {@link FRAME_MARKER}. The supervisor:
291
+ * - `rpc` → `brokerCall()` → write a framed `rpc-result` to stdin.
292
+ * - `result` → capture as the run outcome and stop.
293
+ * Non-framed stdout lines (stray guest prints) are ignored.
294
+ */
295
+ async function relayBindingsRun(
296
+ child: Deno.ChildProcess,
297
+ req: SandboxRunRequest,
298
+ timedOut: () => boolean,
299
+ ): Promise<RunOutcome> {
300
+ const bindings = req.bindings as { url: string; token: string };
301
+ const encoder = new TextEncoder();
302
+ const decoder = new TextDecoder();
303
+ const stdinWriter = child.stdin.getWriter();
304
+
305
+ let stdinOpen = true;
306
+ const closeStdin = async () => {
307
+ if (!stdinOpen) return;
308
+ stdinOpen = false;
309
+ try {
310
+ await stdinWriter.close();
311
+ } catch {
312
+ /* already closed */
313
+ }
314
+ };
315
+ const writeLine = async (obj: unknown) => {
316
+ if (!stdinOpen) return;
317
+ try {
318
+ await stdinWriter.write(encoder.encode(FRAME_MARKER + JSON.stringify(obj) + "\n"));
319
+ } catch {
320
+ /* guest gone */
321
+ }
322
+ };
323
+
324
+ // 1. Send the envelope as the FIRST framed line (note: `bindings:true` is a
325
+ // boolean FLAG to the guest — the token/url are NEVER sent into the guest).
326
+ await writeLine({
327
+ source: req.source,
328
+ input: req.input,
329
+ secrets: req.secrets ?? {},
330
+ bindings: true,
331
+ });
332
+
333
+ // 2. Stream stdout, demuxing frames. Outstanding RPCs run concurrently so a
334
+ // slow broker call doesn't block reading further frames.
335
+ let outcome: RunOutcome | null = null;
336
+ let pending = "";
337
+ const inflight: Array<Promise<void>> = [];
338
+
339
+ const handleLine = async (line: string) => {
340
+ if (!line.startsWith(FRAME_MARKER)) return; // stray output → ignore
341
+ let msg: {
342
+ type?: string;
343
+ id?: number;
344
+ method?: string;
345
+ args?: unknown;
346
+ ok?: boolean;
347
+ output?: unknown;
348
+ error?: string;
349
+ logs?: string[];
350
+ };
351
+ try {
352
+ msg = JSON.parse(line.slice(FRAME_MARKER.length));
353
+ } catch {
354
+ return;
355
+ }
356
+ if (msg.type === "rpc" && typeof msg.id === "number" && typeof msg.method === "string") {
357
+ const id = msg.id;
358
+ const p = (async () => {
359
+ const r = await brokerCall(bindings, msg.method as string, msg.args);
360
+ await writeLine({
361
+ type: "rpc-result",
362
+ id,
363
+ ok: !!r.ok,
364
+ value: r.value,
365
+ error: r.error,
366
+ });
367
+ })();
368
+ inflight.push(p);
369
+ } else if (msg.type === "result") {
370
+ outcome = {
371
+ ok: !!msg.ok,
372
+ output: msg.output,
373
+ error: msg.error,
374
+ logs: Array.isArray(msg.logs) ? msg.logs : [],
375
+ };
376
+ }
377
+ };
378
+
379
+ try {
380
+ const reader = child.stdout.getReader();
381
+ while (true) {
382
+ const { value, done } = await reader.read();
383
+ if (done) break;
384
+ pending += decoder.decode(value, { stream: true });
385
+ let idx: number;
386
+ while ((idx = pending.indexOf("\n")) !== -1) {
387
+ const line = pending.slice(0, idx);
388
+ pending = pending.slice(idx + 1);
389
+ await handleLine(line);
390
+ }
391
+ if (outcome) break; // final result seen
392
+ if (timedOut()) break;
393
+ }
394
+ // Flush a trailing line without newline (the final result frame may not
395
+ // be newline-terminated before exit).
396
+ if (!outcome && pending.length > 0) {
397
+ await handleLine(pending.trim());
398
+ }
399
+ } catch {
400
+ /* stdout closed abruptly — fall through to the no-result handling */
401
+ }
402
+
403
+ // Settle outstanding broker relays, then close the guest's stdin so its drain
404
+ // loop ends and the process can exit.
405
+ await Promise.allSettled(inflight);
406
+ await closeStdin();
407
+
408
+ // Drain remaining stdout + collect stderr for diagnostics, then reap.
409
+ let stderrText = "";
410
+ try {
411
+ const { stderr } = await child.output();
412
+ stderrText = decoder.decode(stderr).trim();
413
+ } catch {
414
+ /* already consumed/killed */
415
+ }
416
+
417
+ if (outcome) return outcome;
418
+ return {
419
+ ok: false,
420
+ error:
421
+ stderrText.length > 0
422
+ ? `subprocess exited without result: ${stderrText.slice(0, 800)}`
423
+ : "subprocess exited without producing a result",
424
+ logs: [],
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Execute one untrusted request in a fresh, hardened Deno subprocess.
430
+ * Never throws — spawn/IO failures are caught and returned as structured errors.
431
+ */
432
+ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult> {
433
+ const started = performance.now();
434
+ // Best-effort teardown for any per-run resource (e.g. the egress-firewall
435
+ // ruleset temp file). Set when the firewall is applied; fired on every return.
436
+ let onFinish: (() => void) | undefined;
437
+ const finish = (r: RunOutcome): SandboxRunResult => {
438
+ if (onFinish) {
439
+ try {
440
+ onFinish();
441
+ } catch {
442
+ /* best-effort cleanup */
443
+ }
444
+ onFinish = undefined;
445
+ }
446
+ return { ...r, ms: Math.round(performance.now() - started) };
447
+ };
448
+
449
+ const caps: SandboxCapabilities = req.capabilities ?? {
450
+ net: [],
451
+ import: [],
452
+ timeoutMs: DEFAULT_TIMEOUT_MS,
453
+ memoryMb: DEFAULT_MEMORY_MB,
454
+ };
455
+ const timeoutMs = clampInt(caps.timeoutMs, DEFAULT_TIMEOUT_MS, 1, MAX_TIMEOUT_MS);
456
+ const memoryMb = clampInt(caps.memoryMb, DEFAULT_MEMORY_MB, MIN_MEMORY_MB, MAX_MEMORY_MB);
457
+ const netHosts = Array.isArray(caps.net) ? caps.net : [];
458
+ const importHosts = Array.isArray(caps.import) ? caps.import : [];
459
+
460
+ // ── EGRESS VALIDATION (SSRF / private-IP rejection) — before spawn. ──
461
+ const egress = await validateEgressHosts([...netHosts, ...importHosts]);
462
+ if (!egress.ok) {
463
+ return finish({ ok: false, error: `egress blocked: ${egress.reason}`, logs: [] });
464
+ }
465
+
466
+ // BINDINGS URL VALIDATION (token-exfiltration defense) — before spawn.
467
+ // A bindings run mails the per-run token to `bindings.url`; never relay to a
468
+ // URL the supervisor was not configured to trust (see brokerUrlRejection).
469
+ if (req.bindings) {
470
+ const bindingsRejection = brokerUrlRejection(
471
+ (req.bindings as { url?: unknown }).url,
472
+ EXPECTED_BROKER_URL,
473
+ );
474
+ if (bindingsRejection !== null) {
475
+ return finish({
476
+ ok: false,
477
+ error: `bindings rejected: ${bindingsRejection}`,
478
+ logs: [],
479
+ });
480
+ }
481
+ }
482
+
483
+ // ── BUILD THE HARDENED SUBPROCESS COMMAND. ──
484
+ const args = [
485
+ "run",
486
+ "--no-prompt",
487
+ `--v8-flags=--max-old-space-size=${memoryMb}`,
488
+ // net + import are INDEPENDENT axes from the manifest (do NOT alias import=net).
489
+ ...netFlag(netHosts),
490
+ ...importFlags(importHosts),
491
+ // Guest-entry needs to read ITS OWN file. We grant read scoped to that one
492
+ // path; the guest source runs as a blob module (no fs), and fs ops inside the
493
+ // guest still fail because the guest cannot widen this scope.
494
+ `--allow-read=${GUEST_ENTRY_PATH}`,
495
+ GUEST_ENTRY_PATH,
496
+ ];
497
+
498
+ // ── DEFENSE-IN-DEPTH: wrap the spawn in a filtered netns on capable Linux. ──
499
+ // The guest argv is `[DENO_BIN, ...args]`. On Linux+tools+caps we prefix it
500
+ // with `unshare --net … nft -f <ruleset> … exec <guest>` so the kernel DROPS
501
+ // egress to private/metadata ranges. Everywhere else this is a logged no-op
502
+ // and `spawnCmd`/`spawnArgs` stay the un-wrapped guest command.
503
+ let spawnCmd = DENO_BIN;
504
+ let spawnArgs = args;
505
+ try {
506
+ const plan = await buildEgressFirewallPlan(netHosts, importHosts);
507
+ if (plan.applied) {
508
+ const wrapped = await applyEgressFirewall([DENO_BIN, ...args], plan.nftRuleset);
509
+ if (wrapped) {
510
+ spawnCmd = wrapped.cmd;
511
+ spawnArgs = wrapped.args;
512
+ onFinish = () => void wrapped.cleanup();
513
+ console.log(
514
+ `[sandbox] egress firewall: ACTIVE (netns + nftables, ${plan.allowCount} allow rule(s))`,
515
+ );
516
+ }
517
+ // wrapped === null → staging failed; fall through to the un-wrapped spawn.
518
+ } else {
519
+ // One-line notice so operators see WHY it's off (non-Linux, missing
520
+ // tool, no caps, or disabled). Not an error — the baseline is safe.
521
+ console.log(`[sandbox] egress firewall: skipped — ${plan.reason}`);
522
+ }
523
+ } catch (err) {
524
+ // The firewall is additive: never let a probe error block a run.
525
+ console.warn(
526
+ `[sandbox] egress firewall: probe failed (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
527
+ );
528
+ }
529
+
530
+ let child: Deno.ChildProcess;
531
+ try {
532
+ const command = new Deno.Command(spawnCmd, {
533
+ args: spawnArgs,
534
+ stdin: "piped",
535
+ stdout: "piped",
536
+ stderr: "piped",
537
+ // Do NOT leak the supervisor's env into the guest process.
538
+ clearEnv: true,
539
+ env: {},
540
+ });
541
+ child = command.spawn();
542
+ } catch (err) {
543
+ // Spawn-throw → structured error, never a bare 500.
544
+ return finish({
545
+ ok: false,
546
+ error: `failed to spawn sandbox subprocess: ${err instanceof Error ? err.message : String(err)}`,
547
+ logs: [],
548
+ });
549
+ }
550
+
551
+ let timedOut = false;
552
+ let killTimer: number | undefined;
553
+ let graceTimer: number | undefined;
554
+
555
+ try {
556
+ // HARD wall-time: SIGTERM, then SIGKILL after a grace period. Shared by
557
+ // both the legacy and the bindings I/O paths.
558
+ killTimer = setTimeout(() => {
559
+ timedOut = true;
560
+ try {
561
+ child.kill("SIGTERM");
562
+ } catch {
563
+ /* already gone */
564
+ }
565
+ graceTimer = setTimeout(() => {
566
+ try {
567
+ child.kill("SIGKILL");
568
+ } catch {
569
+ /* already gone */
570
+ }
571
+ }, KILL_GRACE_MS);
572
+ }, timeoutMs);
573
+
574
+ // ── BINDINGS PATH: framed stdio relay to the host broker. ──
575
+ if (req.bindings) {
576
+ const outcome = await relayBindingsRun(child, req, () => timedOut);
577
+ clearTimeout(killTimer);
578
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
579
+ if (timedOut) {
580
+ return finish({
581
+ ok: false,
582
+ timedOut: true,
583
+ error: `wall-time exceeded (${timeoutMs}ms)`,
584
+ logs: outcome?.logs ?? [],
585
+ });
586
+ }
587
+ return finish(outcome);
588
+ }
589
+
590
+ // ── LEGACY PATH: one envelope in, one result line out (M2 behavior). ──
591
+ const envelope = JSON.stringify({
592
+ source: req.source,
593
+ input: req.input,
594
+ secrets: req.secrets ?? {},
595
+ });
596
+ const writer = child.stdin.getWriter();
597
+ await writer.write(new TextEncoder().encode(envelope));
598
+ await writer.close();
599
+
600
+ const { stdout, stderr } = await child.output();
601
+ clearTimeout(killTimer);
602
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
603
+
604
+ if (timedOut) {
605
+ return finish({
606
+ ok: false,
607
+ timedOut: true,
608
+ error: `wall-time exceeded (${timeoutMs}ms)`,
609
+ logs: [],
610
+ });
611
+ }
612
+
613
+ const stdoutText = new TextDecoder().decode(stdout);
614
+ const result = extractResult(stdoutText, RESULT_MARKER);
615
+ if (result) return finish(result);
616
+
617
+ // No marker line → the guest crashed hard (e.g. OOM in its OWN process,
618
+ // blocked import). The subprocess died; the SERVICE is unaffected.
619
+ const stderrText = new TextDecoder().decode(stderr).trim();
620
+ return finish({
621
+ ok: false,
622
+ error:
623
+ stderrText.length > 0
624
+ ? `subprocess exited without result: ${stderrText.slice(0, 800)}`
625
+ : "subprocess exited without producing a result",
626
+ logs: [],
627
+ });
628
+ } catch (err) {
629
+ if (killTimer !== undefined) clearTimeout(killTimer);
630
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
631
+ try {
632
+ child.kill("SIGKILL");
633
+ } catch {
634
+ /* noop */
635
+ }
636
+ return finish({
637
+ ok: false,
638
+ error: `sandbox IO error: ${err instanceof Error ? err.message : String(err)}`,
639
+ logs: [],
640
+ });
641
+ }
642
+ }
643
+
644
+ function jsonResponse(body: unknown, status = 200): Response {
645
+ return new Response(JSON.stringify(body), {
646
+ status,
647
+ headers: { "content-type": "application/json" },
648
+ });
649
+ }
650
+
651
+ const port = Number(Deno.env.get("PORT") ?? 8787);
652
+
653
+ Deno.serve(
654
+ { port, onListen: ({ port }) => console.log(`sandbox-server listening on :${port}`) },
655
+ async (request) => {
656
+ const url = new URL(request.url);
657
+
658
+ if (request.method === "GET" && url.pathname === "/health") {
659
+ return jsonResponse({ ok: true, runtime: "deno", version: Deno.version.deno });
660
+ }
661
+
662
+ if (request.method === "POST" && url.pathname === "/run") {
663
+ let req: SandboxRunRequest;
664
+ try {
665
+ req = (await request.json()) as SandboxRunRequest;
666
+ } catch {
667
+ return jsonResponse({ ok: false, error: "invalid JSON body", logs: [] }, 400);
668
+ }
669
+ if (typeof req?.source !== "string" || !req?.capabilities) {
670
+ return jsonResponse(
671
+ { ok: false, error: "missing 'source' or 'capabilities'", logs: [] },
672
+ 400,
673
+ );
674
+ }
675
+ // runInSubprocess never throws — always a structured result.
676
+ const result = await runInSubprocess(req);
677
+ return jsonResponse(result);
678
+ }
679
+
680
+ return jsonResponse({ ok: false, error: "not found", logs: [] }, 404);
681
+ },
682
+ );