@schemavaults/sandbox-management-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -2
  3. package/dist/commands.d.ts +16 -0
  4. package/dist/commands.js +99 -0
  5. package/dist/dbh/index.d.ts +4 -0
  6. package/dist/dbh/index.js +2 -0
  7. package/dist/dbh/migration.d.ts +18 -0
  8. package/dist/dbh/migration.js +41 -0
  9. package/dist/dbh/store.d.ts +42 -0
  10. package/dist/dbh/store.js +105 -0
  11. package/dist/docker/egress-proxy/config.d.ts +20 -0
  12. package/dist/docker/egress-proxy/config.js +29 -0
  13. package/dist/docker/egress-proxy/main.d.ts +1 -0
  14. package/dist/docker/egress-proxy/main.js +19 -0
  15. package/dist/docker/egress-proxy/policy.d.ts +49 -0
  16. package/dist/docker/egress-proxy/policy.js +89 -0
  17. package/dist/docker/egress-proxy/server.d.ts +25 -0
  18. package/dist/docker/egress-proxy/server.js +225 -0
  19. package/dist/docker/egress.d.ts +41 -0
  20. package/dist/docker/egress.js +67 -0
  21. package/dist/docker/index.d.ts +3 -0
  22. package/dist/docker/index.js +2 -0
  23. package/dist/docker/provider.d.ts +101 -0
  24. package/dist/docker/provider.js +373 -0
  25. package/dist/docker/tar.d.ts +20 -0
  26. package/dist/docker/tar.js +124 -0
  27. package/dist/errors.d.ts +40 -0
  28. package/dist/errors.js +67 -0
  29. package/dist/in-memory-store.d.ts +11 -0
  30. package/dist/in-memory-store.js +40 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.js +8 -0
  33. package/dist/internal/base64.d.ts +7 -0
  34. package/dist/internal/base64.js +20 -0
  35. package/dist/internal/output.d.ts +6 -0
  36. package/dist/internal/output.js +25 -0
  37. package/dist/internal/posix-path.d.ts +8 -0
  38. package/dist/internal/posix-path.js +38 -0
  39. package/dist/manager.d.ts +126 -0
  40. package/dist/manager.js +477 -0
  41. package/dist/network.d.ts +27 -0
  42. package/dist/network.js +128 -0
  43. package/dist/provider.d.ts +77 -0
  44. package/dist/provider.js +1 -0
  45. package/dist/shell-main-process.d.ts +27 -0
  46. package/dist/shell-main-process.js +94 -0
  47. package/dist/store.d.ts +36 -0
  48. package/dist/store.js +19 -0
  49. package/dist/testing/index.d.ts +3 -0
  50. package/dist/testing/index.js +2 -0
  51. package/dist/testing/mock-provider.d.ts +99 -0
  52. package/dist/testing/mock-provider.js +181 -0
  53. package/dist/types.d.ts +158 -0
  54. package/dist/types.js +21 -0
  55. package/dist/vercel/index.d.ts +2 -0
  56. package/dist/vercel/index.js +1 -0
  57. package/dist/vercel/provider.d.ts +125 -0
  58. package/dist/vercel/provider.js +190 -0
  59. package/dist/web-storage/index.d.ts +2 -0
  60. package/dist/web-storage/index.js +1 -0
  61. package/dist/web-storage/store.d.ts +53 -0
  62. package/dist/web-storage/store.js +118 -0
  63. package/package.json +117 -4
