@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.
- package/README.md +203 -18
- package/dist/adapter-http-Coy4jqsj.mjs +865 -0
- package/dist/adapter-http-DDeL-Ym3.d.mts +259 -0
- package/dist/adapter.d.mts +3 -2
- package/dist/adapter.mjs +3 -2
- package/dist/custom-tools-CgfgyVNv.d.mts +55 -0
- package/dist/custom-tools-DxI7JvUn.mjs +407 -0
- package/dist/index.d.mts +40 -115
- package/dist/index.mjs +11 -14
- package/dist/modules/sandbox.d.mts +40 -0
- package/dist/modules/sandbox.mjs +211 -0
- package/package.json +20 -5
- package/src/broker-wire.ts +94 -0
- package/src/egress-firewall.ts +9 -3
- package/src/guest-bindings.ts +43 -1
- package/src/guest-entry.ts +132 -22
- package/src/guest-runtime-source.ts +38 -0
- package/src/net-validation.ts +204 -51
- package/src/sandbox-server.ts +860 -144
- package/src/server-internals.ts +195 -12
- package/src/types.ts +38 -10
- package/src/workload-admission.ts +289 -0
- package/src/workload.ts +441 -0
- package/dist/adapter-http-7ypWHBck.d.mts +0 -43
- package/dist/adapter-http-w7SjAfcC.mjs +0 -298
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { i as parseSandboxRunResult, n as registerSandboxCustomToolsSession } from "./custom-tools-DxI7JvUn.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/net-validation.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parse a `host[:port]` allowlist entry (Deno `--allow-net` syntax).
|
|
6
|
+
* Bracketed IPv6 (`[::1]:443`) and bare IPv6 (`::1`) are both handled.
|
|
7
|
+
*/
|
|
8
|
+
function parseHostEntry(entry) {
|
|
9
|
+
const trimmed = entry.trim();
|
|
10
|
+
if (trimmed.startsWith("[")) {
|
|
11
|
+
const close = trimmed.indexOf("]");
|
|
12
|
+
if (close === -1) return { host: trimmed };
|
|
13
|
+
const host = trimmed.slice(1, close);
|
|
14
|
+
const rest = trimmed.slice(close + 1);
|
|
15
|
+
if (rest.startsWith(":")) {
|
|
16
|
+
const port = Number(rest.slice(1));
|
|
17
|
+
return Number.isInteger(port) ? {
|
|
18
|
+
host,
|
|
19
|
+
port
|
|
20
|
+
} : { host };
|
|
21
|
+
}
|
|
22
|
+
return { host };
|
|
23
|
+
}
|
|
24
|
+
if ((trimmed.match(/:/g)?.length ?? 0) > 1) return { host: trimmed };
|
|
25
|
+
const lastColon = trimmed.lastIndexOf(":");
|
|
26
|
+
if (lastColon !== -1) {
|
|
27
|
+
const maybePort = Number(trimmed.slice(lastColon + 1));
|
|
28
|
+
if (Number.isInteger(maybePort)) return {
|
|
29
|
+
host: trimmed.slice(0, lastColon),
|
|
30
|
+
port: maybePort
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { host: trimmed };
|
|
34
|
+
}
|
|
35
|
+
/** True if `s` is a syntactically-valid dotted-quad IPv4 literal. */
|
|
36
|
+
function isIpv4Literal(s) {
|
|
37
|
+
const parts = s.split(".");
|
|
38
|
+
if (parts.length !== 4) return false;
|
|
39
|
+
return parts.every((p) => {
|
|
40
|
+
if (!/^\d{1,3}$/.test(p)) return false;
|
|
41
|
+
const n = Number(p);
|
|
42
|
+
return n >= 0 && n <= 255;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** True if `s` looks like an IPv6 literal (contains a colon, hex/`:`/`.` only). */
|
|
46
|
+
function isIpv6Literal(s) {
|
|
47
|
+
if (!s.includes(":")) return false;
|
|
48
|
+
return /^[0-9a-fA-F:.]+$/.test(s);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Classify an IPv4 literal as private/link-local/loopback/metadata/CGNAT.
|
|
52
|
+
* Ranges (all per the task + cloud-metadata convention):
|
|
53
|
+
* 0.0.0.0/8 (this-host), 127/8 (loopback), 10/8, 172.16/12, 192.168/16 (RFC-1918),
|
|
54
|
+
* 169.254/16 (link-local, incl. 169.254.169.254 metadata), 100.64/10 (CGNAT/RFC-6598).
|
|
55
|
+
*/
|
|
56
|
+
function isPrivateIpv4(ip) {
|
|
57
|
+
const [a, b] = ip.split(".").map(Number);
|
|
58
|
+
if (a === 0) return true;
|
|
59
|
+
if (a === 127) return true;
|
|
60
|
+
if (a === 10) return true;
|
|
61
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
62
|
+
if (a === 192 && b === 168) return true;
|
|
63
|
+
if (a === 169 && b === 254) return true;
|
|
64
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Normalize an IPv6 literal to lowercase, stripping a `%zone` suffix. */
|
|
68
|
+
function normalizeIpv6(ip) {
|
|
69
|
+
const pct = ip.indexOf("%");
|
|
70
|
+
return (pct === -1 ? ip : ip.slice(0, pct)).toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Classify an IPv6 literal as private/link-local/loopback/metadata.
|
|
74
|
+
* Covers: ::1 (loopback), :: (unspecified), fc00::/7 (unique-local),
|
|
75
|
+
* fe80::/10 (link-local), and IPv4-mapped/translated forms (::ffff:a.b.c.d,
|
|
76
|
+
* ::a.b.c.d) by delegating their embedded IPv4 to `isPrivateIpv4`.
|
|
77
|
+
*/
|
|
78
|
+
function isPrivateIpv6(raw) {
|
|
79
|
+
const ip = normalizeIpv6(raw);
|
|
80
|
+
if (ip === "::1") return true;
|
|
81
|
+
if (ip === "::" || ip === "") return true;
|
|
82
|
+
const v4Tail = ip.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
83
|
+
if (v4Tail && isIpv4Literal(v4Tail[1])) {
|
|
84
|
+
if (ip.startsWith("::")) return isPrivateIpv4(v4Tail[1]);
|
|
85
|
+
}
|
|
86
|
+
const mapped = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
87
|
+
if (mapped) {
|
|
88
|
+
const hi = Number.parseInt(mapped[1], 16) & 65535;
|
|
89
|
+
const lo = Number.parseInt(mapped[2], 16) & 65535;
|
|
90
|
+
return isPrivateIpv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
91
|
+
}
|
|
92
|
+
if (/^f[cd][0-9a-f]{0,2}:/.test(ip) || ip.startsWith("fc") || ip.startsWith("fd")) {
|
|
93
|
+
const first = ip.split(":")[0];
|
|
94
|
+
if (first.length > 0) {
|
|
95
|
+
if ((Number.parseInt(first.padStart(4, "0").slice(0, 2), 16) & 254) === 252) return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) return true;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Classify a bare IP literal (v4 or v6) as a blocked egress target.
|
|
103
|
+
* Returns `{ ok:false, reason }` when the IP is private/link-local/loopback/metadata.
|
|
104
|
+
*/
|
|
105
|
+
function classifyIpLiteral(ip) {
|
|
106
|
+
if (isIpv4Literal(ip)) return isPrivateIpv4(ip) ? {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `private/link-local/loopback IPv4: ${ip}`
|
|
109
|
+
} : { ok: true };
|
|
110
|
+
if (isIpv6Literal(ip)) return isPrivateIpv6(ip) ? {
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: `private/link-local/loopback IPv6: ${ip}`
|
|
113
|
+
} : { ok: true };
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
/** Bound each DNS lookup so a slow/hanging resolver can't stall validation. */
|
|
117
|
+
const DNS_TIMEOUT_MS = 3e3;
|
|
118
|
+
function withDnsTimeout(promise, timeoutMs = DNS_TIMEOUT_MS, signal) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
let settled = false;
|
|
121
|
+
const finish = (callback) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
signal?.removeEventListener("abort", onAbort);
|
|
126
|
+
callback();
|
|
127
|
+
};
|
|
128
|
+
const onAbort = () => finish(() => reject(/* @__PURE__ */ new Error("DNS lookup cancelled")));
|
|
129
|
+
const timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error(`DNS lookup timed out (${timeoutMs}ms)`))), timeoutMs);
|
|
130
|
+
if (signal?.aborted) onAbort();
|
|
131
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Lazily load `node:dns/promises` `resolve` (works on both Bun and Deno). */
|
|
136
|
+
let cachedResolver;
|
|
137
|
+
async function defaultResolver(hostname) {
|
|
138
|
+
if (!cachedResolver) {
|
|
139
|
+
const dns = await import("node:dns/promises");
|
|
140
|
+
cachedResolver = async (h) => {
|
|
141
|
+
const [v4, v6] = await Promise.allSettled([withDnsTimeout(dns.resolve4(h)), withDnsTimeout(dns.resolve6(h))]);
|
|
142
|
+
const out = [];
|
|
143
|
+
if (v4.status === "fulfilled") out.push(...v4.value);
|
|
144
|
+
if (v6.status === "fulfilled") out.push(...v6.value);
|
|
145
|
+
if (out.length === 0 && v4.status === "rejected" && v6.status === "rejected") throw new Error(`DNS resolution failed (v4: ${reasonOf(v4)}, v6: ${reasonOf(v6)})`);
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return cachedResolver(hostname);
|
|
150
|
+
}
|
|
151
|
+
function reasonOf(settled) {
|
|
152
|
+
const r = settled.reason;
|
|
153
|
+
return r instanceof Error ? r.message : String(r);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Validate a single `host[:port]` allowlist entry against the private-IP policy.
|
|
157
|
+
*
|
|
158
|
+
* - IP literals are classified directly (no DNS).
|
|
159
|
+
* - Hostnames are DNS-resolved; if ANY resolved address is private/link-local/
|
|
160
|
+
* loopback/metadata, the host is REJECTED (DNS-rebind defense at manifest time).
|
|
161
|
+
* - `localhost` and the `.local` mDNS suffix are rejected up-front.
|
|
162
|
+
*
|
|
163
|
+
* `resolve` is injectable for tests / Deno; defaults to `node:dns/promises`.
|
|
164
|
+
*/
|
|
165
|
+
async function validateHostEgress(entry, opts = {}) {
|
|
166
|
+
if (opts.signal?.aborted) return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: "DNS lookup cancelled"
|
|
169
|
+
};
|
|
170
|
+
const { host } = parseHostEntry(entry);
|
|
171
|
+
const lower = host.toLowerCase();
|
|
172
|
+
if (lower.length === 0) return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "empty host"
|
|
175
|
+
};
|
|
176
|
+
if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: `loopback/mDNS hostname not allowed: ${host}`
|
|
179
|
+
};
|
|
180
|
+
const literal = classifyIpLiteral(host);
|
|
181
|
+
if (!literal.ok) return literal;
|
|
182
|
+
if (isIpv4Literal(host) || isIpv6Literal(host)) return { ok: true };
|
|
183
|
+
const resolver = opts.resolve ?? defaultResolver;
|
|
184
|
+
let addrs;
|
|
185
|
+
try {
|
|
186
|
+
addrs = await withDnsTimeout(resolver(host), opts.timeoutMs ?? DNS_TIMEOUT_MS, opts.signal);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: `DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (addrs.length === 0) return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: `DNS resolution returned no addresses for ${host}`
|
|
196
|
+
};
|
|
197
|
+
for (const addr of addrs) {
|
|
198
|
+
const c = classifyIpLiteral(addr);
|
|
199
|
+
if (!c.ok) return {
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: `${host} resolves to a blocked address (${c.reason})`
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return { ok: true };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Validate ALL `net` + `import` hosts in a capability set.
|
|
208
|
+
* Returns the first rejection found, or `{ ok:true }` if every host passes.
|
|
209
|
+
*/
|
|
210
|
+
async function validateEgressHosts(hosts, opts = {}) {
|
|
211
|
+
if (hosts.length === 0) return { ok: true };
|
|
212
|
+
const controller = new AbortController();
|
|
213
|
+
const onAbort = () => controller.abort();
|
|
214
|
+
if (opts.signal?.aborted) controller.abort();
|
|
215
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
216
|
+
const timeoutMs = Math.max(1, Math.min(DNS_TIMEOUT_MS, Math.floor(opts.timeoutMs ?? DNS_TIMEOUT_MS)));
|
|
217
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
218
|
+
const concurrency = Math.max(1, Math.min(8, Math.floor(opts.concurrency ?? 4)));
|
|
219
|
+
const results = Array.from({ length: hosts.length });
|
|
220
|
+
let cursor = 0;
|
|
221
|
+
const worker = async () => {
|
|
222
|
+
while (cursor < hosts.length) {
|
|
223
|
+
const index = cursor;
|
|
224
|
+
cursor += 1;
|
|
225
|
+
results[index] = await validateHostEgress(hosts[index], {
|
|
226
|
+
...opts,
|
|
227
|
+
signal: controller.signal,
|
|
228
|
+
timeoutMs
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
try {
|
|
233
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, hosts.length) }, () => worker()));
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
237
|
+
}
|
|
238
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
239
|
+
const result = results[index];
|
|
240
|
+
if (!result.ok) return {
|
|
241
|
+
ok: false,
|
|
242
|
+
reason: `host "${hosts[index]}": ${result.reason}`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return { ok: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/types.ts
|
|
250
|
+
/** Line prefix marking a framed protocol message on guest stdout. */
|
|
251
|
+
const FRAME_MARKER = "__QP_SANDBOX_MSG__";
|
|
252
|
+
/**
|
|
253
|
+
* Header carrying the per-run scoped token on the supervisor → broker request.
|
|
254
|
+
* Mirrors `questpie`'s `BINDINGS_TOKEN_HEADER`. Supervisor-only; never set by or
|
|
255
|
+
* exposed to guest code.
|
|
256
|
+
*/
|
|
257
|
+
const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
|
|
258
|
+
/** Host-issued admission binding an authorized workload to one exact request. */
|
|
259
|
+
const WORKLOAD_ADMISSION_HEADER = "x-questpie-workload-admission";
|
|
260
|
+
/** Trusted service identity for the structurally separate direct-host path. */
|
|
261
|
+
const HOST_ADMISSION_HEADER = "x-questpie-sandbox-host-admission";
|
|
262
|
+
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/workload-admission.ts
|
|
265
|
+
const ADMISSION_VERSION = "qpsw1";
|
|
266
|
+
const MINIMUM_SECRET_BYTES = 32;
|
|
267
|
+
const encoder = new TextEncoder();
|
|
268
|
+
const decoder = new TextDecoder();
|
|
269
|
+
function encodeBase64Url(value) {
|
|
270
|
+
let binary = "";
|
|
271
|
+
for (const byte of value) binary += String.fromCharCode(byte);
|
|
272
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
273
|
+
}
|
|
274
|
+
function isIdentity(value) {
|
|
275
|
+
return typeof value === "string" && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
|
|
276
|
+
}
|
|
277
|
+
function assertSandboxWorkloadAdmissionKey(key) {
|
|
278
|
+
if (!isIdentity(key.keyId) || !isIdentity(key.instanceId) || key.secret.byteLength < MINIMUM_SECRET_BYTES) throw new Error("Invalid sandbox workload admission configuration.");
|
|
279
|
+
}
|
|
280
|
+
async function importHmacKey(key, usage) {
|
|
281
|
+
assertSandboxWorkloadAdmissionKey(key);
|
|
282
|
+
return crypto.subtle.importKey("raw", Uint8Array.from(key.secret), {
|
|
283
|
+
name: "HMAC",
|
|
284
|
+
hash: "SHA-256"
|
|
285
|
+
}, false, [usage]);
|
|
286
|
+
}
|
|
287
|
+
async function sha256(value) {
|
|
288
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
|
289
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
290
|
+
}
|
|
291
|
+
async function sealSandboxWorkloadAdmission(key, claims, requestBody) {
|
|
292
|
+
const payload = {
|
|
293
|
+
...claims,
|
|
294
|
+
requestSha256: await sha256(requestBody)
|
|
295
|
+
};
|
|
296
|
+
const signed = `${ADMISSION_VERSION}.${encodeBase64Url(encoder.encode(key.keyId))}.${encodeBase64Url(encoder.encode(JSON.stringify(payload)))}`;
|
|
297
|
+
const cryptoKey = await importHmacKey(key, "sign");
|
|
298
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(signed));
|
|
299
|
+
return `${signed}.${encodeBase64Url(new Uint8Array(signature))}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/workload.ts
|
|
304
|
+
const SANDBOX_WORKLOAD_DENIAL_MESSAGE = "The workload is not authorized for this sandbox operation.";
|
|
305
|
+
var SandboxWorkloadDeniedError = class extends Error {
|
|
306
|
+
code = "sandbox_authority_denied";
|
|
307
|
+
constructor() {
|
|
308
|
+
super(SANDBOX_WORKLOAD_DENIAL_MESSAGE);
|
|
309
|
+
this.name = "SandboxWorkloadDeniedError";
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const MAX_SOURCE_BYTES = 1048576;
|
|
313
|
+
const MAX_NAMED_VALUES = 64;
|
|
314
|
+
const MAX_HOSTS_PER_AXIS = 32;
|
|
315
|
+
const MAX_TOTAL_HOSTS = 32;
|
|
316
|
+
const MAX_SECRET_VALUE_BYTES = 65536;
|
|
317
|
+
const MAX_TOTAL_SECRET_BYTES = 262144;
|
|
318
|
+
const MAX_INPUT_DEPTH = 32;
|
|
319
|
+
const MAX_INPUT_NODES = 1e4;
|
|
320
|
+
const MAX_INPUT_BYTES = 1048576;
|
|
321
|
+
const MAX_BINDINGS_URL_BYTES = 2048;
|
|
322
|
+
const MAX_BINDINGS_TOKEN_BYTES = 8192;
|
|
323
|
+
const utf8Encoder = new TextEncoder();
|
|
324
|
+
function isRecord(value) {
|
|
325
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Copy enumerable data descriptors without invoking getters. Proxy traps and
|
|
329
|
+
* exotic prototypes are rejected by the caller's fail-closed catch.
|
|
330
|
+
*/
|
|
331
|
+
function snapshotDataRecord(value) {
|
|
332
|
+
if (!isRecord(value)) return null;
|
|
333
|
+
const prototype = Object.getPrototypeOf(value);
|
|
334
|
+
if (prototype !== Object.prototype && prototype !== null) return null;
|
|
335
|
+
const result = Object.create(null);
|
|
336
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
337
|
+
if (typeof key !== "string") return null;
|
|
338
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
339
|
+
if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) return null;
|
|
340
|
+
result[key] = descriptor.value;
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
function exactOrOptionalKeys(value, required, optional) {
|
|
345
|
+
const keys = Object.keys(value);
|
|
346
|
+
return required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key));
|
|
347
|
+
}
|
|
348
|
+
function normalizeHosts(value) {
|
|
349
|
+
if (!Array.isArray(value) || value.length > MAX_HOSTS_PER_AXIS) return null;
|
|
350
|
+
const hosts = [];
|
|
351
|
+
for (const host of value) {
|
|
352
|
+
if (typeof host !== "string" || host.length === 0 || host.length > 253 || host.includes("*") || host.includes("/") || /\s/.test(host)) return null;
|
|
353
|
+
hosts.push(host);
|
|
354
|
+
}
|
|
355
|
+
return Object.freeze(hosts);
|
|
356
|
+
}
|
|
357
|
+
function normalizeCapabilities(value) {
|
|
358
|
+
const record = snapshotDataRecord(value);
|
|
359
|
+
if (!record || !exactOrOptionalKeys(record, [
|
|
360
|
+
"net",
|
|
361
|
+
"import",
|
|
362
|
+
"timeoutMs",
|
|
363
|
+
"memoryMb"
|
|
364
|
+
], [])) return null;
|
|
365
|
+
const net = normalizeHosts(record.net);
|
|
366
|
+
const imports = normalizeHosts(record.import);
|
|
367
|
+
if (!net || !imports || net.length + imports.length > MAX_TOTAL_HOSTS || typeof record.timeoutMs !== "number" || !Number.isInteger(record.timeoutMs) || record.timeoutMs < 1 || record.timeoutMs > 3e4 || typeof record.memoryMb !== "number" || !Number.isInteger(record.memoryMb) || record.memoryMb < 16 || record.memoryMb > 1024) return null;
|
|
368
|
+
return Object.freeze({
|
|
369
|
+
net: [...net],
|
|
370
|
+
import: [...imports],
|
|
371
|
+
timeoutMs: record.timeoutMs,
|
|
372
|
+
memoryMb: record.memoryMb
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function normalizeSecrets(value) {
|
|
376
|
+
const record = snapshotDataRecord(value);
|
|
377
|
+
if (!record || Object.keys(record).length > MAX_NAMED_VALUES) return null;
|
|
378
|
+
const result = Object.create(null);
|
|
379
|
+
let totalBytes = 0;
|
|
380
|
+
for (const [name, secret] of Object.entries(record)) {
|
|
381
|
+
const nameBytes = utf8Encoder.encode(name).byteLength;
|
|
382
|
+
const secretBytes = typeof secret === "string" ? utf8Encoder.encode(secret).byteLength : Number.POSITIVE_INFINITY;
|
|
383
|
+
totalBytes += nameBytes + secretBytes;
|
|
384
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name) || typeof secret !== "string" || secretBytes > MAX_SECRET_VALUE_BYTES || totalBytes > MAX_TOTAL_SECRET_BYTES) return null;
|
|
385
|
+
result[name] = secret;
|
|
386
|
+
}
|
|
387
|
+
return Object.freeze(result);
|
|
388
|
+
}
|
|
389
|
+
function normalizeBindings(value) {
|
|
390
|
+
const record = snapshotDataRecord(value);
|
|
391
|
+
if (!record || !exactOrOptionalKeys(record, ["url", "token"], []) || typeof record.url !== "string" || record.url.length === 0 || utf8Encoder.encode(record.url).byteLength > MAX_BINDINGS_URL_BYTES || typeof record.token !== "string" || record.token.length === 0 || utf8Encoder.encode(record.token).byteLength > MAX_BINDINGS_TOKEN_BYTES) return null;
|
|
392
|
+
if (!URL.canParse(record.url)) return null;
|
|
393
|
+
return Object.freeze({
|
|
394
|
+
url: record.url,
|
|
395
|
+
token: record.token
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function consumeJsonBudget(budget, bytes) {
|
|
399
|
+
budget.nodes += 1;
|
|
400
|
+
budget.bytes += bytes;
|
|
401
|
+
return budget.nodes <= MAX_INPUT_NODES && budget.bytes <= MAX_INPUT_BYTES;
|
|
402
|
+
}
|
|
403
|
+
function snapshotJson(value, seen = /* @__PURE__ */ new Set(), budget = {
|
|
404
|
+
nodes: 0,
|
|
405
|
+
bytes: 0
|
|
406
|
+
}, depth = 0) {
|
|
407
|
+
if (depth > MAX_INPUT_DEPTH) return { ok: false };
|
|
408
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return consumeJsonBudget(budget, typeof value === "string" ? utf8Encoder.encode(value).byteLength : 5) ? {
|
|
409
|
+
ok: true,
|
|
410
|
+
value
|
|
411
|
+
} : { ok: false };
|
|
412
|
+
if (typeof value === "number") return Number.isFinite(value) && consumeJsonBudget(budget, 24) ? {
|
|
413
|
+
ok: true,
|
|
414
|
+
value
|
|
415
|
+
} : { ok: false };
|
|
416
|
+
if (typeof value !== "object" || seen.has(value)) return { ok: false };
|
|
417
|
+
if (!consumeJsonBudget(budget, 2)) return { ok: false };
|
|
418
|
+
seen.add(value);
|
|
419
|
+
if (Array.isArray(value)) {
|
|
420
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
421
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
|
|
422
|
+
const arrayLength = lengthDescriptor && "value" in lengthDescriptor ? lengthDescriptor.value : null;
|
|
423
|
+
if (typeof arrayLength !== "number" || !Number.isInteger(arrayLength) || arrayLength < 0 || arrayLength > MAX_INPUT_NODES || Object.keys(descriptors).some((key) => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) return { ok: false };
|
|
424
|
+
const result$1 = [];
|
|
425
|
+
for (let index = 0; index < arrayLength; index += 1) {
|
|
426
|
+
const descriptor = descriptors[String(index)];
|
|
427
|
+
if (!descriptor) {
|
|
428
|
+
if (!consumeJsonBudget(budget, 4)) return { ok: false };
|
|
429
|
+
result$1.push(null);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (!descriptor.enumerable || !("value" in descriptor)) return { ok: false };
|
|
433
|
+
const item = snapshotJson(descriptor.value, seen, budget, depth + 1);
|
|
434
|
+
if (!item.ok) return item;
|
|
435
|
+
result$1.push(item.value);
|
|
436
|
+
}
|
|
437
|
+
seen.delete(value);
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
value: Object.freeze(result$1)
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const record = snapshotDataRecord(value);
|
|
444
|
+
if (!record) return { ok: false };
|
|
445
|
+
const result = Object.create(null);
|
|
446
|
+
for (const [key, itemValue] of Object.entries(record)) {
|
|
447
|
+
if (!consumeJsonBudget(budget, utf8Encoder.encode(key).byteLength)) return { ok: false };
|
|
448
|
+
const item = snapshotJson(itemValue, seen, budget, depth + 1);
|
|
449
|
+
if (!item.ok) return item;
|
|
450
|
+
result[key] = item.value;
|
|
451
|
+
}
|
|
452
|
+
seen.delete(value);
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
value: Object.freeze(result)
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function snapshotSandboxWorkloadPolicy(value, now = /* @__PURE__ */ new Date()) {
|
|
459
|
+
try {
|
|
460
|
+
const policy = snapshotDataRecord(value);
|
|
461
|
+
if (!policy || !exactOrOptionalKeys(policy, ["source", "capabilities"], [
|
|
462
|
+
"input",
|
|
463
|
+
"secrets",
|
|
464
|
+
"bindings",
|
|
465
|
+
"sandboxTools",
|
|
466
|
+
"validUntil"
|
|
467
|
+
]) || typeof policy.source !== "string" || policy.source.length === 0 || new TextEncoder().encode(policy.source).byteLength > MAX_SOURCE_BYTES) return null;
|
|
468
|
+
const capabilities = normalizeCapabilities(policy.capabilities);
|
|
469
|
+
const secrets = normalizeSecrets(policy.secrets ?? {});
|
|
470
|
+
const bindings = policy.bindings === void 0 ? void 0 : normalizeBindings(policy.bindings);
|
|
471
|
+
const sandboxTools = policy.sandboxTools === void 0 ? void 0 : snapshotDataRecord(policy.sandboxTools);
|
|
472
|
+
if (!capabilities || !secrets || policy.bindings !== void 0 && !bindings || policy.sandboxTools !== void 0 && (!sandboxTools || !exactOrOptionalKeys(sandboxTools, ["envelope"], []) || sandboxTools.envelope === void 0 || !bindings)) return null;
|
|
473
|
+
if (policy.validUntil !== void 0 && (typeof policy.validUntil !== "string" || !Number.isFinite(Date.parse(policy.validUntil)) || Date.parse(policy.validUntil) <= now.getTime())) return null;
|
|
474
|
+
const input = snapshotJson(policy.input ?? null);
|
|
475
|
+
if (!input.ok) return null;
|
|
476
|
+
return Object.freeze({
|
|
477
|
+
source: policy.source,
|
|
478
|
+
input: input.value,
|
|
479
|
+
capabilities,
|
|
480
|
+
secrets,
|
|
481
|
+
...bindings ? { bindings } : {},
|
|
482
|
+
...sandboxTools ? { sandboxTools: Object.freeze({ envelope: sandboxTools.envelope }) } : {},
|
|
483
|
+
...typeof policy.validUntil === "string" ? { validUntil: policy.validUntil } : {}
|
|
484
|
+
});
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function serializeSandboxWorkloadPolicy(policy) {
|
|
490
|
+
return JSON.stringify({
|
|
491
|
+
source: policy.source,
|
|
492
|
+
input: policy.input ?? null,
|
|
493
|
+
capabilities: policy.capabilities,
|
|
494
|
+
secrets: policy.secrets ?? {},
|
|
495
|
+
...policy.bindings ? { bindings: policy.bindings } : {},
|
|
496
|
+
...policy.validUntil ? { validUntil: policy.validUntil } : {}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/adapter-http.ts
|
|
502
|
+
const DEFAULT_GUEST_TIMEOUT_MS = 5e3;
|
|
503
|
+
const MAX_WORKLOAD_ADMISSION_TTL_MS = 5e3;
|
|
504
|
+
const DEFAULT_AUTHORIZATION_TIMEOUT_MS = 2e3;
|
|
505
|
+
const MAX_AUTHORIZATION_TIMEOUT_MS = 5e3;
|
|
506
|
+
const DEFAULT_AUDIT_TIMEOUT_MS = 250;
|
|
507
|
+
const MAX_AUDIT_TIMEOUT_MS = 1e3;
|
|
508
|
+
const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
|
|
509
|
+
const MAX_RESPONSE_BODY_BYTES = 2 * 1024 * 1024;
|
|
510
|
+
function opaqueBindingToken() {
|
|
511
|
+
const bytes = new Uint8Array(32);
|
|
512
|
+
crypto.getRandomValues(bytes);
|
|
513
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
514
|
+
}
|
|
515
|
+
var OperationDeadlineError = class extends Error {};
|
|
516
|
+
var OperationCancelledError = class extends Error {};
|
|
517
|
+
var ResponseBodyTooLargeError = class extends Error {};
|
|
518
|
+
async function readResponseTextBounded(response, maximumBytes) {
|
|
519
|
+
if (!response.body) return "";
|
|
520
|
+
const reader = response.body.getReader();
|
|
521
|
+
const chunks = [];
|
|
522
|
+
let total = 0;
|
|
523
|
+
while (true) {
|
|
524
|
+
const { value, done } = await reader.read();
|
|
525
|
+
if (done) break;
|
|
526
|
+
total += value.byteLength;
|
|
527
|
+
if (total > maximumBytes) {
|
|
528
|
+
await reader.cancel();
|
|
529
|
+
throw new ResponseBodyTooLargeError();
|
|
530
|
+
}
|
|
531
|
+
chunks.push(value);
|
|
532
|
+
}
|
|
533
|
+
const body = new Uint8Array(total);
|
|
534
|
+
let offset = 0;
|
|
535
|
+
for (const chunk of chunks) {
|
|
536
|
+
body.set(chunk, offset);
|
|
537
|
+
offset += chunk.byteLength;
|
|
538
|
+
}
|
|
539
|
+
return new TextDecoder().decode(body);
|
|
540
|
+
}
|
|
541
|
+
function boundedMs(value, fallback, maximum) {
|
|
542
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.min(maximum, Math.max(1, Math.round(value))) : fallback;
|
|
543
|
+
}
|
|
544
|
+
async function settleWithin(work, timeoutMs, parent) {
|
|
545
|
+
const controller = new AbortController();
|
|
546
|
+
const onAbort = () => controller.abort(new OperationCancelledError());
|
|
547
|
+
if (parent?.aborted) controller.abort(new OperationCancelledError());
|
|
548
|
+
else parent?.addEventListener("abort", onAbort, { once: true });
|
|
549
|
+
let timer;
|
|
550
|
+
try {
|
|
551
|
+
return await Promise.race([Promise.resolve().then(() => work(controller.signal)), new Promise((_, reject) => {
|
|
552
|
+
timer = setTimeout(() => {
|
|
553
|
+
const error = new OperationDeadlineError();
|
|
554
|
+
controller.abort(error);
|
|
555
|
+
reject(error);
|
|
556
|
+
}, timeoutMs);
|
|
557
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new OperationCancelledError()), { once: true });
|
|
558
|
+
if (controller.signal.aborted) reject(controller.signal.reason ?? new OperationCancelledError());
|
|
559
|
+
})]);
|
|
560
|
+
} finally {
|
|
561
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
562
|
+
parent?.removeEventListener("abort", onAbort);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Production sandboxed `ExecutorAdapter` — POSTs the run to the standalone Deno
|
|
567
|
+
* sandbox service over HTTP. The main app (Bun/Node) holds NO Deno; it only
|
|
568
|
+
* speaks this JSON contract.
|
|
569
|
+
*
|
|
570
|
+
* Use as the `sandboxed` adapter in the executor config:
|
|
571
|
+
* executor: { sandboxed: httpSandboxAdapter({ url: process.env.SANDBOX_URL }) }
|
|
572
|
+
*/
|
|
573
|
+
var HttpSandboxAdapter = class {
|
|
574
|
+
fetchImpl;
|
|
575
|
+
validateEgress;
|
|
576
|
+
constructor(options = {}) {
|
|
577
|
+
this.options = options;
|
|
578
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
579
|
+
this.validateEgress = options.validateEgress ?? true;
|
|
580
|
+
}
|
|
581
|
+
resolveUrl() {
|
|
582
|
+
const raw = this.options.url ?? (typeof process !== "undefined" ? process.env?.SANDBOX_URL : void 0);
|
|
583
|
+
if (!raw) throw new Error("HttpSandboxAdapter: no sandbox URL configured. Pass `url` or set SANDBOX_URL.");
|
|
584
|
+
let url;
|
|
585
|
+
try {
|
|
586
|
+
url = new URL(raw);
|
|
587
|
+
} catch {
|
|
588
|
+
throw new Error("HttpSandboxAdapter: sandbox URL must be canonical HTTP(S).");
|
|
589
|
+
}
|
|
590
|
+
if (url.protocol !== "http:" && url.protocol !== "https:" || url.username.length > 0 || url.password.length > 0 || url.hash.length > 0 || url.search.length > 0 || url.pathname !== "/" || raw !== url.origin && raw !== `${url.origin}/`) throw new Error("HttpSandboxAdapter: sandbox URL must be canonical HTTP(S).");
|
|
591
|
+
return url.origin;
|
|
592
|
+
}
|
|
593
|
+
async postRun(requestBody, timeoutMs, headers, signal, sanitizeTransportErrors = false, onResponseStatus) {
|
|
594
|
+
const fetchTimeoutMs = this.options.fetchTimeoutMs ?? timeoutMs + 1e4;
|
|
595
|
+
try {
|
|
596
|
+
if (signal?.aborted) return {
|
|
597
|
+
ok: false,
|
|
598
|
+
error: "sandbox request cancelled",
|
|
599
|
+
logs: []
|
|
600
|
+
};
|
|
601
|
+
const { res, text } = await settleWithin(async (requestSignal) => {
|
|
602
|
+
const res$1 = await this.fetchImpl(`${this.resolveUrl()}/run`, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
redirect: "error",
|
|
605
|
+
headers: {
|
|
606
|
+
"content-type": "application/json",
|
|
607
|
+
...headers
|
|
608
|
+
},
|
|
609
|
+
body: requestBody,
|
|
610
|
+
signal: requestSignal
|
|
611
|
+
});
|
|
612
|
+
if (!res$1.ok) {
|
|
613
|
+
await res$1.body?.cancel();
|
|
614
|
+
return {
|
|
615
|
+
res: res$1,
|
|
616
|
+
text: ""
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
res: res$1,
|
|
621
|
+
text: await readResponseTextBounded(res$1, MAX_RESPONSE_BODY_BYTES)
|
|
622
|
+
};
|
|
623
|
+
}, fetchTimeoutMs, signal);
|
|
624
|
+
onResponseStatus?.(res.status);
|
|
625
|
+
if (!res.ok) return {
|
|
626
|
+
ok: false,
|
|
627
|
+
error: sanitizeTransportErrors ? "sandbox transport failed" : `sandbox request failed (HTTP ${res.status})`,
|
|
628
|
+
logs: []
|
|
629
|
+
};
|
|
630
|
+
let decoded;
|
|
631
|
+
try {
|
|
632
|
+
decoded = JSON.parse(text);
|
|
633
|
+
} catch {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: sanitizeTransportErrors ? "sandbox transport returned invalid response" : `sandbox returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
|
|
637
|
+
logs: []
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const parsed = parseSandboxRunResult(decoded);
|
|
641
|
+
if (!parsed) return {
|
|
642
|
+
ok: false,
|
|
643
|
+
error: sanitizeTransportErrors ? "sandbox transport returned invalid response" : "sandbox returned an invalid response",
|
|
644
|
+
logs: []
|
|
645
|
+
};
|
|
646
|
+
return {
|
|
647
|
+
ok: !!parsed.ok,
|
|
648
|
+
output: parsed.output,
|
|
649
|
+
error: parsed.error,
|
|
650
|
+
logs: Array.isArray(parsed.logs) ? parsed.logs : [],
|
|
651
|
+
timedOut: parsed.timedOut,
|
|
652
|
+
ms: parsed.ms
|
|
653
|
+
};
|
|
654
|
+
} catch (err) {
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
error: signal?.aborted || err instanceof OperationCancelledError ? "sandbox request cancelled" : err instanceof OperationDeadlineError ? sanitizeTransportErrors ? "sandbox transport timed out" : `sandbox request timed out after ${fetchTimeoutMs}ms` : err instanceof ResponseBodyTooLargeError ? sanitizeTransportErrors ? "sandbox transport returned invalid response" : "sandbox returned an invalid response" : sanitizeTransportErrors ? "sandbox transport failed" : `sandbox request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
658
|
+
logs: []
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async run(options) {
|
|
663
|
+
const caps = options.capabilities ?? {};
|
|
664
|
+
const net = caps.net ?? [];
|
|
665
|
+
const importHosts = caps.import ?? [];
|
|
666
|
+
const timeoutMs = caps.timeoutMs ?? DEFAULT_GUEST_TIMEOUT_MS;
|
|
667
|
+
if (this.validateEgress) {
|
|
668
|
+
const egress = await validateEgressHosts([...net, ...importHosts]);
|
|
669
|
+
if (!egress.ok) return {
|
|
670
|
+
ok: false,
|
|
671
|
+
error: `egress blocked: ${egress.reason}`,
|
|
672
|
+
logs: []
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
const sandboxCaps = {
|
|
676
|
+
net,
|
|
677
|
+
import: importHosts,
|
|
678
|
+
timeoutMs,
|
|
679
|
+
memoryMb: caps.memoryMb ?? 128
|
|
680
|
+
};
|
|
681
|
+
const bindings = options.sandboxBindings ?? (options.sandboxTools && options.brokerUrl ? {
|
|
682
|
+
url: options.brokerUrl,
|
|
683
|
+
token: opaqueBindingToken()
|
|
684
|
+
} : void 0);
|
|
685
|
+
if (options.sandboxTools && !bindings) return {
|
|
686
|
+
ok: false,
|
|
687
|
+
error: "sandbox custom tools require broker bindings",
|
|
688
|
+
logs: []
|
|
689
|
+
};
|
|
690
|
+
const toolsSession = options.sandboxTools && bindings ? registerSandboxCustomToolsSession(bindings.token, bindings.url, options.sandboxTools.envelope, timeoutMs + 3e4) : void 0;
|
|
691
|
+
const hostAdmissionSecret = this.options.hostAdmissionSecret ?? (typeof process !== "undefined" ? process.env?.SANDBOX_HOST_ADMISSION_SECRET : void 0);
|
|
692
|
+
try {
|
|
693
|
+
return await this.postRun(JSON.stringify({
|
|
694
|
+
mode: "host",
|
|
695
|
+
source: options.source,
|
|
696
|
+
input: options.input ?? null,
|
|
697
|
+
capabilities: sandboxCaps,
|
|
698
|
+
secrets: options.secrets ?? {},
|
|
699
|
+
...bindings ? { bindings } : {}
|
|
700
|
+
}), timeoutMs, hostAdmissionSecret ? { [HOST_ADMISSION_HEADER]: hostAdmissionSecret } : void 0);
|
|
701
|
+
} finally {
|
|
702
|
+
toolsSession?.revoke();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async runWorkload(options) {
|
|
706
|
+
if (options.signal?.aborted) return {
|
|
707
|
+
ok: false,
|
|
708
|
+
error: "sandbox request cancelled",
|
|
709
|
+
logs: []
|
|
710
|
+
};
|
|
711
|
+
const configured = this.options.workload;
|
|
712
|
+
if (!configured) throw new SandboxWorkloadDeniedError();
|
|
713
|
+
const authorizationTimeoutMs = boundedMs(configured.authorizationTimeoutMs, DEFAULT_AUTHORIZATION_TIMEOUT_MS, MAX_AUTHORIZATION_TIMEOUT_MS);
|
|
714
|
+
const auditTimeoutMs = boundedMs(configured.auditTimeoutMs, DEFAULT_AUDIT_TIMEOUT_MS, MAX_AUDIT_TIMEOUT_MS);
|
|
715
|
+
const audit = async (event, required) => {
|
|
716
|
+
if (!configured.audit) return;
|
|
717
|
+
try {
|
|
718
|
+
await settleWithin((signal) => configured.audit(Object.freeze(event), { signal }), auditTimeoutMs, options.signal);
|
|
719
|
+
} catch {
|
|
720
|
+
if (required) throw new SandboxWorkloadDeniedError();
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
const authorize = async (phase) => {
|
|
724
|
+
if (options.signal?.aborted) {
|
|
725
|
+
await audit({
|
|
726
|
+
boundary: "sandbox.workload",
|
|
727
|
+
phase,
|
|
728
|
+
decision: "denied",
|
|
729
|
+
reason: "cancelled"
|
|
730
|
+
}, false);
|
|
731
|
+
throw new SandboxWorkloadDeniedError();
|
|
732
|
+
}
|
|
733
|
+
let resolved;
|
|
734
|
+
try {
|
|
735
|
+
resolved = await settleWithin((signal) => configured.authorize(options.envelope, {
|
|
736
|
+
phase,
|
|
737
|
+
signal
|
|
738
|
+
}), authorizationTimeoutMs, options.signal);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
await audit({
|
|
741
|
+
boundary: "sandbox.workload",
|
|
742
|
+
phase,
|
|
743
|
+
decision: "denied",
|
|
744
|
+
reason: options.signal?.aborted || error instanceof OperationCancelledError ? "cancelled" : error instanceof OperationDeadlineError ? "authorization_timed_out" : "authorization_failed"
|
|
745
|
+
}, false);
|
|
746
|
+
throw new SandboxWorkloadDeniedError();
|
|
747
|
+
}
|
|
748
|
+
const policy = snapshotSandboxWorkloadPolicy(resolved);
|
|
749
|
+
if (!policy) {
|
|
750
|
+
await audit({
|
|
751
|
+
boundary: "sandbox.workload",
|
|
752
|
+
phase,
|
|
753
|
+
decision: "denied",
|
|
754
|
+
reason: "policy_invalid"
|
|
755
|
+
}, false);
|
|
756
|
+
throw new SandboxWorkloadDeniedError();
|
|
757
|
+
}
|
|
758
|
+
return policy;
|
|
759
|
+
};
|
|
760
|
+
const prepared = await authorize("prepare");
|
|
761
|
+
if (this.validateEgress) {
|
|
762
|
+
let egress;
|
|
763
|
+
try {
|
|
764
|
+
egress = await settleWithin((signal) => validateEgressHosts([...prepared.capabilities.net, ...prepared.capabilities.import], {
|
|
765
|
+
signal,
|
|
766
|
+
timeoutMs: authorizationTimeoutMs,
|
|
767
|
+
concurrency: 4
|
|
768
|
+
}), authorizationTimeoutMs, options.signal);
|
|
769
|
+
} catch {
|
|
770
|
+
await audit({
|
|
771
|
+
boundary: "sandbox.workload",
|
|
772
|
+
phase: "prepare",
|
|
773
|
+
decision: "denied",
|
|
774
|
+
reason: options.signal?.aborted ? "cancelled" : "egress_denied"
|
|
775
|
+
}, false);
|
|
776
|
+
throw new SandboxWorkloadDeniedError();
|
|
777
|
+
}
|
|
778
|
+
if (!egress.ok) {
|
|
779
|
+
await audit({
|
|
780
|
+
boundary: "sandbox.workload",
|
|
781
|
+
phase: "prepare",
|
|
782
|
+
decision: "denied",
|
|
783
|
+
reason: "egress_denied"
|
|
784
|
+
}, false);
|
|
785
|
+
throw new SandboxWorkloadDeniedError();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const current = await authorize("dispatch");
|
|
789
|
+
if (serializeSandboxWorkloadPolicy(prepared) !== serializeSandboxWorkloadPolicy(current) || prepared.sandboxTools?.envelope !== current.sandboxTools?.envelope) {
|
|
790
|
+
await audit({
|
|
791
|
+
boundary: "sandbox.workload",
|
|
792
|
+
phase: "dispatch",
|
|
793
|
+
decision: "denied",
|
|
794
|
+
reason: "policy_changed"
|
|
795
|
+
}, false);
|
|
796
|
+
throw new SandboxWorkloadDeniedError();
|
|
797
|
+
}
|
|
798
|
+
await audit({
|
|
799
|
+
boundary: "sandbox.workload",
|
|
800
|
+
phase: "dispatch",
|
|
801
|
+
decision: "allowed",
|
|
802
|
+
reason: "authorization_succeeded"
|
|
803
|
+
}, true);
|
|
804
|
+
let result;
|
|
805
|
+
let responseStatus;
|
|
806
|
+
try {
|
|
807
|
+
if (options.signal?.aborted) result = {
|
|
808
|
+
ok: false,
|
|
809
|
+
error: "sandbox request cancelled",
|
|
810
|
+
logs: []
|
|
811
|
+
};
|
|
812
|
+
else {
|
|
813
|
+
const requestBody = JSON.stringify({
|
|
814
|
+
mode: "workload",
|
|
815
|
+
source: current.source,
|
|
816
|
+
input: current.input ?? null,
|
|
817
|
+
capabilities: current.capabilities,
|
|
818
|
+
secrets: current.secrets ?? {},
|
|
819
|
+
...current.bindings ? { bindings: current.bindings } : {}
|
|
820
|
+
});
|
|
821
|
+
if (new TextEncoder().encode(requestBody).byteLength > MAX_REQUEST_BODY_BYTES) throw new SandboxWorkloadDeniedError();
|
|
822
|
+
const now = Date.now();
|
|
823
|
+
const expiresAt = new Date(Math.min(current.validUntil ? Date.parse(current.validUntil) : Number.POSITIVE_INFINITY, now + MAX_WORKLOAD_ADMISSION_TTL_MS)).toISOString();
|
|
824
|
+
const admission = await sealSandboxWorkloadAdmission(configured.admission, {
|
|
825
|
+
kind: "sandbox_workload_admission",
|
|
826
|
+
version: 1,
|
|
827
|
+
admissionId: crypto.randomUUID(),
|
|
828
|
+
supervisorInstanceId: configured.admission.instanceId,
|
|
829
|
+
expiresAt
|
|
830
|
+
}, requestBody);
|
|
831
|
+
const toolsSession = current.sandboxTools && current.bindings ? registerSandboxCustomToolsSession(current.bindings.token, current.bindings.url, current.sandboxTools.envelope, current.capabilities.timeoutMs + 3e4) : void 0;
|
|
832
|
+
try {
|
|
833
|
+
result = await this.postRun(requestBody, current.capabilities.timeoutMs, { [WORKLOAD_ADMISSION_HEADER]: admission }, options.signal, true, (status) => {
|
|
834
|
+
responseStatus = status;
|
|
835
|
+
});
|
|
836
|
+
} finally {
|
|
837
|
+
toolsSession?.revoke();
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
} catch {
|
|
841
|
+
await audit({
|
|
842
|
+
boundary: "sandbox.workload",
|
|
843
|
+
phase: "transport",
|
|
844
|
+
decision: "denied",
|
|
845
|
+
reason: "transport_failed"
|
|
846
|
+
}, false);
|
|
847
|
+
throw new SandboxWorkloadDeniedError();
|
|
848
|
+
}
|
|
849
|
+
const terminal = responseStatus !== void 0 && (responseStatus < 200 || responseStatus >= 300) ? "transport_denied" : result.error === "sandbox request cancelled" ? "transport_cancelled" : result.error === "sandbox transport timed out" ? "transport_timed_out" : result.error === "sandbox transport returned invalid response" ? "transport_invalid_response" : result.error === "sandbox transport failed" ? "transport_failed" : "transport_completed";
|
|
850
|
+
await audit({
|
|
851
|
+
boundary: "sandbox.workload",
|
|
852
|
+
phase: "transport",
|
|
853
|
+
decision: terminal === "transport_completed" ? "allowed" : "denied",
|
|
854
|
+
reason: terminal
|
|
855
|
+
}, false);
|
|
856
|
+
return result;
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
/** Factory for the production sandboxed adapter. */
|
|
860
|
+
function httpSandboxAdapter(options = {}) {
|
|
861
|
+
return new HttpSandboxAdapter(options);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
//#endregion
|
|
865
|
+
export { BINDINGS_TOKEN_HEADER as a, WORKLOAD_ADMISSION_HEADER as c, validateEgressHosts as d, validateHostEgress as f, SandboxWorkloadDeniedError as i, classifyIpLiteral as l, httpSandboxAdapter as n, FRAME_MARKER as o, SANDBOX_WORKLOAD_DENIAL_MESSAGE as r, HOST_ADMISSION_HEADER as s, HttpSandboxAdapter as t, parseHostEntry as u };
|