@questpie/sandbox 3.15.2 → 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.
@@ -0,0 +1,441 @@
1
+ import type { SandboxCustomToolsRunOptions } from "./custom-tools.js";
2
+ import type {
3
+ SandboxBindings,
4
+ SandboxCapabilities,
5
+ SandboxRunRequest,
6
+ } from "./types.js";
7
+
8
+ export const SANDBOX_WORKLOAD_DENIAL_MESSAGE =
9
+ "The workload is not authorized for this sandbox operation.";
10
+
11
+ export class SandboxWorkloadDeniedError extends Error {
12
+ readonly code = "sandbox_authority_denied" as const;
13
+
14
+ constructor() {
15
+ super(SANDBOX_WORKLOAD_DENIAL_MESSAGE);
16
+ this.name = "SandboxWorkloadDeniedError";
17
+ }
18
+ }
19
+
20
+ export interface SandboxWorkloadPolicy {
21
+ readonly source: string;
22
+ readonly input?: unknown;
23
+ readonly capabilities: SandboxCapabilities;
24
+ readonly secrets?: Readonly<Record<string, string>>;
25
+ readonly bindings?: SandboxBindings;
26
+ /** Host-only custom-tool authority. Never serialized to the supervisor. */
27
+ readonly sandboxTools?: SandboxCustomToolsRunOptions;
28
+ /** Optional consumer deadline; transport admission is additionally capped at 5s. */
29
+ readonly validUntil?: string;
30
+ }
31
+
32
+ export type SandboxWorkloadAuthorizationPhase = "prepare" | "dispatch";
33
+
34
+ export interface SandboxWorkloadAuthorizationContext {
35
+ readonly phase: SandboxWorkloadAuthorizationPhase;
36
+ readonly signal: AbortSignal;
37
+ }
38
+
39
+ export type SandboxWorkloadAuthorizer = (
40
+ envelope: unknown,
41
+ context: SandboxWorkloadAuthorizationContext,
42
+ ) => SandboxWorkloadPolicy | null | Promise<SandboxWorkloadPolicy | null>;
43
+
44
+ export interface SandboxWorkloadAuditEvent {
45
+ readonly boundary: "sandbox.workload";
46
+ readonly phase: SandboxWorkloadAuthorizationPhase | "transport";
47
+ readonly decision: "allowed" | "denied";
48
+ readonly reason:
49
+ | "authorization_failed"
50
+ | "authorization_timed_out"
51
+ | "cancelled"
52
+ | "egress_denied"
53
+ | "policy_changed"
54
+ | "policy_invalid"
55
+ | "authorization_succeeded"
56
+ | "transport_completed"
57
+ | "transport_denied"
58
+ | "transport_cancelled"
59
+ | "transport_timed_out"
60
+ | "transport_invalid_response"
61
+ | "transport_failed";
62
+ }
63
+
64
+ export interface SandboxWorkloadAuditContext {
65
+ readonly signal: AbortSignal;
66
+ }
67
+
68
+ export interface SandboxWorkloadRunOptions {
69
+ /** Consumer-owned opaque value. QUESTPIE never parses or persists it. */
70
+ readonly envelope: unknown;
71
+ readonly signal?: AbortSignal;
72
+ }
73
+
74
+ const MAX_SOURCE_BYTES = 1_048_576;
75
+ const MAX_NAMED_VALUES = 64;
76
+ const MAX_HOSTS_PER_AXIS = 32;
77
+ const MAX_TOTAL_HOSTS = 32;
78
+ const MAX_SECRET_VALUE_BYTES = 65_536;
79
+ const MAX_TOTAL_SECRET_BYTES = 262_144;
80
+ const MAX_INPUT_DEPTH = 32;
81
+ const MAX_INPUT_NODES = 10_000;
82
+ const MAX_INPUT_BYTES = 1_048_576;
83
+ const MAX_BINDINGS_URL_BYTES = 2_048;
84
+ const MAX_BINDINGS_TOKEN_BYTES = 8_192;
85
+ const utf8Encoder = new TextEncoder();
86
+
87
+ function isRecord(value: unknown): value is Record<string, unknown> {
88
+ return typeof value === "object" && value !== null && !Array.isArray(value);
89
+ }
90
+
91
+ /**
92
+ * Copy enumerable data descriptors without invoking getters. Proxy traps and
93
+ * exotic prototypes are rejected by the caller's fail-closed catch.
94
+ */
95
+ function snapshotDataRecord(value: unknown): Record<string, unknown> | null {
96
+ if (!isRecord(value)) return null;
97
+ const prototype = Object.getPrototypeOf(value);
98
+ if (prototype !== Object.prototype && prototype !== null) return null;
99
+ const result: Record<string, unknown> = Object.create(null);
100
+ for (const key of Reflect.ownKeys(value)) {
101
+ if (typeof key !== "string") return null;
102
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
103
+ if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) {
104
+ return null;
105
+ }
106
+ result[key] = descriptor.value;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ function exactOrOptionalKeys(
112
+ value: Record<string, unknown>,
113
+ required: readonly string[],
114
+ optional: readonly string[],
115
+ ): boolean {
116
+ const keys = Object.keys(value);
117
+ return (
118
+ required.every((key) => keys.includes(key)) &&
119
+ keys.every((key) => required.includes(key) || optional.includes(key))
120
+ );
121
+ }
122
+
123
+ function normalizeHosts(value: unknown): readonly string[] | null {
124
+ if (!Array.isArray(value) || value.length > MAX_HOSTS_PER_AXIS) return null;
125
+ const hosts: string[] = [];
126
+ for (const host of value) {
127
+ if (
128
+ typeof host !== "string" ||
129
+ host.length === 0 ||
130
+ host.length > 253 ||
131
+ host.includes("*") ||
132
+ host.includes("/") ||
133
+ /\s/.test(host)
134
+ ) {
135
+ return null;
136
+ }
137
+ hosts.push(host);
138
+ }
139
+ return Object.freeze(hosts);
140
+ }
141
+
142
+ function normalizeCapabilities(value: unknown): SandboxCapabilities | null {
143
+ const record = snapshotDataRecord(value);
144
+ if (
145
+ !record ||
146
+ !exactOrOptionalKeys(record, ["net", "import", "timeoutMs", "memoryMb"], [])
147
+ ) {
148
+ return null;
149
+ }
150
+ const net = normalizeHosts(record.net);
151
+ const imports = normalizeHosts(record.import);
152
+ if (
153
+ !net ||
154
+ !imports ||
155
+ net.length + imports.length > MAX_TOTAL_HOSTS ||
156
+ typeof record.timeoutMs !== "number" ||
157
+ !Number.isInteger(record.timeoutMs) ||
158
+ record.timeoutMs < 1 ||
159
+ record.timeoutMs > 30_000 ||
160
+ typeof record.memoryMb !== "number" ||
161
+ !Number.isInteger(record.memoryMb) ||
162
+ record.memoryMb < 16 ||
163
+ record.memoryMb > 1_024
164
+ ) {
165
+ return null;
166
+ }
167
+ return Object.freeze({
168
+ net: [...net],
169
+ import: [...imports],
170
+ timeoutMs: record.timeoutMs,
171
+ memoryMb: record.memoryMb,
172
+ });
173
+ }
174
+
175
+ function normalizeSecrets(
176
+ value: unknown,
177
+ ): Readonly<Record<string, string>> | null {
178
+ const record = snapshotDataRecord(value);
179
+ if (!record || Object.keys(record).length > MAX_NAMED_VALUES) return null;
180
+ const result: Record<string, string> = Object.create(null);
181
+ let totalBytes = 0;
182
+ for (const [name, secret] of Object.entries(record)) {
183
+ const nameBytes = utf8Encoder.encode(name).byteLength;
184
+ const secretBytes =
185
+ typeof secret === "string"
186
+ ? utf8Encoder.encode(secret).byteLength
187
+ : Number.POSITIVE_INFINITY;
188
+ totalBytes += nameBytes + secretBytes;
189
+ if (
190
+ !/^[A-Za-z0-9_.-]+$/.test(name) ||
191
+ typeof secret !== "string" ||
192
+ secretBytes > MAX_SECRET_VALUE_BYTES ||
193
+ totalBytes > MAX_TOTAL_SECRET_BYTES
194
+ ) {
195
+ return null;
196
+ }
197
+ result[name] = secret;
198
+ }
199
+ return Object.freeze(result);
200
+ }
201
+
202
+ function normalizeBindings(value: unknown): SandboxBindings | null {
203
+ const record = snapshotDataRecord(value);
204
+ if (
205
+ !record ||
206
+ !exactOrOptionalKeys(record, ["url", "token"], []) ||
207
+ typeof record.url !== "string" ||
208
+ record.url.length === 0 ||
209
+ utf8Encoder.encode(record.url).byteLength > MAX_BINDINGS_URL_BYTES ||
210
+ typeof record.token !== "string" ||
211
+ record.token.length === 0 ||
212
+ utf8Encoder.encode(record.token).byteLength > MAX_BINDINGS_TOKEN_BYTES
213
+ ) {
214
+ return null;
215
+ }
216
+ if (!URL.canParse(record.url)) return null;
217
+ return Object.freeze({ url: record.url, token: record.token });
218
+ }
219
+
220
+ interface JsonSnapshotBudget {
221
+ nodes: number;
222
+ bytes: number;
223
+ }
224
+
225
+ function consumeJsonBudget(budget: JsonSnapshotBudget, bytes: number): boolean {
226
+ budget.nodes += 1;
227
+ budget.bytes += bytes;
228
+ return budget.nodes <= MAX_INPUT_NODES && budget.bytes <= MAX_INPUT_BYTES;
229
+ }
230
+
231
+ function snapshotJson(
232
+ value: unknown,
233
+ seen = new Set<object>(),
234
+ budget: JsonSnapshotBudget = { nodes: 0, bytes: 0 },
235
+ depth = 0,
236
+ ): { ok: true; value: unknown } | { ok: false } {
237
+ if (depth > MAX_INPUT_DEPTH) return { ok: false };
238
+ if (
239
+ value === null ||
240
+ typeof value === "string" ||
241
+ typeof value === "boolean"
242
+ ) {
243
+ const bytes =
244
+ typeof value === "string" ? utf8Encoder.encode(value).byteLength : 5;
245
+ return consumeJsonBudget(budget, bytes)
246
+ ? { ok: true, value }
247
+ : { ok: false };
248
+ }
249
+ if (typeof value === "number") {
250
+ return Number.isFinite(value) && consumeJsonBudget(budget, 24)
251
+ ? { ok: true, value }
252
+ : { ok: false };
253
+ }
254
+ if (typeof value !== "object" || seen.has(value)) return { ok: false };
255
+ if (!consumeJsonBudget(budget, 2)) return { ok: false };
256
+ seen.add(value);
257
+
258
+ if (Array.isArray(value)) {
259
+ const descriptors = Object.getOwnPropertyDescriptors(value);
260
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
261
+ const arrayLength =
262
+ lengthDescriptor && "value" in lengthDescriptor
263
+ ? lengthDescriptor.value
264
+ : null;
265
+ if (
266
+ typeof arrayLength !== "number" ||
267
+ !Number.isInteger(arrayLength) ||
268
+ arrayLength < 0 ||
269
+ arrayLength > MAX_INPUT_NODES ||
270
+ Object.keys(descriptors).some(
271
+ (key) => key !== "length" && !/^(0|[1-9]\d*)$/.test(key),
272
+ )
273
+ ) {
274
+ return { ok: false };
275
+ }
276
+ const result: unknown[] = [];
277
+ for (let index = 0; index < arrayLength; index += 1) {
278
+ const descriptor = descriptors[String(index)];
279
+ if (!descriptor) {
280
+ if (!consumeJsonBudget(budget, 4)) return { ok: false };
281
+ result.push(null);
282
+ continue;
283
+ }
284
+ if (!descriptor.enumerable || !("value" in descriptor)) {
285
+ return { ok: false };
286
+ }
287
+ const item = snapshotJson(descriptor.value, seen, budget, depth + 1);
288
+ if (!item.ok) return item;
289
+ result.push(item.value);
290
+ }
291
+ seen.delete(value);
292
+ return { ok: true, value: Object.freeze(result) };
293
+ }
294
+
295
+ const record = snapshotDataRecord(value);
296
+ if (!record) return { ok: false };
297
+ const result: Record<string, unknown> = Object.create(null);
298
+ for (const [key, itemValue] of Object.entries(record)) {
299
+ if (!consumeJsonBudget(budget, utf8Encoder.encode(key).byteLength)) {
300
+ return { ok: false };
301
+ }
302
+ const item = snapshotJson(itemValue, seen, budget, depth + 1);
303
+ if (!item.ok) return item;
304
+ result[key] = item.value;
305
+ }
306
+ seen.delete(value);
307
+ return { ok: true, value: Object.freeze(result) };
308
+ }
309
+
310
+ export function snapshotSandboxWorkloadPolicy(
311
+ value: unknown,
312
+ now = new Date(),
313
+ ): SandboxWorkloadPolicy | null {
314
+ try {
315
+ const policy = snapshotDataRecord(value);
316
+ if (
317
+ !policy ||
318
+ !exactOrOptionalKeys(
319
+ policy,
320
+ ["source", "capabilities"],
321
+ ["input", "secrets", "bindings", "sandboxTools", "validUntil"],
322
+ ) ||
323
+ typeof policy.source !== "string" ||
324
+ policy.source.length === 0 ||
325
+ new TextEncoder().encode(policy.source).byteLength > MAX_SOURCE_BYTES
326
+ ) {
327
+ return null;
328
+ }
329
+ const capabilities = normalizeCapabilities(policy.capabilities);
330
+ const secrets = normalizeSecrets(policy.secrets ?? {});
331
+ const bindings =
332
+ policy.bindings === undefined
333
+ ? undefined
334
+ : normalizeBindings(policy.bindings);
335
+ const sandboxTools =
336
+ policy.sandboxTools === undefined
337
+ ? undefined
338
+ : snapshotDataRecord(policy.sandboxTools);
339
+ if (
340
+ !capabilities ||
341
+ !secrets ||
342
+ (policy.bindings !== undefined && !bindings) ||
343
+ (policy.sandboxTools !== undefined &&
344
+ (!sandboxTools ||
345
+ !exactOrOptionalKeys(sandboxTools, ["envelope"], []) ||
346
+ sandboxTools.envelope === undefined ||
347
+ !bindings))
348
+ ) {
349
+ return null;
350
+ }
351
+ if (
352
+ policy.validUntil !== undefined &&
353
+ (typeof policy.validUntil !== "string" ||
354
+ !Number.isFinite(Date.parse(policy.validUntil)) ||
355
+ Date.parse(policy.validUntil) <= now.getTime())
356
+ ) {
357
+ return null;
358
+ }
359
+ const input = snapshotJson(policy.input ?? null);
360
+ if (!input.ok) return null;
361
+ return Object.freeze({
362
+ source: policy.source,
363
+ input: input.value,
364
+ capabilities,
365
+ secrets,
366
+ ...(bindings ? { bindings } : {}),
367
+ ...(sandboxTools
368
+ ? {
369
+ sandboxTools: Object.freeze({
370
+ envelope: sandboxTools.envelope,
371
+ }),
372
+ }
373
+ : {}),
374
+ ...(typeof policy.validUntil === "string"
375
+ ? { validUntil: policy.validUntil }
376
+ : {}),
377
+ });
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ export function serializeSandboxWorkloadPolicy(
384
+ policy: SandboxWorkloadPolicy,
385
+ ): string {
386
+ return JSON.stringify({
387
+ source: policy.source,
388
+ input: policy.input ?? null,
389
+ capabilities: policy.capabilities,
390
+ secrets: policy.secrets ?? {},
391
+ ...(policy.bindings ? { bindings: policy.bindings } : {}),
392
+ ...(policy.validUntil ? { validUntil: policy.validUntil } : {}),
393
+ });
394
+ }
395
+
396
+ export function parseSandboxRunRequest(
397
+ value: unknown,
398
+ ): SandboxRunRequest | null {
399
+ try {
400
+ const request = snapshotDataRecord(value);
401
+ if (
402
+ !request ||
403
+ !exactOrOptionalKeys(
404
+ request,
405
+ ["mode", "source", "input", "capabilities"],
406
+ ["secrets", "bindings"],
407
+ ) ||
408
+ (request.mode !== "host" && request.mode !== "workload") ||
409
+ typeof request.source !== "string" ||
410
+ request.source.length === 0 ||
411
+ utf8Encoder.encode(request.source).byteLength > MAX_SOURCE_BYTES
412
+ ) {
413
+ return null;
414
+ }
415
+ const input = snapshotJson(request.input);
416
+ const capabilities = normalizeCapabilities(request.capabilities);
417
+ const secrets = normalizeSecrets(request.secrets ?? {});
418
+ const bindings =
419
+ request.bindings === undefined
420
+ ? undefined
421
+ : normalizeBindings(request.bindings);
422
+ if (
423
+ !input.ok ||
424
+ !capabilities ||
425
+ !secrets ||
426
+ (request.bindings !== undefined && !bindings)
427
+ ) {
428
+ return null;
429
+ }
430
+ return Object.freeze({
431
+ mode: request.mode,
432
+ source: request.source,
433
+ input: input.value,
434
+ capabilities,
435
+ secrets,
436
+ ...(bindings ? { bindings } : {}),
437
+ });
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
@@ -1,43 +0,0 @@
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 };