@@ -0,0 +1,20 @@
1
+ import type { SandboxNetworkAllowlist } from "../../types.js";
2
+ /** Environment variable the proxy image reads its `EgressProxyConfig` from, as JSON. */
3
+ export declare const EGRESS_PROXY_CONFIG_ENV = "SANDBOX_EGRESS_PROXY_CONFIG";
4
+ /** A TCP port the proxy forwards to the sandbox, so it can be published on the host. */
5
+ export interface EgressProxyForward {
6
+ port: number;
7
+ host: string;
8
+ targetPort: number;
9
+ }
10
+ export interface EgressProxyConfig {
11
+ /** Port of the HTTP proxy (plain requests and `CONNECT`). */
12
+ port: number;
13
+ policy: SandboxNetworkAllowlist;
14
+ /** CIDRs of the clients that may use the HTTP proxy. Loopback always may. */
15
+ clients: string[];
16
+ forwards: EgressProxyForward[];
17
+ }
18
+ /** Parses and checks `SANDBOX_EGRESS_PROXY_CONFIG`. Throws a descriptive `Error`. */
19
+ export declare function parseEgressProxyConfig(raw: string | undefined): EgressProxyConfig;
20
+ export declare function isPort(value: unknown): value is number;
@@ -0,0 +1,29 @@
1
+ import { validateNetworkPolicy } from "../../network.js";
2
+ import { ranges } from "./policy.js";
3
+ /** Environment variable the proxy image reads its `EgressProxyConfig` from, as JSON. */
4
+ export const EGRESS_PROXY_CONFIG_ENV = "SANDBOX_EGRESS_PROXY_CONFIG";
5
+ /** Parses and checks `SANDBOX_EGRESS_PROXY_CONFIG`. Throws a descriptive `Error`. */
6
+ export function parseEgressProxyConfig(raw) {
7
+ if (!raw)
8
+ throw new Error(`${EGRESS_PROXY_CONFIG_ENV} is not set`);
9
+ const config = JSON.parse(raw);
10
+ if (!isPort(config.port))
11
+ throw new Error("config.port must be a port number");
12
+ if (typeof config.policy !== "object" || config.policy === null)
13
+ throw new Error("config.policy must be an allowlist");
14
+ validateNetworkPolicy(config.policy);
15
+ if (!Array.isArray(config.clients))
16
+ throw new Error("config.clients must be an array of CIDRs");
17
+ ranges(config.clients);
18
+ if (!Array.isArray(config.forwards))
19
+ throw new Error("config.forwards must be an array");
20
+ for (const forward of config.forwards) {
21
+ if (!isPort(forward?.port) || !isPort(forward.targetPort) || typeof forward.host !== "string") {
22
+ throw new Error("config.forwards entries must be { port, host, targetPort }");
23
+ }
24
+ }
25
+ return config;
26
+ }
27
+ export function isPort(value) {
28
+ return Number.isInteger(value) && value >= 1 && value <= 65535;
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Entry point of the egress proxy image (`images/egress-proxy`). Reads its
3
+ * config from `SANDBOX_EGRESS_PROXY_CONFIG`. `--health` checks a running
4
+ * proxy instead, for the image's HEALTHCHECK.
5
+ */
6
+ import { EGRESS_PROXY_CONFIG_ENV, parseEgressProxyConfig } from "./config.js";
7
+ import { startEgressProxy } from "./server.js";
8
+ const config = parseEgressProxyConfig(process.env[EGRESS_PROXY_CONFIG_ENV]);
9
+ if (process.argv.includes("--health")) {
10
+ const response = await fetch(`http://127.0.0.1:${config.port}/healthz`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
11
+ process.exit(response?.ok ? 0 : 1);
12
+ }
13
+ const proxy = await startEgressProxy(config, {
14
+ log: (line) => console.log(`${new Date().toISOString()} ${line}`),
15
+ });
16
+ console.log(`egress proxy listening on :${proxy.port}` +
17
+ config.forwards.map((forward) => `, forwarding :${forward.port} to ${forward.host}:${forward.targetPort}`).join(""));
18
+ for (const signal of ["SIGTERM", "SIGINT"])
19
+ process.once(signal, () => process.exit(0));
@@ -0,0 +1,49 @@
1
+ import type { CidrRange } from "../../network.js";
2
+ import type { SandboxNetworkAllowlist } from "../../types.js";
3
+ /** Destinations the proxy never connects to: its own loopback and the unspecified address. */
4
+ export declare const PROXY_LOCAL_CIDRS: string[];
5
+ export type EgressDecision = {
6
+ allowed: true;
7
+ address: string;
8
+ } | {
9
+ allowed: false;
10
+ reason: string;
11
+ };
12
+ /**
13
+ * `spec.network` allowlist semantics, shared with Vercel:
14
+ *
15
+ * - A host name is allowed if it is on `allowDomains`, or if it resolves to an
16
+ * address in `allowCidrs`. A leading `*.` matches any subdomain (not the
17
+ * domain itself), and `"*"` matches every name.
18
+ * - An IP address is allowed if it is in `allowCidrs`.
19
+ * - Addresses in `denyCidrs` are never connected to, whatever allowed them.
20
+ */
21
+ export declare class EgressPolicy {
22
+ private readonly anyDomain;
23
+ private readonly domains;
24
+ private readonly suffixes;
25
+ private readonly allowRanges;
26
+ private readonly denyRanges;
27
+ constructor(allowlist: SandboxNetworkAllowlist, { alwaysDeny }?: {
28
+ alwaysDeny?: string[];
29
+ });
30
+ /** Whether `name` (a normalized host name, not an IP address) is on the domain allowlist. */
31
+ allowsName(name: string): boolean;
32
+ /**
33
+ * Whether `name` must be resolved to decide on it. Names that are neither
34
+ * allowlisted nor could match `allowCidrs` are refused without a DNS query,
35
+ * so a sandbox cannot use the proxy to leak data through DNS lookups.
36
+ */
37
+ needsResolution(name: string): boolean;
38
+ /**
39
+ * Picks the address to connect to for `host` (normalized with
40
+ * `normalizeHost`), given what it resolved to. An IP address host resolves
41
+ * to itself.
42
+ */
43
+ decide(host: string, addresses: string[]): EgressDecision;
44
+ }
45
+ /** Lowercases a host, and strips IPv6 brackets and a trailing dot. */
46
+ export declare function normalizeHost(host: string): string;
47
+ /** Whether `address` (as a socket reports it) is in one of `ranges`. */
48
+ export declare function addressInRanges(address: string | undefined, cidrs: readonly CidrRange[]): boolean;
49
+ export declare function ranges(cidrs: readonly string[]): CidrRange[];
@@ -0,0 +1,89 @@
1
+ import { cidrContains, parseCidr, parseIp } from "../../network.js";
2
+ /** Destinations the proxy never connects to: its own loopback and the unspecified address. */
3
+ export const PROXY_LOCAL_CIDRS = ["127.0.0.0/8", "0.0.0.0/8", "::1/128", "::/128"];
4
+ /**
5
+ * `spec.network` allowlist semantics, shared with Vercel:
6
+ *
7
+ * - A host name is allowed if it is on `allowDomains`, or if it resolves to an
8
+ * address in `allowCidrs`. A leading `*.` matches any subdomain (not the
9
+ * domain itself), and `"*"` matches every name.
10
+ * - An IP address is allowed if it is in `allowCidrs`.
11
+ * - Addresses in `denyCidrs` are never connected to, whatever allowed them.
12
+ */
13
+ export class EgressPolicy {
14
+ anyDomain;
15
+ domains;
16
+ suffixes;
17
+ allowRanges;
18
+ denyRanges;
19
+ constructor(allowlist, { alwaysDeny = PROXY_LOCAL_CIDRS } = {}) {
20
+ const domains = (allowlist.allowDomains ?? []).map((domain) => domain.toLowerCase());
21
+ this.anyDomain = domains.includes("*");
22
+ this.domains = new Set(domains.filter((domain) => !domain.startsWith("*")));
23
+ this.suffixes = domains.filter((domain) => domain.startsWith("*.")).map((domain) => domain.slice(1));
24
+ this.allowRanges = ranges(allowlist.allowCidrs ?? []);
25
+ this.denyRanges = ranges([...(allowlist.denyCidrs ?? []), ...alwaysDeny]);
26
+ }
27
+ /** Whether `name` (a normalized host name, not an IP address) is on the domain allowlist. */
28
+ allowsName(name) {
29
+ return this.anyDomain || this.domains.has(name) || this.suffixes.some((suffix) => name.endsWith(suffix));
30
+ }
31
+ /**
32
+ * Whether `name` must be resolved to decide on it. Names that are neither
33
+ * allowlisted nor could match `allowCidrs` are refused without a DNS query,
34
+ * so a sandbox cannot use the proxy to leak data through DNS lookups.
35
+ */
36
+ needsResolution(name) {
37
+ return this.allowsName(name) || this.allowRanges.length > 0;
38
+ }
39
+ /**
40
+ * Picks the address to connect to for `host` (normalized with
41
+ * `normalizeHost`), given what it resolved to. An IP address host resolves
42
+ * to itself.
43
+ */
44
+ decide(host, addresses) {
45
+ const nameAllowed = parseIp(host) === null && this.allowsName(host);
46
+ let blocked = false;
47
+ for (const address of addresses) {
48
+ const ip = parseIp(address);
49
+ if (ip === null)
50
+ continue;
51
+ const allowed = nameAllowed || this.allowRanges.some((range) => cidrContains(range, comparable(ip)));
52
+ if (!allowed)
53
+ continue;
54
+ if (this.denyRanges.some((range) => cidrContains(range, comparable(ip)))) {
55
+ blocked = true;
56
+ continue;
57
+ }
58
+ return { allowed: true, address };
59
+ }
60
+ return {
61
+ allowed: false,
62
+ reason: blocked
63
+ ? `${host} only resolves to blocked addresses`
64
+ : `${host} is not on this sandbox's network allowlist`,
65
+ };
66
+ }
67
+ }
68
+ /** Lowercases a host, and strips IPv6 brackets and a trailing dot. */
69
+ export function normalizeHost(host) {
70
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
71
+ return unbracketed.toLowerCase().replace(/\.$/, "");
72
+ }
73
+ /** Whether `address` (as a socket reports it) is in one of `ranges`. */
74
+ export function addressInRanges(address, cidrs) {
75
+ const ip = address === undefined ? null : parseIp(address);
76
+ return ip !== null && cidrs.some((range) => cidrContains(range, comparable(ip)));
77
+ }
78
+ export function ranges(cidrs) {
79
+ return cidrs.map((cidr) => {
80
+ const range = parseCidr(cidr);
81
+ if (range === null)
82
+ throw new Error(`Invalid CIDR "${cidr}"`);
83
+ return range;
84
+ });
85
+ }
86
+ /** IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) compare as the IPv4 address they carry. */
87
+ function comparable(ip) {
88
+ return ip.version === 6 && ip.value >> 32n === 0xffffn ? { version: 4, value: ip.value & 0xffffffffn } : ip;
89
+ }
@@ -0,0 +1,25 @@
1
+ import type { EgressProxyConfig } from "./config.js";
2
+ export interface EgressProxyOptions {
3
+ /** Listen address. Default `"0.0.0.0"`. */
4
+ host?: string;
5
+ /** Resolves a host name to addresses. Default: the system resolver. */
6
+ resolve?: (name: string) => Promise<string[]>;
7
+ /** Destinations always refused. Default `PROXY_LOCAL_CIDRS` (the proxy's own loopback). */
8
+ alwaysDeny?: string[];
9
+ log?: (line: string) => void;
10
+ }
11
+ export interface RunningEgressProxy {
12
+ /** Port the HTTP proxy listens on (differs from the config when it asked for port 0). */
13
+ port: number;
14
+ /** Listening port of each forward, in config order. */
15
+ forwardPorts: number[];
16
+ close(): Promise<void>;
17
+ }
18
+ /**
19
+ * Starts the egress proxy that enforces a sandbox's network allowlist. The
20
+ * sandbox reaches the internet only through it, as an HTTP proxy
21
+ * (`HTTP_PROXY` / `HTTPS_PROXY`): plain `http://` requests are forwarded and
22
+ * everything else is tunneled with `CONNECT`, so TLS stays end to end. It
23
+ * also forwards the sandbox's published ports.
24
+ */
25
+ export declare function startEgressProxy(config: EgressProxyConfig, options?: EgressProxyOptions): Promise<RunningEgressProxy>;
@@ -0,0 +1,225 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { STATUS_CODES, createServer as createHttpServer, request as httpRequest } from "node:http";
3
+ import { connect, createServer as createTcpServer } from "node:net";
4
+ import { parseIp } from "../../network.js";
5
+ import { isPort } from "./config.js";
6
+ import { EgressPolicy, PROXY_LOCAL_CIDRS, addressInRanges, normalizeHost, ranges } from "./policy.js";
7
+ const LOOPBACK_CLIENTS = ["127.0.0.0/8", "::1/128"];
8
+ const HOP_BY_HOP = new Set([
9
+ "connection",
10
+ "keep-alive",
11
+ "proxy-authenticate",
12
+ "proxy-authorization",
13
+ "proxy-connection",
14
+ "te",
15
+ "trailer",
16
+ "transfer-encoding",
17
+ "upgrade",
18
+ ]);
19
+ /**
20
+ * Starts the egress proxy that enforces a sandbox's network allowlist. The
21
+ * sandbox reaches the internet only through it, as an HTTP proxy
22
+ * (`HTTP_PROXY` / `HTTPS_PROXY`): plain `http://` requests are forwarded and
23
+ * everything else is tunneled with `CONNECT`, so TLS stays end to end. It
24
+ * also forwards the sandbox's published ports.
25
+ */
26
+ export async function startEgressProxy(config, options = {}) {
27
+ const host = options.host ?? "0.0.0.0";
28
+ const log = options.log ?? (() => undefined);
29
+ const resolve = options.resolve ?? (async (name) => (await lookup(name, { all: true })).map((entry) => entry.address));
30
+ const policy = new EgressPolicy(config.policy, { alwaysDeny: options.alwaysDeny ?? PROXY_LOCAL_CIDRS });
31
+ const clients = ranges([...config.clients, ...LOOPBACK_CLIENTS]);
32
+ const sockets = new Set();
33
+ const track = (socket) => {
34
+ sockets.add(socket);
35
+ socket.once("close", () => sockets.delete(socket));
36
+ };
37
+ /** Decides on a connection to `target:port`; `method` is for the log (`CONNECT`, `GET`, ...). */
38
+ async function route(method, target, port) {
39
+ const name = normalizeHost(target);
40
+ let addresses = [name];
41
+ if (parseIp(name) === null && policy.needsResolution(name)) {
42
+ try {
43
+ addresses = await resolve(name);
44
+ }
45
+ catch {
46
+ return { allowed: false, status: 502, reason: `could not resolve ${name}` };
47
+ }
48
+ }
49
+ const decision = policy.decide(name, addresses);
50
+ log(decision.allowed
51
+ ? `allow ${method} ${name}:${port} -> ${decision.address}`
52
+ : `deny ${method} ${name}:${port}: ${decision.reason}`);
53
+ return decision.allowed ? decision : { allowed: false, status: 403, reason: decision.reason };
54
+ }
55
+ const proxy = createHttpServer();
56
+ proxy.on("connection", (socket) => {
57
+ if (!addressInRanges(socket.remoteAddress, clients)) {
58
+ log(`refuse client ${socket.remoteAddress}`);
59
+ socket.destroy();
60
+ return;
61
+ }
62
+ track(socket);
63
+ });
64
+ proxy.on("request", (req, res) => {
65
+ void forwardRequest(req, res).catch((error) => {
66
+ if (!res.headersSent)
67
+ reply(res, 502, `Proxy error: ${message(error)}`);
68
+ else
69
+ res.destroy();
70
+ });
71
+ });
72
+ proxy.on("connect", (req, socket, head) => {
73
+ void tunnel(req, socket, head).catch(() => socket.destroy());
74
+ });
75
+ proxy.on("upgrade", (_req, socket) => {
76
+ refuse(socket, 501, "Upgrades over plain HTTP are not supported; use CONNECT");
77
+ });
78
+ proxy.on("clientError", (_error, socket) => {
79
+ if (socket.writable)
80
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
81
+ else
82
+ socket.destroy();
83
+ });
84
+ async function forwardRequest(req, res) {
85
+ const target = req.url ?? "";
86
+ if (target.startsWith("/")) {
87
+ if (target === "/healthz")
88
+ return reply(res, 200, "ok");
89
+ return reply(res, 400, "This is the sandbox egress proxy: send proxy requests (absolute URLs or CONNECT)");
90
+ }
91
+ let url;
92
+ try {
93
+ url = new URL(target);
94
+ }
95
+ catch {
96
+ return reply(res, 400, `Invalid request target ${target}`);
97
+ }
98
+ if (url.protocol !== "http:") {
99
+ return reply(res, 400, `Only http:// URLs can be forwarded; tunnel ${url.protocol} with CONNECT`);
100
+ }
101
+ const port = url.port ? Number(url.port) : 80;
102
+ const decision = await route(req.method ?? "GET", url.hostname, port);
103
+ if (!decision.allowed)
104
+ return reply(res, decision.status, `Blocked by the sandbox egress proxy: ${decision.reason}`);
105
+ const upstream = httpRequest({
106
+ host: decision.address,
107
+ port,
108
+ method: req.method,
109
+ path: `${url.pathname}${url.search}`,
110
+ headers: { ...withoutHopByHop(req.headers), host: url.host },
111
+ agent: false,
112
+ setHost: false,
113
+ });
114
+ upstream.on("response", (response) => {
115
+ const status = response.statusCode ?? 502;
116
+ res.writeHead(status, response.statusMessage || STATUS_CODES[status] || "", withoutHopByHop(response.headers));
117
+ response.pipe(res);
118
+ });
119
+ upstream.on("error", (error) => {
120
+ if (!res.headersSent)
121
+ reply(res, 502, `Could not reach ${url.host}: ${message(error)}`);
122
+ else
123
+ res.destroy();
124
+ });
125
+ res.on("close", () => upstream.destroy());
126
+ req.pipe(upstream);
127
+ }
128
+ async function tunnel(req, client, head) {
129
+ const target = parseAuthority(req.url ?? "");
130
+ if (target === null)
131
+ return refuse(client, 400, `Invalid CONNECT target ${req.url}`);
132
+ const decision = await route("CONNECT", target.host, target.port);
133
+ if (!decision.allowed)
134
+ return refuse(client, decision.status, `Blocked by the sandbox egress proxy: ${decision.reason}`);
135
+ const upstream = connect({ host: decision.address, port: target.port, allowHalfOpen: true });
136
+ track(upstream);
137
+ const onConnectError = (error) => refuse(client, 502, `Could not reach ${req.url}: ${error.message}`);
138
+ upstream.once("error", onConnectError);
139
+ upstream.once("connect", () => {
140
+ upstream.off("error", onConnectError);
141
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
142
+ if (head.length > 0)
143
+ upstream.write(head);
144
+ splice(client, upstream);
145
+ });
146
+ client.once("close", () => upstream.destroy());
147
+ }
148
+ const forwardServers = config.forwards.map((forward) => createTcpServer({ allowHalfOpen: true }, (client) => {
149
+ track(client);
150
+ const upstream = connect({ host: forward.host, port: forward.targetPort, allowHalfOpen: true });
151
+ track(upstream);
152
+ splice(client, upstream);
153
+ }));
154
+ const port = await listen(proxy, config.port, host);
155
+ const forwardPorts = [];
156
+ for (const [index, server] of forwardServers.entries()) {
157
+ forwardPorts.push(await listen(server, config.forwards[index].port, host));
158
+ }
159
+ return {
160
+ port,
161
+ forwardPorts,
162
+ async close() {
163
+ for (const socket of sockets)
164
+ socket.destroy();
165
+ await Promise.all([proxy, ...forwardServers].map((server) => new Promise((done) => server.close(done))));
166
+ },
167
+ };
168
+ }
169
+ /** Pipes two sockets into each other, passing half-closes through, until both sides are done. */
170
+ function splice(a, b) {
171
+ a.pipe(b);
172
+ b.pipe(a);
173
+ const destroy = () => {
174
+ a.destroy();
175
+ b.destroy();
176
+ };
177
+ a.once("error", destroy);
178
+ b.once("error", destroy);
179
+ a.once("close", () => b.destroy());
180
+ b.once("close", () => a.destroy());
181
+ }
182
+ function parseAuthority(authority) {
183
+ const match = /^(\[[^\]]+\]|[^:[\]]+):(\d{1,5})$/.exec(authority);
184
+ if (!match)
185
+ return null;
186
+ const port = Number(match[2]);
187
+ return isPort(port) ? { host: match[1], port } : null;
188
+ }
189
+ function withoutHopByHop(headers) {
190
+ const listed = String(headers.connection ?? "")
191
+ .split(",")
192
+ .map((name) => name.trim().toLowerCase());
193
+ const result = {};
194
+ for (const [name, value] of Object.entries(headers)) {
195
+ if (value !== undefined && !HOP_BY_HOP.has(name) && !listed.includes(name))
196
+ result[name] = value;
197
+ }
198
+ return result;
199
+ }
200
+ function reply(res, status, body) {
201
+ const text = `${body}\n`;
202
+ res.writeHead(status, {
203
+ "content-type": "text/plain; charset=utf-8",
204
+ "content-length": Buffer.byteLength(text),
205
+ connection: "close",
206
+ });
207
+ res.end(text);
208
+ }
209
+ function refuse(socket, status, body) {
210
+ const reason = { 400: "Bad Request", 403: "Forbidden", 501: "Not Implemented", 502: "Bad Gateway" }[status] ?? "Error";
211
+ const text = `${body}\n`;
212
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(text)}\r\nConnection: close\r\n\r\n${text}`);
213
+ }
214
+ function listen(server, port, host) {
215
+ return new Promise((resolve, reject) => {
216
+ server.once("error", reject);
217
+ server.listen(port, host, () => {
218
+ server.off("error", reject);
219
+ resolve(server.address().port);
220
+ });
221
+ });
222
+ }
223
+ function message(error) {
224
+ return error instanceof Error ? error.message : String(error);
225
+ }
@@ -0,0 +1,41 @@
1
+ import type { SandboxProvider } from "../provider.js";
2
+ import type { SandboxNetworkAllowlist, SandboxSpec } from "../types.js";
3
+ import type { EgressProxyConfig } from "./egress-proxy/config.js";
4
+ /**
5
+ * The egress proxy image published with this SDK version (built from
6
+ * `images/egress-proxy`). Keep the tag in step with `package.json`.
7
+ */
8
+ export declare const DOCKER_EGRESS_PROXY_IMAGE = "ghcr.io/schemavaults/sandbox-egress-proxy:0.1.0";
9
+ /** Label on a sandbox's egress proxy container and internal network; the value is the sandbox ID. */
10
+ export declare const DOCKER_EGRESS_PROXY_LABEL = "dev.schemavaults.sandbox.egress-proxy";
11
+ /** Host names of the proxy and the sandbox on the sandbox's internal network. */
12
+ export declare const EGRESS_PROXY_ALIAS = "egress-proxy";
13
+ export declare const SANDBOX_ALIAS = "sandbox";
14
+ /**
15
+ * How a sandbox container is networked:
16
+ *
17
+ * - `direct`: attached to `networkMode` (`undefined` is Docker's default bridge,
18
+ * `"none"` is no network at all).
19
+ * - `proxy`: attached only to an internal network of its own, with no route
20
+ * out. Its egress proxy shares that network and `outsideNetwork`; it
21
+ * enforces `policy` and forwards the sandbox's ports.
22
+ */
23
+ export type DockerNetworkPlan = {
24
+ mode: "direct";
25
+ networkMode: string | undefined;
26
+ } | {
27
+ mode: "proxy";
28
+ policy: SandboxNetworkAllowlist;
29
+ outsideNetwork: string;
30
+ proxyPort: number;
31
+ };
32
+ /** Whether the sandbox runs behind an egress proxy: an allowlist, or `"deny-all"` with published ports. */
33
+ export declare function usesEgressProxy(spec: SandboxSpec): boolean;
34
+ /** Works out the networking for `spec`, or throws a `SandboxError` if Docker cannot honor it. */
35
+ export declare function dockerNetworkPlan(provider: Pick<SandboxProvider, "name" | "networkPolicies">, spec: SandboxSpec, network: string | undefined): DockerNetworkPlan;
36
+ /** The proxy's config: the policy, who may use it (the internal network), and the ports to forward. */
37
+ export declare function egressProxyConfig(plan: DockerNetworkPlan & {
38
+ mode: "proxy";
39
+ }, spec: SandboxSpec, clients: string[]): EgressProxyConfig;
40
+ /** Points HTTP clients in the sandbox at its egress proxy. `spec.env` can override any of these. */
41
+ export declare function egressProxyEnv(proxyPort: number): Record<string, string>;
@@ -0,0 +1,67 @@
1
+ import { SandboxError } from "../errors.js";
2
+ import { assertNetworkPolicySupported, networkPolicyKind } from "../network.js";
3
+ /**
4
+ * The egress proxy image published with this SDK version (built from
5
+ * `images/egress-proxy`). Keep the tag in step with `package.json`.
6
+ */
7
+ export const DOCKER_EGRESS_PROXY_IMAGE = "ghcr.io/schemavaults/sandbox-egress-proxy:0.1.0";
8
+ /** Label on a sandbox's egress proxy container and internal network; the value is the sandbox ID. */
9
+ export const DOCKER_EGRESS_PROXY_LABEL = "dev.schemavaults.sandbox.egress-proxy";
10
+ /** Host names of the proxy and the sandbox on the sandbox's internal network. */
11
+ export const EGRESS_PROXY_ALIAS = "egress-proxy";
12
+ export const SANDBOX_ALIAS = "sandbox";
13
+ const DEFAULT_PROXY_PORT = 3128;
14
+ const UNUSABLE_OUTSIDE_NETWORKS = new Set(["none", "host"]);
15
+ /** Whether the sandbox runs behind an egress proxy: an allowlist, or `"deny-all"` with published ports. */
16
+ export function usesEgressProxy(spec) {
17
+ const kind = networkPolicyKind(spec.network);
18
+ return kind === "allowlist" || (kind === "deny-all" && (spec.ports?.length ?? 0) > 0);
19
+ }
20
+ /** Works out the networking for `spec`, or throws a `SandboxError` if Docker cannot honor it. */
21
+ export function dockerNetworkPlan(provider, spec, network) {
22
+ // Also checked by the manager; repeated for callers that use the provider directly.
23
+ assertNetworkPolicySupported(provider, spec.network);
24
+ const kind = networkPolicyKind(spec.network);
25
+ if (kind === "allow-all")
26
+ return { mode: "direct", networkMode: network };
27
+ if (!usesEgressProxy(spec)) {
28
+ if (network !== undefined && network !== "none") {
29
+ throw new SandboxError(`spec.network "deny-all" conflicts with spec.providerOptions.docker.network "${network}"`);
30
+ }
31
+ return { mode: "direct", networkMode: "none" };
32
+ }
33
+ const outsideNetwork = network ?? "bridge";
34
+ if (UNUSABLE_OUTSIDE_NETWORKS.has(outsideNetwork) || outsideNetwork.startsWith("container:")) {
35
+ throw new SandboxError(`spec.providerOptions.docker.network "${outsideNetwork}" cannot carry the egress proxy's traffic; use a bridge network or leave it unset`);
36
+ }
37
+ const policy = kind === "allowlist" ? spec.network : {};
38
+ const ports = new Set(spec.ports ?? []);
39
+ let proxyPort = DEFAULT_PROXY_PORT;
40
+ while (ports.has(proxyPort))
41
+ proxyPort++;
42
+ return { mode: "proxy", policy, outsideNetwork, proxyPort };
43
+ }
44
+ /** The proxy's config: the policy, who may use it (the internal network), and the ports to forward. */
45
+ export function egressProxyConfig(plan, spec, clients) {
46
+ return {
47
+ port: plan.proxyPort,
48
+ policy: plan.policy,
49
+ clients,
50
+ forwards: (spec.ports ?? []).map((port) => ({ port, host: SANDBOX_ALIAS, targetPort: port })),
51
+ };
52
+ }
53
+ /** Points HTTP clients in the sandbox at its egress proxy. `spec.env` can override any of these. */
54
+ export function egressProxyEnv(proxyPort) {
55
+ const url = `http://${EGRESS_PROXY_ALIAS}:${proxyPort}`;
56
+ const local = "localhost,127.0.0.1,::1";
57
+ return {
58
+ HTTP_PROXY: url,
59
+ HTTPS_PROXY: url,
60
+ http_proxy: url,
61
+ https_proxy: url,
62
+ NO_PROXY: local,
63
+ no_proxy: local,
64
+ // Node.js (24+) only honors the variables above with this set.
65
+ NODE_USE_ENV_PROXY: "1",
66
+ };
67
+ }
@@ -0,0 +1,3 @@
1
+ export { DOCKER_SANDBOX_ID_LABEL, DockerSandboxProvider } from "./provider.js";
2
+ export type { DockerPullPolicy, DockerSandboxOptions, DockerSandboxProviderOptions } from "./provider.js";
3
+ export { DOCKER_EGRESS_PROXY_IMAGE, DOCKER_EGRESS_PROXY_LABEL } from "./egress.js";
@@ -0,0 +1,2 @@
1
+ export { DOCKER_SANDBOX_ID_LABEL, DockerSandboxProvider } from "./provider.js";
2
+ export { DOCKER_EGRESS_PROXY_IMAGE, DOCKER_EGRESS_PROXY_LABEL } from "./egress.js";