@questpie/sandbox 3.16.0 → 3.17.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.
@@ -20,7 +20,8 @@
20
20
  *
21
21
  * Per-subprocess flags:
22
22
  * --v8-flags=--max-old-space-size=<memoryMb> hard memory bound
23
- * --allow-net=<hosts> scoped fetch() allowlist (INDEPENDENT axis)
23
+ * --allow-net=<hosts> compute-only direct fetch allowlist
24
+ * --allow-net=[] all bindings guests (network stays host-brokered)
24
25
  * --allow-import=<hosts> scoped module-import allowlist (INDEPENDENT axis)
25
26
  * --no-prompt never block on a permission prompt
26
27
  * (no --allow-read/write/env/run/ffi/sys → all DENIED by default)
@@ -32,9 +33,10 @@
32
33
  *
33
34
  * EGRESS / SSRF: net + import hosts are validated against the private-IP policy
34
35
  * (`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).
36
+ * private/link-local/loopback/metadata IP is rejected before spawn. Bindings
37
+ * guests always run with `--allow-net=[]`; their declared `net` capability stays
38
+ * host-side and only the brokered, resolve/validate/pin `http.fetch` can use it.
39
+ * Legacy compute-only guests retain direct Deno networking for compatibility.
38
40
  *
39
41
  * DEFENSE-IN-DEPTH (LINUX cloud workers only): on a capable Linux host the guest
40
42
  * subprocess is additionally wrapped in a NETWORK NAMESPACE with an nftables
@@ -61,12 +63,30 @@
61
63
 
62
64
  // @ts-nocheck — Deno runtime file; not part of the Bun/tsc typecheck graph.
63
65
 
66
+ import { spawn } from "node:child_process";
67
+ import { Readable, Writable } from "node:stream";
68
+
69
+ import {
70
+ BROKER_FRAME_CAP_BYTES,
71
+ BROKER_HTTP_BODY_CAP_BYTES,
72
+ BROKER_TOTAL_OUTPUT_CAP_BYTES,
73
+ brokerRequestWireCap,
74
+ brokerResultValueCap,
75
+ brokerResponseWireCap,
76
+ } from "./broker-wire.ts";
64
77
  import {
65
78
  type EgressFirewallPlan,
66
79
  planEgressFirewall,
67
80
  wrapWithNetns,
68
81
  } from "./egress-firewall.ts";
69
- import { resolveAllowedEndpoints, validateEgressHosts } from "./net-validation.ts";
82
+ import {
83
+ bundleGuestRuntimeSource,
84
+ guestRuntimeDataUrl,
85
+ } from "./guest-runtime-source.ts";
86
+ import {
87
+ resolveAllowedEndpoints,
88
+ validateEgressHosts,
89
+ } from "./net-validation.ts";
70
90
  import {
71
91
  RESULT_MARKER,
72
92
  brokerUrlRejection,
@@ -74,14 +94,33 @@ import {
74
94
  extractResult,
75
95
  importFlags,
76
96
  netFlag,
97
+ normalizeBrokerUrl,
98
+ parseBrokerResponse,
99
+ parseSandboxRunResult,
77
100
  type RunOutcome,
78
101
  } from "./server-internals.ts";
79
- import { BINDINGS_TOKEN_HEADER, FRAME_MARKER } from "./types.ts";
102
+ import {
103
+ BINDINGS_TOKEN_HEADER,
104
+ FRAME_MARKER,
105
+ HOST_ADMISSION_HEADER,
106
+ WORKLOAD_ADMISSION_HEADER,
107
+ } from "./types.ts";
80
108
  import type {
81
109
  SandboxCapabilities,
82
110
  SandboxRunRequest,
83
111
  SandboxRunResult,
84
112
  } from "./types.ts";
113
+ import {
114
+ SandboxWorkloadReplayCache,
115
+ assertSandboxWorkloadAdmissionKey,
116
+ verifySandboxWorkloadAdmission,
117
+ type SandboxWorkloadAdmissionClaims,
118
+ type SandboxWorkloadAdmissionDenialReason,
119
+ } from "./workload-admission.ts";
120
+ import {
121
+ SANDBOX_WORKLOAD_DENIAL_MESSAGE,
122
+ parseSandboxRunRequest,
123
+ } from "./workload.ts";
85
124
 
86
125
  /** Bound on a single binding RPC round-trip to the host broker. */
87
126
  const BROKER_FETCH_TIMEOUT_MS = 10_000;
@@ -91,11 +130,27 @@ const MAX_TIMEOUT_MS = 30_000;
91
130
  const DEFAULT_MEMORY_MB = 128;
92
131
  const MIN_MEMORY_MB = 16;
93
132
  const MAX_MEMORY_MB = 1024;
133
+ const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
134
+ const MAX_CONSUMED_WORKLOAD_ADMISSIONS = 10_000;
135
+ const MAX_STDERR_BYTES = 256 * 1024;
136
+ const MAX_RPC_COUNT = 256;
137
+ const MAX_INFLIGHT_RPCS = 16;
94
138
  /** Grace period after wall-timeout before SIGKILL (SIGTERM first). */
95
139
  const KILL_GRACE_MS = 250;
96
140
 
97
141
  const GUEST_ENTRY_URL = new URL("./guest-entry.ts", import.meta.url);
98
142
  const GUEST_ENTRY_PATH = GUEST_ENTRY_URL.pathname;
143
+ const GUEST_BINDINGS_PATH = new URL("./guest-bindings.ts", import.meta.url)
144
+ .pathname;
145
+ const GUEST_ENTRY_SOURCE = await Deno.readTextFile(GUEST_ENTRY_PATH);
146
+ const GUEST_BINDINGS_SOURCE = await Deno.readTextFile(GUEST_BINDINGS_PATH);
147
+ const GUEST_RUNTIME_MODULE = guestRuntimeDataUrl(
148
+ bundleGuestRuntimeSource(
149
+ GUEST_ENTRY_SOURCE,
150
+ GUEST_BINDINGS_SOURCE,
151
+ BROKER_HTTP_BODY_CAP_BYTES,
152
+ ),
153
+ );
99
154
  const DENO_BIN = Deno.env.get("DENO_BIN") ?? Deno.execPath();
100
155
 
101
156
  /**
@@ -105,9 +160,149 @@ const DENO_BIN = Deno.env.get("DENO_BIN") ?? Deno.execPath();
105
160
  * but if a future/spoofed caller hands a stray `bindings.url`, the supervisor
106
161
  * must NOT mail the token to it. Set this env to the SAME trusted internal broker
107
162
  * 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.
163
+ * relay. Bindings fail closed when this canonical URL is missing or malformed.
109
164
  */
110
165
  const EXPECTED_BROKER_URL = Deno.env.get("SANDBOX_BROKER_URL")?.trim();
166
+ const WORKLOAD_ADMISSION_SECRET = Deno.env
167
+ .get("SANDBOX_WORKLOAD_ADMISSION_SECRET")
168
+ ?.trim();
169
+ const WORKLOAD_ADMISSION_KEY_ID =
170
+ Deno.env.get("SANDBOX_WORKLOAD_ADMISSION_KEY_ID")?.trim() ??
171
+ "sandbox-workload-v1";
172
+ const SANDBOX_INSTANCE_ID = Deno.env.get("SANDBOX_INSTANCE_ID")?.trim() ?? "";
173
+ const HOST_ADMISSION_SECRET = Deno.env
174
+ .get("SANDBOX_HOST_ADMISSION_SECRET")
175
+ ?.trim();
176
+ if (WORKLOAD_ADMISSION_SECRET) {
177
+ assertSandboxWorkloadAdmissionKey({
178
+ keyId: WORKLOAD_ADMISSION_KEY_ID,
179
+ secret: new TextEncoder().encode(WORKLOAD_ADMISSION_SECRET),
180
+ instanceId: SANDBOX_INSTANCE_ID,
181
+ });
182
+ }
183
+ const consumedWorkloadAdmissions = new SandboxWorkloadReplayCache(
184
+ MAX_CONSUMED_WORKLOAD_ADMISSIONS,
185
+ );
186
+
187
+ interface SandboxChildProcess {
188
+ readonly stdin: WritableStream<Uint8Array>;
189
+ readonly stdout: ReadableStream<Uint8Array>;
190
+ readonly stderr: ReadableStream<Uint8Array>;
191
+ readonly exited: Promise<void>;
192
+ kill(signal: "SIGKILL" | "SIGTERM"): void;
193
+ }
194
+
195
+ class SandboxOutputLimitError extends Error {}
196
+
197
+ interface OutputBudget {
198
+ total: number;
199
+ }
200
+
201
+ async function readStream(
202
+ stream: ReadableStream<Uint8Array>,
203
+ budget: OutputBudget,
204
+ maximumBytes: number,
205
+ onLimit: () => void,
206
+ ): Promise<Uint8Array> {
207
+ const reader = stream.getReader();
208
+ const chunks: Uint8Array[] = [];
209
+ let streamTotal = 0;
210
+ while (true) {
211
+ const { value, done } = await reader.read();
212
+ if (done) break;
213
+ streamTotal += value.byteLength;
214
+ budget.total += value.byteLength;
215
+ if (
216
+ streamTotal > maximumBytes ||
217
+ budget.total > BROKER_TOTAL_OUTPUT_CAP_BYTES
218
+ ) {
219
+ onLimit();
220
+ await reader.cancel();
221
+ throw new SandboxOutputLimitError();
222
+ }
223
+ chunks.push(value);
224
+ }
225
+ const output = new Uint8Array(streamTotal);
226
+ let offset = 0;
227
+ for (const chunk of chunks) {
228
+ output.set(chunk, offset);
229
+ offset += chunk.byteLength;
230
+ }
231
+ return output;
232
+ }
233
+
234
+ function spawnSandboxChild(
235
+ command: string,
236
+ args: string[],
237
+ argv0: string,
238
+ ): SandboxChildProcess {
239
+ const child = spawn(command, args, {
240
+ argv0,
241
+ cwd: "/",
242
+ env: {},
243
+ stdio: ["pipe", "pipe", "pipe"],
244
+ });
245
+ const exited = new Promise<void>((resolve, reject) => {
246
+ child.once("error", reject);
247
+ child.once("close", () => resolve());
248
+ });
249
+ void exited.catch(() => {});
250
+ return {
251
+ stdin: Writable.toWeb(child.stdin),
252
+ stdout: Readable.toWeb(child.stdout),
253
+ stderr: Readable.toWeb(child.stderr),
254
+ exited,
255
+ kill: (signal) => {
256
+ child.kill(signal);
257
+ },
258
+ };
259
+ }
260
+
261
+ function authenticatesHost(value: string | null): boolean {
262
+ if (
263
+ !value ||
264
+ !HOST_ADMISSION_SECRET ||
265
+ new TextEncoder().encode(HOST_ADMISSION_SECRET).byteLength < 32
266
+ ) {
267
+ return false;
268
+ }
269
+ const expected = new TextEncoder().encode(HOST_ADMISSION_SECRET);
270
+ const actual = new TextEncoder().encode(value);
271
+ if (actual.byteLength !== expected.byteLength) return false;
272
+ let difference = 0;
273
+ for (let index = 0; index < expected.byteLength; index += 1) {
274
+ difference |= expected[index] ^ actual[index];
275
+ }
276
+ return difference === 0;
277
+ }
278
+
279
+ type WorkloadAdmissionReason =
280
+ | SandboxWorkloadAdmissionDenialReason
281
+ | "authorized"
282
+ | "host_unauthorized"
283
+ | "missing"
284
+ | "replay"
285
+ | "unknown_mode";
286
+
287
+ function auditWorkloadAdmission(
288
+ decision: "allowed" | "denied",
289
+ reason: WorkloadAdmissionReason,
290
+ claims?: SandboxWorkloadAdmissionClaims,
291
+ ): void {
292
+ console.log(
293
+ JSON.stringify(
294
+ Object.freeze({
295
+ event: "questpie.sandbox.workload_admission",
296
+ boundary: "sandbox.runtime_admission",
297
+ decision,
298
+ reason,
299
+ ...(claims
300
+ ? { supervisorInstanceId: claims.supervisorInstanceId }
301
+ : {}),
302
+ }),
303
+ ),
304
+ );
305
+ }
111
306
 
112
307
  // ──────────────────────────────────────────────────────────────────────────
113
308
  // DEFENSE-IN-DEPTH: kernel egress firewall (LINUX-ONLY, cloud workers). S6 of
@@ -125,24 +320,51 @@ const EXPECTED_BROKER_URL = Deno.env.get("SANDBOX_BROKER_URL")?.trim();
125
320
  // on a capable Linux host (e.g. when the orchestrator already provides netns).
126
321
  // ──────────────────────────────────────────────────────────────────────────
127
322
 
128
- const NETNS_FIREWALL_DISABLED = Deno.env.get("SANDBOX_DISABLE_NETNS_FIREWALL") === "1";
323
+ const NETNS_FIREWALL_DISABLED =
324
+ Deno.env.get("SANDBOX_DISABLE_NETNS_FIREWALL") === "1";
129
325
 
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> {
326
+ async function runProbe(
327
+ command: string,
328
+ args: string[],
329
+ signal?: AbortSignal,
330
+ ): Promise<boolean> {
331
+ if (signal?.aborted) return false;
332
+ let child;
132
333
  try {
133
- const out = await new Deno.Command(tool, {
134
- args: [versionArg],
334
+ child = new Deno.Command(command, {
335
+ args,
135
336
  stdin: "null",
136
337
  stdout: "null",
137
338
  stderr: "null",
138
- }).output();
139
- // `unshare --version` / `nft --version` / `ip -V` all exit 0 when present.
140
- return out.success;
339
+ }).spawn();
340
+ const onAbort = () => {
341
+ try {
342
+ child.kill("SIGKILL");
343
+ } catch {
344
+ /* already exited */
345
+ }
346
+ };
347
+ signal?.addEventListener("abort", onAbort, { once: true });
348
+ if (signal?.aborted) onAbort();
349
+ try {
350
+ return (await child.output()).success && !signal?.aborted;
351
+ } finally {
352
+ signal?.removeEventListener("abort", onAbort);
353
+ }
141
354
  } catch {
142
- return false; // ENOENT (not on PATH) or spawn denied
355
+ return false;
143
356
  }
144
357
  }
145
358
 
359
+ /** Probe PATH for a tool by exec'ing `<tool> --version` (no shell). Never throws. */
360
+ function hasTool(
361
+ tool: string,
362
+ versionArg = "--version",
363
+ signal?: AbortSignal,
364
+ ): Promise<boolean> {
365
+ return runProbe(tool, [versionArg], signal);
366
+ }
367
+
146
368
  /**
147
369
  * Probe whether the supervisor can actually CREATE a filtered netns. We don't
148
370
  * parse capability bitmasks — we just attempt the real operation cheaply:
@@ -150,18 +372,13 @@ async function hasTool(tool: string, versionArg = "--version"): Promise<boolean>
150
372
  * (unprivileged-userns or root) network namespace; if it EPERMs, we no-op.
151
373
  * This is the most honest probe: it tests the exact syscall path we'll use.
152
374
  */
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
- }
375
+ function canCreateNetns(signal?: AbortSignal): Promise<boolean> {
376
+ return runProbe("unshare", ["--net", "--map-root-user", "true"], signal);
377
+ }
378
+
379
+ /** GNU env can set argv[0] for the Deno process inside the netns wrapper. */
380
+ function supportsVirtualArgv0Env(signal?: AbortSignal): Promise<boolean> {
381
+ return runProbe("env", ["--argv0=/runtime/deno", "true"], signal);
165
382
  }
166
383
 
167
384
  /**
@@ -173,25 +390,41 @@ async function canCreateNetns(): Promise<boolean> {
173
390
  async function buildEgressFirewallPlan(
174
391
  netHosts: string[],
175
392
  importHosts: string[],
393
+ signal?: AbortSignal,
176
394
  ): Promise<EgressFirewallPlan> {
177
395
  if (NETNS_FIREWALL_DISABLED) {
178
- return { applied: false, reason: "disabled via SANDBOX_DISABLE_NETNS_FIREWALL=1" };
396
+ return {
397
+ applied: false,
398
+ reason: "disabled via SANDBOX_DISABLE_NETNS_FIREWALL=1",
399
+ };
179
400
  }
180
401
  const os = Deno.build.os; // "linux" | "darwin" | "windows" — Deno's vocabulary
181
402
  // Cheap OS gate FIRST: skip the (slowish) tool/cap probes entirely off Linux.
182
403
  if (os !== "linux") {
183
- return planEgressFirewall({ os, allow: [], tools: { unshare: false, nft: false, ip: false }, hasCaps: false });
404
+ return planEgressFirewall({
405
+ os,
406
+ allow: [],
407
+ tools: { unshare: false, nft: false, ip: false },
408
+ hasCaps: false,
409
+ });
184
410
  }
185
411
  const [unshare, nft, ip] = await Promise.all([
186
- hasTool("unshare"),
187
- hasTool("nft"),
188
- hasTool("ip", "-V"),
412
+ hasTool("unshare", "--version", signal),
413
+ hasTool("nft", "--version", signal),
414
+ hasTool("ip", "-V", signal),
189
415
  ]);
190
- const hasCaps = unshare ? await canCreateNetns() : false;
416
+ const hasCaps = unshare ? await canCreateNetns(signal) : false;
191
417
  // Resolve the allowlist to public IPs (best-effort; never throws). For the
192
418
  // 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 });
419
+ const allow = await resolveAllowedEndpoints([...netHosts, ...importHosts], {
420
+ signal,
421
+ });
422
+ return planEgressFirewall({
423
+ os,
424
+ allow,
425
+ tools: { unshare, nft, ip },
426
+ hasCaps,
427
+ });
195
428
  }
196
429
 
197
430
  /**
@@ -203,11 +436,28 @@ async function buildEgressFirewallPlan(
203
436
  async function applyEgressFirewall(
204
437
  guestArgv: string[],
205
438
  ruleset: string,
206
- ): Promise<{ cmd: string; args: string[]; cleanup: () => Promise<void> } | null> {
439
+ signal?: AbortSignal,
440
+ ): Promise<{
441
+ cmd: string;
442
+ args: string[];
443
+ cleanup: () => Promise<void>;
444
+ } | null> {
207
445
  let rulesetPath: string;
208
446
  try {
209
- rulesetPath = await Deno.makeTempFile({ prefix: "qp-sandbox-egress-", suffix: ".nft" });
447
+ if (signal?.aborted) return null;
448
+ rulesetPath = await Deno.makeTempFile({
449
+ prefix: "qp-sandbox-egress-",
450
+ suffix: ".nft",
451
+ });
452
+ if (signal?.aborted) {
453
+ await Deno.remove(rulesetPath);
454
+ return null;
455
+ }
210
456
  await Deno.writeTextFile(rulesetPath, ruleset);
457
+ if (signal?.aborted) {
458
+ await Deno.remove(rulesetPath);
459
+ return null;
460
+ }
211
461
  } catch (err) {
212
462
  console.warn(
213
463
  `[sandbox] egress firewall: failed to stage ruleset (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
@@ -243,12 +493,21 @@ async function brokerCall(
243
493
  bindings: { url: string; token: string },
244
494
  method: string,
245
495
  args: unknown,
246
- ): Promise<{ ok: boolean; value?: unknown; error?: { code: string; message: string } }> {
496
+ parentSignal?: AbortSignal,
497
+ ): Promise<{
498
+ ok: boolean;
499
+ value?: unknown;
500
+ error?: { code: string; message: string };
501
+ }> {
247
502
  const controller = new AbortController();
503
+ const onAbort = () => controller.abort();
504
+ if (parentSignal?.aborted) controller.abort();
505
+ else parentSignal?.addEventListener("abort", onAbort, { once: true });
248
506
  const t = setTimeout(() => controller.abort(), BROKER_FETCH_TIMEOUT_MS);
249
507
  try {
250
508
  const res = await fetch(bindings.url, {
251
509
  method: "POST",
510
+ redirect: "error",
252
511
  headers: {
253
512
  "content-type": "application/json",
254
513
  [BINDINGS_TOKEN_HEADER]: bindings.token,
@@ -256,28 +515,79 @@ async function brokerCall(
256
515
  body: JSON.stringify({ method, args }),
257
516
  signal: controller.signal,
258
517
  });
259
- const text = await res.text();
518
+ const reader = res.body?.getReader();
519
+ const chunks: Uint8Array[] = [];
520
+ let total = 0;
521
+ if (reader) {
522
+ while (true) {
523
+ const { value, done } = await reader.read();
524
+ if (done) break;
525
+ total += value.byteLength;
526
+ if (total > brokerResponseWireCap(method)) {
527
+ await reader.cancel();
528
+ return {
529
+ ok: false,
530
+ error: {
531
+ code: "execution_error",
532
+ message: "sandbox broker returned an invalid response",
533
+ },
534
+ };
535
+ }
536
+ chunks.push(value);
537
+ }
538
+ }
539
+ const bytes = new Uint8Array(total);
540
+ let offset = 0;
541
+ for (const chunk of chunks) {
542
+ bytes.set(chunk, offset);
543
+ offset += chunk.byteLength;
544
+ }
545
+ const text = new TextDecoder().decode(bytes);
260
546
  try {
261
- return JSON.parse(text);
547
+ const parsed = parseBrokerResponse(
548
+ JSON.parse(text),
549
+ brokerResultValueCap(method),
550
+ );
551
+ if (!res.ok) {
552
+ return parsed && !parsed.ok
553
+ ? parsed
554
+ : {
555
+ ok: false,
556
+ error: {
557
+ code: "execution_error",
558
+ message: "sandbox binding operation failed",
559
+ },
560
+ };
561
+ }
562
+ return (
563
+ parsed ?? {
564
+ ok: false,
565
+ error: {
566
+ code: "execution_error",
567
+ message: "sandbox broker returned an invalid response",
568
+ },
569
+ }
570
+ );
262
571
  } catch {
263
572
  return {
264
573
  ok: false,
265
574
  error: {
266
575
  code: "execution_error",
267
- message: `broker returned non-JSON (HTTP ${res.status})`,
576
+ message: "sandbox broker returned an invalid response",
268
577
  },
269
578
  };
270
579
  }
271
- } catch (err) {
580
+ } catch {
272
581
  return {
273
582
  ok: false,
274
583
  error: {
275
584
  code: "execution_error",
276
- message: `broker request failed: ${err instanceof Error ? err.message : String(err)}`,
585
+ message: "sandbox broker request failed",
277
586
  },
278
587
  };
279
588
  } finally {
280
589
  clearTimeout(t);
590
+ parentSignal?.removeEventListener("abort", onAbort);
281
591
  }
282
592
  }
283
593
 
@@ -293,11 +603,20 @@ async function brokerCall(
293
603
  * Non-framed stdout lines (stray guest prints) are ignored.
294
604
  */
295
605
  async function relayBindingsRun(
296
- child: Deno.ChildProcess,
606
+ child: SandboxChildProcess,
297
607
  req: SandboxRunRequest,
298
608
  timedOut: () => boolean,
609
+ stderrPromise: Promise<Uint8Array>,
610
+ outputBudget: OutputBudget,
611
+ onLimit: () => void,
612
+ onTerminal: () => void,
613
+ signal?: AbortSignal,
299
614
  ): Promise<RunOutcome> {
300
- const bindings = req.bindings as { url: string; token: string };
615
+ const requestedBindings = req.bindings as { url: string; token: string };
616
+ const bindings = {
617
+ ...requestedBindings,
618
+ url: normalizeBrokerUrl(requestedBindings.url)!,
619
+ };
301
620
  const encoder = new TextEncoder();
302
621
  const decoder = new TextDecoder();
303
622
  const stdinWriter = child.stdin.getWriter();
@@ -315,7 +634,9 @@ async function relayBindingsRun(
315
634
  const writeLine = async (obj: unknown) => {
316
635
  if (!stdinOpen) return;
317
636
  try {
318
- await stdinWriter.write(encoder.encode(FRAME_MARKER + JSON.stringify(obj) + "\n"));
637
+ await stdinWriter.write(
638
+ encoder.encode(FRAME_MARKER + JSON.stringify(obj) + "\n"),
639
+ );
319
640
  } catch {
320
641
  /* guest gone */
321
642
  }
@@ -333,8 +654,22 @@ async function relayBindingsRun(
333
654
  // 2. Stream stdout, demuxing frames. Outstanding RPCs run concurrently so a
334
655
  // slow broker call doesn't block reading further frames.
335
656
  let outcome: RunOutcome | null = null;
336
- let pending = "";
337
- const inflight: Array<Promise<void>> = [];
657
+ let terminalFailure: string | null = null;
658
+ let pendingChunks: Uint8Array[] = [];
659
+ let pendingBytes = 0;
660
+ let rpcCount = 0;
661
+ const inflight = new Set<Promise<void>>();
662
+ const takePendingLine = (): string => {
663
+ const bytes = new Uint8Array(pendingBytes);
664
+ let offset = 0;
665
+ for (const chunk of pendingChunks) {
666
+ bytes.set(chunk, offset);
667
+ offset += chunk.byteLength;
668
+ }
669
+ pendingChunks = [];
670
+ pendingBytes = 0;
671
+ return decoder.decode(bytes);
672
+ };
338
673
 
339
674
  const handleLine = async (line: string) => {
340
675
  if (!line.startsWith(FRAME_MARKER)) return; // stray output → ignore
@@ -353,10 +688,42 @@ async function relayBindingsRun(
353
688
  } catch {
354
689
  return;
355
690
  }
356
- if (msg.type === "rpc" && typeof msg.id === "number" && typeof msg.method === "string") {
691
+ if (
692
+ msg.type === "rpc" &&
693
+ typeof msg.id === "number" &&
694
+ typeof msg.method === "string"
695
+ ) {
696
+ if (
697
+ encoder.encode(line).byteLength >
698
+ brokerRequestWireCap(msg.method) + FRAME_MARKER.length
699
+ ) {
700
+ onLimit();
701
+ throw new SandboxOutputLimitError();
702
+ }
703
+ rpcCount += 1;
704
+ if (rpcCount > MAX_RPC_COUNT) {
705
+ onLimit();
706
+ throw new SandboxOutputLimitError();
707
+ }
708
+ while (inflight.size >= MAX_INFLIGHT_RPCS) {
709
+ await Promise.race(inflight);
710
+ }
357
711
  const id = msg.id;
358
712
  const p = (async () => {
359
- const r = await brokerCall(bindings, msg.method as string, msg.args);
713
+ const r = await brokerCall(
714
+ bindings,
715
+ msg.method as string,
716
+ msg.args,
717
+ signal,
718
+ );
719
+ if (!r.ok) {
720
+ if (!terminalFailure && !outcome) {
721
+ terminalFailure = r.error.message;
722
+ onTerminal();
723
+ }
724
+ return;
725
+ }
726
+ if (signal?.aborted || terminalFailure || outcome) return;
360
727
  await writeLine({
361
728
  type: "rpc-result",
362
729
  id,
@@ -365,14 +732,35 @@ async function relayBindingsRun(
365
732
  error: r.error,
366
733
  });
367
734
  })();
368
- inflight.push(p);
735
+ inflight.add(p);
736
+ void p.finally(() => inflight.delete(p));
369
737
  } 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
- };
738
+ const keys = Object.keys(msg);
739
+ if (
740
+ keys.every((key) =>
741
+ ["type", "ok", "output", "error", "logs"].includes(key),
742
+ )
743
+ ) {
744
+ const parsed = parseSandboxRunResult({
745
+ ok: msg.ok,
746
+ ...(Object.hasOwn(msg, "output") ? { output: msg.output } : {}),
747
+ ...(Object.hasOwn(msg, "error") ? { error: msg.error } : {}),
748
+ logs: msg.logs,
749
+ });
750
+ if (!parsed) {
751
+ terminalFailure = "sandbox guest returned an invalid result";
752
+ onTerminal();
753
+ } else if (inflight.size > 0) {
754
+ terminalFailure = "sandbox result raced binding calls";
755
+ onTerminal();
756
+ } else {
757
+ outcome = parsed;
758
+ onTerminal();
759
+ }
760
+ } else {
761
+ terminalFailure = "sandbox guest returned an invalid result";
762
+ onTerminal();
763
+ }
376
764
  }
377
765
  };
378
766
 
@@ -381,22 +769,39 @@ async function relayBindingsRun(
381
769
  while (true) {
382
770
  const { value, done } = await reader.read();
383
771
  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);
772
+ outputBudget.total += value.byteLength;
773
+ if (outputBudget.total > BROKER_TOTAL_OUTPUT_CAP_BYTES) {
774
+ onLimit();
775
+ throw new SandboxOutputLimitError();
776
+ }
777
+ let start = 0;
778
+ while (start < value.byteLength) {
779
+ const newline = value.indexOf(0x0a, start);
780
+ const end = newline === -1 ? value.byteLength : newline;
781
+ const segment = value.subarray(start, end);
782
+ if (segment.byteLength > 0) {
783
+ pendingChunks.push(segment);
784
+ pendingBytes += segment.byteLength;
785
+ }
786
+ if (pendingBytes > BROKER_FRAME_CAP_BYTES) {
787
+ onLimit();
788
+ throw new SandboxOutputLimitError();
789
+ }
790
+ if (newline === -1) break;
791
+ await handleLine(takePendingLine());
792
+ if (outcome || terminalFailure) break;
793
+ start = newline + 1;
390
794
  }
391
- if (outcome) break; // final result seen
795
+ if (outcome || terminalFailure) break;
392
796
  if (timedOut()) break;
393
797
  }
394
798
  // Flush a trailing line without newline (the final result frame may not
395
799
  // be newline-terminated before exit).
396
- if (!outcome && pending.length > 0) {
397
- await handleLine(pending.trim());
800
+ if (!outcome && !terminalFailure && pendingBytes > 0) {
801
+ await handleLine(takePendingLine().trim());
398
802
  }
399
- } catch {
803
+ } catch (error) {
804
+ if (error instanceof SandboxOutputLimitError) throw error;
400
805
  /* stdout closed abruptly — fall through to the no-result handling */
401
806
  }
402
807
 
@@ -408,12 +813,16 @@ async function relayBindingsRun(
408
813
  // Drain remaining stdout + collect stderr for diagnostics, then reap.
409
814
  let stderrText = "";
410
815
  try {
411
- const { stderr } = await child.output();
816
+ const stderr = await stderrPromise;
817
+ await child.exited;
412
818
  stderrText = decoder.decode(stderr).trim();
413
819
  } catch {
414
820
  /* already consumed/killed */
415
821
  }
416
822
 
823
+ if (terminalFailure) {
824
+ return { ok: false, error: terminalFailure, logs: [] };
825
+ }
417
826
  if (outcome) return outcome;
418
827
  return {
419
828
  ok: false,
@@ -429,38 +838,95 @@ async function relayBindingsRun(
429
838
  * Execute one untrusted request in a fresh, hardened Deno subprocess.
430
839
  * Never throws — spawn/IO failures are caught and returned as structured errors.
431
840
  */
432
- async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult> {
841
+ async function runInSubprocess(
842
+ req: SandboxRunRequest,
843
+ signal?: AbortSignal,
844
+ ): Promise<SandboxRunResult> {
433
845
  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;
846
+ const caps: SandboxCapabilities = req.capabilities ?? {
847
+ net: [],
848
+ import: [],
849
+ timeoutMs: DEFAULT_TIMEOUT_MS,
850
+ memoryMb: DEFAULT_MEMORY_MB,
851
+ };
852
+ const timeoutMs = clampInt(
853
+ caps.timeoutMs,
854
+ DEFAULT_TIMEOUT_MS,
855
+ 1,
856
+ MAX_TIMEOUT_MS,
857
+ );
858
+ let deadlineTimedOut = false;
859
+ let parentCancelled = signal?.aborted ?? false;
860
+ const executionController = new AbortController();
861
+ const onParentAbort = () => {
862
+ parentCancelled = true;
863
+ executionController.abort();
864
+ };
865
+ if (signal?.aborted) executionController.abort();
866
+ else signal?.addEventListener("abort", onParentAbort, { once: true });
867
+ const absoluteTimer = setTimeout(() => {
868
+ deadlineTimedOut = true;
869
+ executionController.abort();
870
+ }, timeoutMs);
871
+ const executionSignal = executionController.signal;
872
+ // Best-effort teardown for per-run resources (work root, firewall ruleset).
873
+ const cleanup: Array<() => void> = [
874
+ () => clearTimeout(absoluteTimer),
875
+ () => signal?.removeEventListener("abort", onParentAbort),
876
+ ];
437
877
  const finish = (r: RunOutcome): SandboxRunResult => {
438
- if (onFinish) {
878
+ for (const dispose of cleanup.splice(0)) {
439
879
  try {
440
- onFinish();
880
+ dispose();
441
881
  } catch {
442
882
  /* best-effort cleanup */
443
883
  }
444
- onFinish = undefined;
445
884
  }
446
885
  return { ...r, ms: Math.round(performance.now() - started) };
447
886
  };
887
+ const interrupted = (): SandboxRunResult =>
888
+ finish(
889
+ deadlineTimedOut
890
+ ? {
891
+ ok: false,
892
+ timedOut: true,
893
+ error: `wall-time exceeded (${timeoutMs}ms)`,
894
+ logs: [],
895
+ }
896
+ : {
897
+ ok: false,
898
+ error: "sandbox request cancelled",
899
+ logs: [],
900
+ },
901
+ );
902
+ if (executionSignal.aborted) {
903
+ return interrupted();
904
+ }
448
905
 
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);
906
+ const memoryMb = clampInt(
907
+ caps.memoryMb,
908
+ DEFAULT_MEMORY_MB,
909
+ MIN_MEMORY_MB,
910
+ MAX_MEMORY_MB,
911
+ );
457
912
  const netHosts = Array.isArray(caps.net) ? caps.net : [];
458
913
  const importHosts = Array.isArray(caps.import) ? caps.import : [];
459
914
 
460
915
  // ── EGRESS VALIDATION (SSRF / private-IP rejection) — before spawn. ──
461
- const egress = await validateEgressHosts([...netHosts, ...importHosts]);
916
+ const egress = await validateEgressHosts([...netHosts, ...importHosts], {
917
+ signal: executionSignal,
918
+ timeoutMs,
919
+ concurrency: 4,
920
+ });
921
+ if (executionSignal.aborted) {
922
+ return interrupted();
923
+ }
462
924
  if (!egress.ok) {
463
- return finish({ ok: false, error: `egress blocked: ${egress.reason}`, logs: [] });
925
+ return finish({
926
+ ok: false,
927
+ error: `egress blocked: ${egress.reason}`,
928
+ logs: [],
929
+ });
464
930
  }
465
931
 
466
932
  // BINDINGS URL VALIDATION (token-exfiltration defense) — before spawn.
@@ -480,19 +946,36 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
480
946
  }
481
947
  }
482
948
 
949
+ // Every subprocess receives a fresh host-private cwd. The guest has no file
950
+ // permissions for it and guest-entry exposes only the virtual `/work` name.
951
+ let guestWorkRoot: string;
952
+ try {
953
+ if (executionSignal.aborted) return interrupted();
954
+ guestWorkRoot = await Deno.makeTempDir({ prefix: "qp-sandbox-work-" });
955
+ cleanup.push(() => Deno.removeSync(guestWorkRoot, { recursive: true }));
956
+ if (executionSignal.aborted) return interrupted();
957
+ } catch {
958
+ return finish({
959
+ ok: false,
960
+ error: "failed to create sandbox work root",
961
+ logs: [],
962
+ });
963
+ }
483
964
  // ── BUILD THE HARDENED SUBPROCESS COMMAND. ──
965
+ // Bindings guests keep their declared `net` allowlist on the trusted broker.
966
+ // Giving the subprocess the same `--allow-net` grant would let raw sockets
967
+ // bypass the resolve/validate/pin/redirect checks enforced by `http.fetch`.
968
+ const directNetHosts = req.bindings ? [] : netHosts;
484
969
  const args = [
485
970
  "run",
486
971
  "--no-prompt",
487
972
  `--v8-flags=--max-old-space-size=${memoryMb}`,
488
973
  // net + import are INDEPENDENT axes from the manifest (do NOT alias import=net).
489
- ...netFlag(netHosts),
974
+ ...netFlag(directNetHosts),
490
975
  ...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,
976
+ // A self-contained data module needs neither a host path nor a socket, so it
977
+ // remains loadable after the Linux network namespace is created.
978
+ GUEST_RUNTIME_MODULE,
496
979
  ];
497
980
 
498
981
  // ── DEFENSE-IN-DEPTH: wrap the spawn in a filtered netns on capable Linux. ──
@@ -503,16 +986,31 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
503
986
  let spawnCmd = DENO_BIN;
504
987
  let spawnArgs = args;
505
988
  try {
506
- const plan = await buildEgressFirewallPlan(netHosts, importHosts);
989
+ const plan = await buildEgressFirewallPlan(
990
+ directNetHosts,
991
+ importHosts,
992
+ executionSignal,
993
+ );
507
994
  if (plan.applied) {
508
- const wrapped = await applyEgressFirewall([DENO_BIN, ...args], plan.nftRuleset);
995
+ const canVirtualizeArgv0 = await supportsVirtualArgv0Env(executionSignal);
996
+ const wrapped = canVirtualizeArgv0
997
+ ? await applyEgressFirewall(
998
+ ["env", "--argv0=/runtime/deno", DENO_BIN, ...args],
999
+ plan.nftRuleset,
1000
+ executionSignal,
1001
+ )
1002
+ : null;
509
1003
  if (wrapped) {
510
1004
  spawnCmd = wrapped.cmd;
511
1005
  spawnArgs = wrapped.args;
512
- onFinish = () => void wrapped.cleanup();
1006
+ cleanup.push(() => void wrapped.cleanup());
513
1007
  console.log(
514
1008
  `[sandbox] egress firewall: ACTIVE (netns + nftables, ${plan.allowCount} allow rule(s))`,
515
1009
  );
1010
+ } else if (!canVirtualizeArgv0) {
1011
+ console.log(
1012
+ "[sandbox] egress firewall: skipped — env lacks argv0 virtualization; baseline isolation remains active",
1013
+ );
516
1014
  }
517
1015
  // wrapped === null → staging failed; fall through to the un-wrapped spawn.
518
1016
  } else {
@@ -526,19 +1024,13 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
526
1024
  `[sandbox] egress firewall: probe failed (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
527
1025
  );
528
1026
  }
1027
+ if (executionSignal.aborted) {
1028
+ return interrupted();
1029
+ }
529
1030
 
530
- let child: Deno.ChildProcess;
1031
+ let child: SandboxChildProcess;
531
1032
  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();
1033
+ child = spawnSandboxChild(spawnCmd, spawnArgs, "/runtime/deno");
542
1034
  } catch (err) {
543
1035
  // Spawn-throw → structured error, never a bare 500.
544
1036
  return finish({
@@ -548,35 +1040,81 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
548
1040
  });
549
1041
  }
550
1042
 
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;
1043
+ let outputLimitExceeded = false;
1044
+ let cancelGraceTimer: number | undefined;
1045
+ const relayController = new AbortController();
1046
+ let terminationStarted = false;
1047
+ const terminateChild = () => {
1048
+ if (terminationStarted) return;
1049
+ terminationStarted = true;
1050
+ try {
1051
+ child.kill("SIGTERM");
1052
+ } catch {
1053
+ /* already gone */
1054
+ }
1055
+ cancelGraceTimer = setTimeout(() => {
560
1056
  try {
561
- child.kill("SIGTERM");
1057
+ child.kill("SIGKILL");
562
1058
  } catch {
563
1059
  /* already gone */
564
1060
  }
565
- graceTimer = setTimeout(() => {
566
- try {
567
- child.kill("SIGKILL");
568
- } catch {
569
- /* already gone */
570
- }
571
- }, KILL_GRACE_MS);
572
- }, timeoutMs);
1061
+ }, KILL_GRACE_MS);
1062
+ };
1063
+ const onExecutionAbort = () => {
1064
+ relayController.abort();
1065
+ terminateChild();
1066
+ };
1067
+ const onOutputLimit = () => {
1068
+ if (outputLimitExceeded) return;
1069
+ outputLimitExceeded = true;
1070
+ relayController.abort();
1071
+ terminateChild();
1072
+ };
1073
+ if (executionSignal.aborted) onExecutionAbort();
1074
+ else
1075
+ executionSignal.addEventListener("abort", onExecutionAbort, { once: true });
1076
+ const outputBudget: OutputBudget = { total: 0 };
1077
+ const stderrPromise = readStream(
1078
+ child.stderr,
1079
+ outputBudget,
1080
+ MAX_STDERR_BYTES,
1081
+ onOutputLimit,
1082
+ );
1083
+ void stderrPromise.catch(() => {});
573
1084
 
1085
+ try {
574
1086
  // ── BINDINGS PATH: framed stdio relay to the host broker. ──
575
1087
  if (req.bindings) {
576
- const outcome = await relayBindingsRun(child, req, () => timedOut);
577
- clearTimeout(killTimer);
578
- if (graceTimer !== undefined) clearTimeout(graceTimer);
579
- if (timedOut) {
1088
+ const outcome = await relayBindingsRun(
1089
+ child,
1090
+ req,
1091
+ () => deadlineTimedOut,
1092
+ stderrPromise,
1093
+ outputBudget,
1094
+ onOutputLimit,
1095
+ () => {
1096
+ relayController.abort();
1097
+ terminateChild();
1098
+ },
1099
+ relayController.signal,
1100
+ );
1101
+ if (cancelGraceTimer !== undefined) clearTimeout(cancelGraceTimer);
1102
+ executionSignal.removeEventListener("abort", onExecutionAbort);
1103
+ if (parentCancelled) {
1104
+ return finish({
1105
+ ok: false,
1106
+ error: "sandbox request cancelled",
1107
+ logs: outcome?.logs ?? [],
1108
+ });
1109
+ }
1110
+ if (outputLimitExceeded) {
1111
+ return finish({
1112
+ ok: false,
1113
+ error: "sandbox output limit exceeded",
1114
+ logs: [],
1115
+ });
1116
+ }
1117
+ if (deadlineTimedOut) {
580
1118
  return finish({
581
1119
  ok: false,
582
1120
  timedOut: true,
@@ -597,11 +1135,34 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
597
1135
  await writer.write(new TextEncoder().encode(envelope));
598
1136
  await writer.close();
599
1137
 
600
- const { stdout, stderr } = await child.output();
601
- clearTimeout(killTimer);
602
- if (graceTimer !== undefined) clearTimeout(graceTimer);
1138
+ const [stdout, stderr] = await Promise.all([
1139
+ readStream(
1140
+ child.stdout,
1141
+ outputBudget,
1142
+ BROKER_TOTAL_OUTPUT_CAP_BYTES,
1143
+ onOutputLimit,
1144
+ ),
1145
+ stderrPromise,
1146
+ child.exited,
1147
+ ]);
1148
+ if (cancelGraceTimer !== undefined) clearTimeout(cancelGraceTimer);
1149
+ executionSignal.removeEventListener("abort", onExecutionAbort);
603
1150
 
604
- if (timedOut) {
1151
+ if (parentCancelled) {
1152
+ return finish({
1153
+ ok: false,
1154
+ error: "sandbox request cancelled",
1155
+ logs: [],
1156
+ });
1157
+ }
1158
+ if (outputLimitExceeded) {
1159
+ return finish({
1160
+ ok: false,
1161
+ error: "sandbox output limit exceeded",
1162
+ logs: [],
1163
+ });
1164
+ }
1165
+ if (deadlineTimedOut) {
605
1166
  return finish({
606
1167
  ok: false,
607
1168
  timedOut: true,
@@ -626,16 +1187,20 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
626
1187
  logs: [],
627
1188
  });
628
1189
  } catch (err) {
629
- if (killTimer !== undefined) clearTimeout(killTimer);
630
- if (graceTimer !== undefined) clearTimeout(graceTimer);
1190
+ if (cancelGraceTimer !== undefined) clearTimeout(cancelGraceTimer);
1191
+ executionSignal.removeEventListener("abort", onExecutionAbort);
631
1192
  try {
632
1193
  child.kill("SIGKILL");
633
1194
  } catch {
634
1195
  /* noop */
635
1196
  }
1197
+ if (parentCancelled || deadlineTimedOut) return interrupted();
636
1198
  return finish({
637
1199
  ok: false,
638
- error: `sandbox IO error: ${err instanceof Error ? err.message : String(err)}`,
1200
+ error:
1201
+ outputLimitExceeded || err instanceof SandboxOutputLimitError
1202
+ ? "sandbox output limit exceeded"
1203
+ : `sandbox IO error: ${err instanceof Error ? err.message : String(err)}`,
639
1204
  logs: [],
640
1205
  });
641
1206
  }
@@ -648,32 +1213,183 @@ function jsonResponse(body: unknown, status = 200): Response {
648
1213
  });
649
1214
  }
650
1215
 
1216
+ class RequestBodyTooLargeError extends Error {}
1217
+
1218
+ async function readBoundedRequestBody(request: Request): Promise<string> {
1219
+ const declared = request.headers.get("content-length");
1220
+ if (
1221
+ declared !== null &&
1222
+ Number.isFinite(Number(declared)) &&
1223
+ Number(declared) > MAX_REQUEST_BODY_BYTES
1224
+ ) {
1225
+ throw new RequestBodyTooLargeError();
1226
+ }
1227
+ if (!request.body) return "";
1228
+ const reader = request.body.getReader();
1229
+ const chunks: Uint8Array[] = [];
1230
+ let total = 0;
1231
+ while (true) {
1232
+ const { value, done } = await reader.read();
1233
+ if (done) break;
1234
+ total += value.byteLength;
1235
+ if (total > MAX_REQUEST_BODY_BYTES) {
1236
+ await reader.cancel();
1237
+ throw new RequestBodyTooLargeError();
1238
+ }
1239
+ chunks.push(value);
1240
+ }
1241
+ const body = new Uint8Array(total);
1242
+ let offset = 0;
1243
+ for (const chunk of chunks) {
1244
+ body.set(chunk, offset);
1245
+ offset += chunk.byteLength;
1246
+ }
1247
+ return new TextDecoder().decode(body);
1248
+ }
1249
+
651
1250
  const port = Number(Deno.env.get("PORT") ?? 8787);
652
1251
 
653
1252
  Deno.serve(
654
- { port, onListen: ({ port }) => console.log(`sandbox-server listening on :${port}`) },
1253
+ {
1254
+ port,
1255
+ onListen: ({ port }) => {
1256
+ console.log(`sandbox-server listening on :${port}`);
1257
+ },
1258
+ },
655
1259
  async (request) => {
656
1260
  const url = new URL(request.url);
657
-
658
1261
  if (request.method === "GET" && url.pathname === "/health") {
659
- return jsonResponse({ ok: true, runtime: "deno", version: Deno.version.deno });
1262
+ return jsonResponse({
1263
+ ok: true,
1264
+ runtime: "deno",
1265
+ version: Deno.version.deno,
1266
+ });
660
1267
  }
661
1268
 
662
1269
  if (request.method === "POST" && url.pathname === "/run") {
663
1270
  let req: SandboxRunRequest;
1271
+ let requestBody: string;
664
1272
  try {
665
- req = (await request.json()) as SandboxRunRequest;
666
- } catch {
667
- return jsonResponse({ ok: false, error: "invalid JSON body", logs: [] }, 400);
1273
+ requestBody = await readBoundedRequestBody(request);
1274
+ req = JSON.parse(requestBody) as SandboxRunRequest;
1275
+ } catch (error) {
1276
+ if (error instanceof RequestBodyTooLargeError) {
1277
+ return jsonResponse(
1278
+ { ok: false, error: "request body too large", logs: [] },
1279
+ 413,
1280
+ );
1281
+ }
1282
+ return jsonResponse(
1283
+ { ok: false, error: "invalid JSON body", logs: [] },
1284
+ 400,
1285
+ );
1286
+ }
1287
+ let workloadAdmission: SandboxWorkloadAdmissionClaims | null = null;
1288
+ let workloadDenialReason: WorkloadAdmissionReason = "missing";
1289
+ let workloadDenialClaims: SandboxWorkloadAdmissionClaims | undefined;
1290
+ if (
1291
+ req?.mode === "host" &&
1292
+ !authenticatesHost(request.headers.get(HOST_ADMISSION_HEADER))
1293
+ ) {
1294
+ auditWorkloadAdmission("denied", "host_unauthorized");
1295
+ return jsonResponse(
1296
+ {
1297
+ ok: false,
1298
+ error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
1299
+ logs: [],
1300
+ },
1301
+ 403,
1302
+ );
1303
+ }
1304
+ if (req?.mode === "workload") {
1305
+ const envelope = request.headers.get(WORKLOAD_ADMISSION_HEADER);
1306
+ if (envelope && WORKLOAD_ADMISSION_SECRET) {
1307
+ try {
1308
+ const verification = await verifySandboxWorkloadAdmission(
1309
+ {
1310
+ keyId: WORKLOAD_ADMISSION_KEY_ID,
1311
+ secret: new TextEncoder().encode(WORKLOAD_ADMISSION_SECRET),
1312
+ instanceId: SANDBOX_INSTANCE_ID,
1313
+ },
1314
+ envelope,
1315
+ requestBody,
1316
+ );
1317
+ if (verification.ok) {
1318
+ workloadAdmission = verification.claims;
1319
+ } else {
1320
+ workloadDenialReason = verification.reason;
1321
+ workloadDenialClaims = verification.claims;
1322
+ }
1323
+ } catch {
1324
+ workloadAdmission = null;
1325
+ workloadDenialReason = "invalid";
1326
+ }
1327
+ }
668
1328
  }
669
- if (typeof req?.source !== "string" || !req?.capabilities) {
1329
+ if (req?.mode === "workload" && !workloadAdmission) {
1330
+ auditWorkloadAdmission(
1331
+ "denied",
1332
+ workloadDenialReason,
1333
+ workloadDenialClaims,
1334
+ );
670
1335
  return jsonResponse(
671
- { ok: false, error: "missing 'source' or 'capabilities'", logs: [] },
1336
+ {
1337
+ ok: false,
1338
+ error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
1339
+ logs: [],
1340
+ },
1341
+ 403,
1342
+ );
1343
+ }
1344
+ if (req?.mode !== "workload" && req?.mode !== "host") {
1345
+ auditWorkloadAdmission("denied", "unknown_mode");
1346
+ return jsonResponse(
1347
+ {
1348
+ ok: false,
1349
+ error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
1350
+ logs: [],
1351
+ },
1352
+ 403,
1353
+ );
1354
+ }
1355
+ const validatedRequest = parseSandboxRunRequest(req);
1356
+ if (!validatedRequest) {
1357
+ return jsonResponse(
1358
+ { ok: false, error: "invalid sandbox request", logs: [] },
672
1359
  400,
673
1360
  );
674
1361
  }
1362
+ req = validatedRequest;
1363
+ if (
1364
+ workloadAdmission &&
1365
+ !consumedWorkloadAdmissions.consume(workloadAdmission)
1366
+ ) {
1367
+ auditWorkloadAdmission("denied", "replay", workloadAdmission);
1368
+ return jsonResponse(
1369
+ {
1370
+ ok: false,
1371
+ error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
1372
+ logs: [],
1373
+ },
1374
+ 403,
1375
+ );
1376
+ }
1377
+ if (workloadAdmission) {
1378
+ auditWorkloadAdmission("allowed", "authorized", workloadAdmission);
1379
+ }
675
1380
  // runInSubprocess never throws — always a structured result.
676
- const result = await runInSubprocess(req);
1381
+ const result = await runInSubprocess(req, request.signal);
1382
+ if (
1383
+ req.mode === "workload" &&
1384
+ !result.ok &&
1385
+ !result.timedOut &&
1386
+ result.error !== "sandbox request cancelled"
1387
+ ) {
1388
+ return jsonResponse({
1389
+ ...result,
1390
+ error: "sandbox execution failed",
1391
+ });
1392
+ }
677
1393
  return jsonResponse(result);
678
1394
  }
679
1395