@claxedo/sandbox-manager 0.5.2 → 0.7.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.
@@ -33,16 +33,8 @@ import { Image, DaytonaNotFoundError } from "@daytona/sdk";
33
33
 
34
34
  // src/runtime-version.ts
35
35
  var DEFAULT_WORKSPACE_RUNTIME_VERSION = "0.5.2";
36
- var versionCache;
37
36
  function workspaceRuntimeVersion() {
38
- if (versionCache) return versionCache;
39
- const envVersion = process.env.WORKSPACE_RUNTIME_VERSION;
40
- if (envVersion) {
41
- versionCache = envVersion;
42
- return versionCache;
43
- }
44
- versionCache = DEFAULT_WORKSPACE_RUNTIME_VERSION;
45
- return versionCache;
37
+ return process.env.WORKSPACE_RUNTIME_VERSION || DEFAULT_WORKSPACE_RUNTIME_VERSION;
46
38
  }
47
39
 
48
40
  // src/image-name.ts
@@ -71,6 +63,207 @@ var SANDBOX_IMAGE_REPOSITORY = sandboxImageRepository();
71
63
  var SANDBOX_IMAGE = process.env.CLAXEDO_SANDBOX_IMAGE || defaultSandboxImage();
72
64
  var SNAPSHOT_NAME = process.env.CLAXEDO_SNAPSHOT_NAME || defaultSnapshotName();
73
65
 
66
+ // src/hosted-network-policy.ts
67
+ var SANDBOX_MODEL_PROVIDER_HOSTS = [
68
+ "api.anthropic.com",
69
+ "api.openai.com",
70
+ "generativelanguage.googleapis.com",
71
+ "openrouter.ai",
72
+ // Model catalog + engine config, fetched on runner startup.
73
+ "models.dev",
74
+ "opencode.ai"
75
+ ];
76
+ var SANDBOX_PACKAGE_REGISTRY_HOSTS = [
77
+ "registry.npmjs.org",
78
+ "nodejs.org",
79
+ "pypi.org",
80
+ "files.pythonhosted.org",
81
+ "crates.io",
82
+ "static.crates.io",
83
+ "index.crates.io",
84
+ "proxy.golang.org",
85
+ "sum.golang.org"
86
+ ];
87
+ var SANDBOX_BASELINE_EGRESS_HOSTS = [
88
+ ...SANDBOX_MODEL_PROVIDER_HOSTS,
89
+ ...SANDBOX_PACKAGE_REGISTRY_HOSTS
90
+ ];
91
+
92
+ // src/driver-catalog.ts
93
+ var sandboxDriverCatalog = {
94
+ exe: {
95
+ id: "exe",
96
+ label: "exe.dev",
97
+ credentialFields: [{ key: "api_token", label: "API Token", secret: true }],
98
+ metadata: {
99
+ driverRunsIn: ["worker", "node"],
100
+ hostStopBehavior: "not-supported",
101
+ hostResumeBehavior: "same-host",
102
+ targetAccess: "relay",
103
+ secretBrokering: "none",
104
+ egressControl: "none",
105
+ persistence: {
106
+ resume: "same-sandbox",
107
+ capture: "none",
108
+ clone: true,
109
+ captureSource: "not-applicable",
110
+ retention: "provider-managed",
111
+ restoreMount: "same-resource"
112
+ }
113
+ }
114
+ },
115
+ daytona: {
116
+ id: "daytona",
117
+ label: "Daytona",
118
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
119
+ metadata: {
120
+ driverRunsIn: ["worker", "node"],
121
+ hostStopBehavior: "suspends-host",
122
+ hostResumeBehavior: "same-host",
123
+ targetAccess: "relay",
124
+ secretBrokering: "native",
125
+ // Daytona is the only driver that can filter egress by NAME as well as
126
+ // by address: `domainAllowList` (names) alongside `networkAllowList`
127
+ // (CIDRs), with `networkBlockAll` as the deny-all floor.
128
+ egressControl: "hosts-and-cidrs",
129
+ persistence: {
130
+ resume: "same-sandbox",
131
+ capture: "filesystem",
132
+ clone: false,
133
+ captureSource: "preserved",
134
+ retention: "provider-managed",
135
+ restoreMount: "new-resource"
136
+ }
137
+ }
138
+ },
139
+ modal: {
140
+ id: "modal",
141
+ label: "Modal",
142
+ credentialFields: [
143
+ { key: "token_id", label: "Token ID", secret: true },
144
+ { key: "token_secret", label: "Token Secret", secret: true }
145
+ ],
146
+ metadata: {
147
+ driverRunsIn: ["node"],
148
+ hostStopBehavior: "terminates-host",
149
+ hostResumeBehavior: "replacement-host",
150
+ targetAccess: "relay",
151
+ secretBrokering: "none",
152
+ // Modal can cut the network entirely (`blockNetwork`) but cannot express
153
+ // an allowlist, and its driver throws on a host policy. A blackout is
154
+ // not containment for a workspace that has to clone and reach a model.
155
+ egressControl: "none",
156
+ persistence: {
157
+ resume: "replacement-restore",
158
+ capture: "filesystem",
159
+ clone: false,
160
+ captureSource: "preserved",
161
+ retention: "provider-managed",
162
+ restoreMount: "new-resource"
163
+ }
164
+ }
165
+ },
166
+ vercel: {
167
+ id: "vercel",
168
+ label: "Vercel",
169
+ credentialFields: [
170
+ { key: "access_token", label: "Access Token", secret: true },
171
+ { key: "team_id", label: "Team ID" },
172
+ { key: "project_id", label: "Project ID" }
173
+ ],
174
+ metadata: {
175
+ driverRunsIn: ["node"],
176
+ hostStopBehavior: "terminates-host",
177
+ hostResumeBehavior: "replacement-host",
178
+ targetAccess: "relay",
179
+ secretBrokering: "native",
180
+ // Vercel's sandbox firewall takes a hostname allow list (or "deny-all"),
181
+ // never CIDRs.
182
+ egressControl: "hosts",
183
+ persistence: {
184
+ resume: "replacement-restore",
185
+ capture: "filesystem",
186
+ clone: false,
187
+ captureSource: "stopped",
188
+ retention: "explicit",
189
+ restoreMount: "new-resource"
190
+ }
191
+ }
192
+ },
193
+ cloudflare: {
194
+ id: "cloudflare",
195
+ label: "Cloudflare",
196
+ credentialFields: [
197
+ { key: "api_token", label: "API Token", secret: true },
198
+ { key: "worker_url", label: "Worker URL" }
199
+ ],
200
+ metadata: {
201
+ driverRunsIn: ["worker"],
202
+ hostStopBehavior: "not-supported",
203
+ hostResumeBehavior: "same-host",
204
+ targetAccess: "relay",
205
+ secretBrokering: "proxy",
206
+ // The Cloudflare Sandbox Worker exposes no egress filter: its broker
207
+ // (drivers/cloudflare-egress.ts) is an OPT-IN proxy the sandbox chooses
208
+ // to route brokered-credential requests through, not a boundary. The
209
+ // driver therefore drops `net` entirely — which is exactly why this
210
+ // must be declared, so the manager refuses instead of provisioning a
211
+ // sandbox the caller believes is contained.
212
+ egressControl: "none",
213
+ persistence: {
214
+ resume: "replacement-restore",
215
+ capture: "directories",
216
+ clone: false,
217
+ captureSource: "preserved",
218
+ retention: "provider-managed",
219
+ restoreMount: "copy-on-write"
220
+ }
221
+ }
222
+ },
223
+ box: {
224
+ id: "box",
225
+ label: "Box",
226
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
227
+ metadata: {
228
+ driverRunsIn: ["node"],
229
+ hostStopBehavior: "suspends-host",
230
+ hostResumeBehavior: "same-host",
231
+ targetAccess: "relay",
232
+ secretBrokering: "none",
233
+ egressControl: "none",
234
+ persistence: {
235
+ resume: "same-sandbox",
236
+ capture: "none",
237
+ clone: false,
238
+ captureSource: "not-applicable",
239
+ retention: "provider-managed",
240
+ restoreMount: "same-resource"
241
+ }
242
+ }
243
+ },
244
+ docker: {
245
+ id: "docker",
246
+ label: "Docker",
247
+ credentialFields: [{ key: "image", label: "Image" }],
248
+ metadata: {
249
+ driverRunsIn: ["local"],
250
+ hostStopBehavior: "terminates-host",
251
+ hostResumeBehavior: "same-host",
252
+ targetAccess: "loopback",
253
+ secretBrokering: "none",
254
+ egressControl: "none",
255
+ persistence: {
256
+ resume: "replacement-restore",
257
+ capture: "same-resource",
258
+ clone: false,
259
+ captureSource: "preserved",
260
+ retention: "provider-managed",
261
+ restoreMount: "same-resource"
262
+ }
263
+ }
264
+ }
265
+ };
266
+
74
267
  // src/drivers/modal.ts
75
268
  var DEFAULT_APP_NAME = "claxedo";
76
269
  var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
@@ -192,7 +385,9 @@ function createModalSandboxDriver(options) {
192
385
  hostStopBehavior: "terminates-host",
193
386
  hostResumeBehavior: "replacement-host",
194
387
  targetAccess: "relay",
195
- secretBrokering: "none"
388
+ secretBrokering: "none",
389
+ egressControl: "none",
390
+ persistence: sandboxDriverCatalog.modal.metadata.persistence
196
391
  },
197
392
  ensureHost,
198
393
  resumeHost: (input) => ensureHost(input.ensure),
@@ -101,10 +101,29 @@ export type VercelSandboxDriverOptions = {
101
101
  * a header on outbound requests to the allowlisted hosts, so the raw value
102
102
  * never enters the sandbox (see @claxedo/sandbox-manager SandboxBrokeredSecret
103
103
  * and https://vercel.com/docs/sandbox/concepts/firewall — credentials
104
- * brokering). The resulting policy is deny-all-except-brokered-hosts, which is
105
- * the secure default: a sandbox holding a brokered credential should only be
106
- * able to reach the host that credential is for.
104
+ * brokering).
105
+ *
106
+ * The result is the UNION of `base` — the create-time allow-list — and the
107
+ * brokered hosts, never a replacement. Replacing it is wrong in both
108
+ * directions. It BREAKS egress the caller was granted: `updateNetworkPolicy`
109
+ * overwrites the whole policy, so a sandbox created with git and the npm
110
+ * registry allowed loses both the instant a brokered secret is attached, and
111
+ * the agent's next `npm install` fails mid-run. And it can silently WIDEN
112
+ * egress: a brokered host that was not in the create-time list becomes
113
+ * reachable, so a policy nobody approved is installed as a side effect of
114
+ * attaching a credential. A union changes exactly one thing — it adds the
115
+ * brokered hosts, which is the smallest edit that makes the credential usable.
116
+ *
117
+ * `base` is the create-time `NetworkPolicy` exactly as passed to `create`, and
118
+ * it is REQUIRED even though `undefined` is a legal value: `undefined` means
119
+ * "no policy was requested", so the merge keeps egress unrestricted, expressed
120
+ * as the `"*"` wildcard the record form needs for the header transforms to ride
121
+ * alongside it. That is the opposite of what this function used to return with
122
+ * no base at all (deny-all-except-brokered-hosts), so an optional parameter
123
+ * would let an existing caller keep compiling while its egress silently WIDENS.
124
+ * Spelling the base out forces that decision to be made once, visibly. A
125
+ * `"deny-all"` base contributes nothing, leaving deny-all-except-brokered-hosts.
107
126
  */
108
- export declare function vercelBrokeredNetworkPolicy(secrets: SandboxBrokeredSecret[]): NetworkPolicy;
127
+ export declare function vercelBrokeredNetworkPolicy(secrets: SandboxBrokeredSecret[], base: NetworkPolicy | undefined): NetworkPolicy;
109
128
  export declare function createVercelSandboxDriver(options: VercelSandboxDriverOptions): SandboxDriver;
110
129
  export {};
@@ -22,16 +22,8 @@ function workspaceRuntimeSourceEnv(input) {
22
22
 
23
23
  // src/runtime-version.ts
24
24
  var DEFAULT_WORKSPACE_RUNTIME_VERSION = "0.5.2";
25
- var versionCache;
26
25
  function workspaceRuntimeVersion() {
27
- if (versionCache) return versionCache;
28
- const envVersion = process.env.WORKSPACE_RUNTIME_VERSION;
29
- if (envVersion) {
30
- versionCache = envVersion;
31
- return versionCache;
32
- }
33
- versionCache = DEFAULT_WORKSPACE_RUNTIME_VERSION;
34
- return versionCache;
26
+ return process.env.WORKSPACE_RUNTIME_VERSION || DEFAULT_WORKSPACE_RUNTIME_VERSION;
35
27
  }
36
28
 
37
29
  // src/command.ts
@@ -45,6 +37,207 @@ var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
45
37
  // src/defaults.ts
46
38
  var RUNTIME_DIR = "/opt/workspace-runtime";
47
39
 
40
+ // src/hosted-network-policy.ts
41
+ var SANDBOX_MODEL_PROVIDER_HOSTS = [
42
+ "api.anthropic.com",
43
+ "api.openai.com",
44
+ "generativelanguage.googleapis.com",
45
+ "openrouter.ai",
46
+ // Model catalog + engine config, fetched on runner startup.
47
+ "models.dev",
48
+ "opencode.ai"
49
+ ];
50
+ var SANDBOX_PACKAGE_REGISTRY_HOSTS = [
51
+ "registry.npmjs.org",
52
+ "nodejs.org",
53
+ "pypi.org",
54
+ "files.pythonhosted.org",
55
+ "crates.io",
56
+ "static.crates.io",
57
+ "index.crates.io",
58
+ "proxy.golang.org",
59
+ "sum.golang.org"
60
+ ];
61
+ var SANDBOX_BASELINE_EGRESS_HOSTS = [
62
+ ...SANDBOX_MODEL_PROVIDER_HOSTS,
63
+ ...SANDBOX_PACKAGE_REGISTRY_HOSTS
64
+ ];
65
+
66
+ // src/driver-catalog.ts
67
+ var sandboxDriverCatalog = {
68
+ exe: {
69
+ id: "exe",
70
+ label: "exe.dev",
71
+ credentialFields: [{ key: "api_token", label: "API Token", secret: true }],
72
+ metadata: {
73
+ driverRunsIn: ["worker", "node"],
74
+ hostStopBehavior: "not-supported",
75
+ hostResumeBehavior: "same-host",
76
+ targetAccess: "relay",
77
+ secretBrokering: "none",
78
+ egressControl: "none",
79
+ persistence: {
80
+ resume: "same-sandbox",
81
+ capture: "none",
82
+ clone: true,
83
+ captureSource: "not-applicable",
84
+ retention: "provider-managed",
85
+ restoreMount: "same-resource"
86
+ }
87
+ }
88
+ },
89
+ daytona: {
90
+ id: "daytona",
91
+ label: "Daytona",
92
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
93
+ metadata: {
94
+ driverRunsIn: ["worker", "node"],
95
+ hostStopBehavior: "suspends-host",
96
+ hostResumeBehavior: "same-host",
97
+ targetAccess: "relay",
98
+ secretBrokering: "native",
99
+ // Daytona is the only driver that can filter egress by NAME as well as
100
+ // by address: `domainAllowList` (names) alongside `networkAllowList`
101
+ // (CIDRs), with `networkBlockAll` as the deny-all floor.
102
+ egressControl: "hosts-and-cidrs",
103
+ persistence: {
104
+ resume: "same-sandbox",
105
+ capture: "filesystem",
106
+ clone: false,
107
+ captureSource: "preserved",
108
+ retention: "provider-managed",
109
+ restoreMount: "new-resource"
110
+ }
111
+ }
112
+ },
113
+ modal: {
114
+ id: "modal",
115
+ label: "Modal",
116
+ credentialFields: [
117
+ { key: "token_id", label: "Token ID", secret: true },
118
+ { key: "token_secret", label: "Token Secret", secret: true }
119
+ ],
120
+ metadata: {
121
+ driverRunsIn: ["node"],
122
+ hostStopBehavior: "terminates-host",
123
+ hostResumeBehavior: "replacement-host",
124
+ targetAccess: "relay",
125
+ secretBrokering: "none",
126
+ // Modal can cut the network entirely (`blockNetwork`) but cannot express
127
+ // an allowlist, and its driver throws on a host policy. A blackout is
128
+ // not containment for a workspace that has to clone and reach a model.
129
+ egressControl: "none",
130
+ persistence: {
131
+ resume: "replacement-restore",
132
+ capture: "filesystem",
133
+ clone: false,
134
+ captureSource: "preserved",
135
+ retention: "provider-managed",
136
+ restoreMount: "new-resource"
137
+ }
138
+ }
139
+ },
140
+ vercel: {
141
+ id: "vercel",
142
+ label: "Vercel",
143
+ credentialFields: [
144
+ { key: "access_token", label: "Access Token", secret: true },
145
+ { key: "team_id", label: "Team ID" },
146
+ { key: "project_id", label: "Project ID" }
147
+ ],
148
+ metadata: {
149
+ driverRunsIn: ["node"],
150
+ hostStopBehavior: "terminates-host",
151
+ hostResumeBehavior: "replacement-host",
152
+ targetAccess: "relay",
153
+ secretBrokering: "native",
154
+ // Vercel's sandbox firewall takes a hostname allow list (or "deny-all"),
155
+ // never CIDRs.
156
+ egressControl: "hosts",
157
+ persistence: {
158
+ resume: "replacement-restore",
159
+ capture: "filesystem",
160
+ clone: false,
161
+ captureSource: "stopped",
162
+ retention: "explicit",
163
+ restoreMount: "new-resource"
164
+ }
165
+ }
166
+ },
167
+ cloudflare: {
168
+ id: "cloudflare",
169
+ label: "Cloudflare",
170
+ credentialFields: [
171
+ { key: "api_token", label: "API Token", secret: true },
172
+ { key: "worker_url", label: "Worker URL" }
173
+ ],
174
+ metadata: {
175
+ driverRunsIn: ["worker"],
176
+ hostStopBehavior: "not-supported",
177
+ hostResumeBehavior: "same-host",
178
+ targetAccess: "relay",
179
+ secretBrokering: "proxy",
180
+ // The Cloudflare Sandbox Worker exposes no egress filter: its broker
181
+ // (drivers/cloudflare-egress.ts) is an OPT-IN proxy the sandbox chooses
182
+ // to route brokered-credential requests through, not a boundary. The
183
+ // driver therefore drops `net` entirely — which is exactly why this
184
+ // must be declared, so the manager refuses instead of provisioning a
185
+ // sandbox the caller believes is contained.
186
+ egressControl: "none",
187
+ persistence: {
188
+ resume: "replacement-restore",
189
+ capture: "directories",
190
+ clone: false,
191
+ captureSource: "preserved",
192
+ retention: "provider-managed",
193
+ restoreMount: "copy-on-write"
194
+ }
195
+ }
196
+ },
197
+ box: {
198
+ id: "box",
199
+ label: "Box",
200
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
201
+ metadata: {
202
+ driverRunsIn: ["node"],
203
+ hostStopBehavior: "suspends-host",
204
+ hostResumeBehavior: "same-host",
205
+ targetAccess: "relay",
206
+ secretBrokering: "none",
207
+ egressControl: "none",
208
+ persistence: {
209
+ resume: "same-sandbox",
210
+ capture: "none",
211
+ clone: false,
212
+ captureSource: "not-applicable",
213
+ retention: "provider-managed",
214
+ restoreMount: "same-resource"
215
+ }
216
+ }
217
+ },
218
+ docker: {
219
+ id: "docker",
220
+ label: "Docker",
221
+ credentialFields: [{ key: "image", label: "Image" }],
222
+ metadata: {
223
+ driverRunsIn: ["local"],
224
+ hostStopBehavior: "terminates-host",
225
+ hostResumeBehavior: "same-host",
226
+ targetAccess: "loopback",
227
+ secretBrokering: "none",
228
+ egressControl: "none",
229
+ persistence: {
230
+ resume: "replacement-restore",
231
+ capture: "same-resource",
232
+ clone: false,
233
+ captureSource: "preserved",
234
+ retention: "provider-managed",
235
+ restoreMount: "same-resource"
236
+ }
237
+ }
238
+ }
239
+ };
240
+
48
241
  // src/drivers/vercel.ts
49
242
  var DEFAULT_RUNTIME = "node22";
50
243
  var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
@@ -53,8 +246,9 @@ var DEFAULT_TIMEOUT_MS = 30 * 60 * 1e3;
53
246
  var DEFAULT_SNAPSHOT_BUILD_TIMEOUT_MS = 10 * 60 * 1e3;
54
247
  var DEFAULT_OPERATION_TIMEOUT_MS = 6e4;
55
248
  var DEFAULT_KEEP_ALIVE_MS = 5 * 60 * 1e3;
56
- var EXPECTED_RUNTIME_VERSION = workspaceRuntimeVersion();
57
- var RUNTIME_PKG = `@claxedo/workspace-runtime@${EXPECTED_RUNTIME_VERSION}`;
249
+ function runtimePackage(version) {
250
+ return `@claxedo/workspace-runtime@${version}`;
251
+ }
58
252
  var SNAPSHOT_CACHE_TTL_MS = 60 * 60 * 1e3;
59
253
  var snapshotCache = /* @__PURE__ */ new Map();
60
254
  var snapshotCacheState = /* @__PURE__ */ new WeakMap();
@@ -74,8 +268,9 @@ function network(input) {
74
268
  if (hosts.length === 0) return "deny-all";
75
269
  return { allow: hosts };
76
270
  }
77
- function vercelBrokeredNetworkPolicy(secrets) {
271
+ function vercelBrokeredNetworkPolicy(secrets, base) {
78
272
  const allow = {};
273
+ for (const [host, rules] of baseAllowEntries(base)) allow[host] = [...rules];
79
274
  for (const secret of secrets) {
80
275
  if (!secret.header) {
81
276
  throw new Error(
@@ -87,7 +282,14 @@ function vercelBrokeredNetworkPolicy(secrets) {
87
282
  (allow[host] ??= []).push({ transform: [{ headers: { [secret.header]: secret.value } }] });
88
283
  }
89
284
  }
90
- return { allow };
285
+ const subnets = typeof base === "object" && base?.subnets ? { subnets: base.subnets } : {};
286
+ return { allow, ...subnets };
287
+ }
288
+ function baseAllowEntries(base) {
289
+ if (base === void 0 || base === "allow-all") return [["*", []]];
290
+ if (base === "deny-all") return [];
291
+ if (Array.isArray(base.allow)) return base.allow.map((host) => [host, []]);
292
+ return Object.entries(base.allow ?? {});
91
293
  }
92
294
  function createVercelSandboxDriver(options) {
93
295
  const sandboxFactory = options.sandbox ?? Sandbox;
@@ -141,7 +343,8 @@ function createVercelSandboxDriver(options) {
141
343
  }
142
344
  async function ensureSnapshot() {
143
345
  if (options.baseSnapshotId) return options.baseSnapshotId;
144
- const cacheKey = `${options.teamId}:${options.projectId}:${runtime}:${EXPECTED_RUNTIME_VERSION}`;
346
+ const expectedRuntimeVersion = workspaceRuntimeVersion();
347
+ const cacheKey = `${options.teamId}:${options.projectId}:${runtime}:${expectedRuntimeVersion}`;
145
348
  const hit = cache.get(cacheKey);
146
349
  const expiresAt = cacheState.expires.get(cacheKey);
147
350
  if (hit && expiresAt === void 0) {
@@ -183,8 +386,8 @@ function createVercelSandboxDriver(options) {
183
386
  `sudo chown $(whoami) ${shell(RUNTIME_DIR)}`,
184
387
  `cd ${shell(RUNTIME_DIR)}`,
185
388
  "npm init -y",
186
- `npm install ${RUNTIME_PKG} --min-release-age=2`,
187
- `node -e "const fs=require('fs');const actual=JSON.parse(fs.readFileSync('${RUNTIME_DIR}/node_modules/@claxedo/workspace-runtime/package.json','utf8')).version;if(actual!=='${EXPECTED_RUNTIME_VERSION}'){throw new Error('workspace-runtime snapshot package version mismatch: expected ${EXPECTED_RUNTIME_VERSION}, got '+actual)}"`,
389
+ `npm install ${runtimePackage(expectedRuntimeVersion)} --min-release-age=2`,
390
+ `node -e "const fs=require('fs');const actual=JSON.parse(fs.readFileSync('${RUNTIME_DIR}/node_modules/@claxedo/workspace-runtime/package.json','utf8')).version;if(actual!=='${expectedRuntimeVersion}'){throw new Error('workspace-runtime snapshot package version mismatch: expected ${expectedRuntimeVersion}, got '+actual)}"`,
188
391
  "test -x node_modules/.bin/workspace-runtime",
189
392
  "test -x node_modules/.bin/claude-agent-acp",
190
393
  "test -x node_modules/.bin/codex-acp",
@@ -249,20 +452,21 @@ function createVercelSandboxDriver(options) {
249
452
  ...bootEnv(input, hostId),
250
453
  ...await options.env?.(input, { id: hostId })
251
454
  };
455
+ const createPolicy = network(input);
252
456
  const sandbox = await sandboxFactory.create({
253
457
  ...credentials(),
254
458
  source: await source(input),
255
459
  ports: [runtimePort],
256
460
  timeout: timeoutMs,
257
461
  env,
258
- ...network(input) ? { networkPolicy: network(input) } : {}
462
+ ...createPolicy ? { networkPolicy: createPolicy } : {}
259
463
  }).catch((err) => {
260
464
  if (transientDriverError(err)) return void 0;
261
465
  throw err;
262
466
  });
263
467
  if (!sandbox) return { provisioning: true, retryAfterMs: 2e3 };
264
468
  if (input.secrets?.length) {
265
- await sandbox.updateNetworkPolicy(vercelBrokeredNetworkPolicy(input.secrets), {
469
+ await sandbox.updateNetworkPolicy(vercelBrokeredNetworkPolicy(input.secrets, createPolicy), {
266
470
  signal: AbortSignal.timeout(operationTimeoutMs)
267
471
  });
268
472
  }
@@ -282,7 +486,9 @@ function createVercelSandboxDriver(options) {
282
486
  hostStopBehavior: "terminates-host",
283
487
  hostResumeBehavior: "replacement-host",
284
488
  targetAccess: "relay",
285
- secretBrokering: "native"
489
+ secretBrokering: "native",
490
+ egressControl: "hosts",
491
+ persistence: sandboxDriverCatalog.vercel.metadata.persistence
286
492
  },
287
493
  ensureHost,
288
494
  resumeHost: (input) => ensureHost(input.ensure),
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The egress allowlist a HOSTED, multi-tenant sandbox is provisioned with.
3
+ *
4
+ * A hosted sandbox runs agent-authored code on the operator's infrastructure,
5
+ * next to other tenants' sandboxes, holding a checkout of the user's private
6
+ * repository and (via `SandboxBrokeredSecret`) the ability to spend the
7
+ * operator's model credits. The default for that shape of workload is
8
+ * restrictive, not allow-all: the interesting attack is not "the agent
9
+ * downloads a package", it is "the agent POSTs the repository to a host the
10
+ * attacker controls". An allowlist is what makes that fail.
11
+ *
12
+ * Every entry below has to earn its place, so the list is assembled from three
13
+ * sources of truth rather than from taste:
14
+ *
15
+ * 1. DERIVED PER REQUEST — the control plane the sandbox reports back to and
16
+ * the git host it clones from. These differ per deployment and per
17
+ * workspace, so they are inputs, not constants.
18
+ * 2. MODEL PROVIDERS — the endpoints an agent harness must reach to do any
19
+ * work at all. Taken from the harness→provider mapping the credential
20
+ * layer already maintains (`PROVIDER_TO_GROUP` /`DEFAULT_ALLOWLIST` in
21
+ * claxedo-server's `network/types.ts`), narrowed to the API hostnames.
22
+ * 3. PACKAGE REGISTRIES — an agent that cannot install a dependency cannot
23
+ * edit a real repository. Same source, again narrowed.
24
+ *
25
+ * What is deliberately NOT here, despite being in that same default list:
26
+ * general-purpose object storage and CDN wildcards (`*.workers.dev`,
27
+ * `r2.cloudflarestorage.com`, `storage.googleapis.com`,
28
+ * `*.blob.core.windows.net`, `*.amazonaws.com`). Every one of those is a
29
+ * bucket an attacker can create in their own account, so allowlisting them
30
+ * hands back exactly the exfiltration channel the policy exists to close. A
31
+ * deployment that genuinely needs one adds it through `extraHosts` as an
32
+ * explicit, auditable decision.
33
+ *
34
+ * This module is data + string handling only, so it runs unchanged inside the
35
+ * hosted Worker (no DNS, no node builtins). That matters: name-based policy is
36
+ * the only kind the Worker can compute, which is why `egressControl` treats
37
+ * name filtering as the capability that qualifies a driver for hosted use.
38
+ */
39
+ import type { SandboxNetworkPolicy, SandboxSource } from ".";
40
+ /**
41
+ * Model-provider endpoints. Derived from the harness→provider groups in
42
+ * claxedo-server `network/types.ts` (`PROVIDER_TO_GROUP`): claude-acp and
43
+ * claude-sdk → anthropic, codex-acp/codex-app-server/cursor-acp → openai,
44
+ * google → google_ai; plus the aggregator and catalog endpoints the engine
45
+ * itself fetches at startup.
46
+ */
47
+ export declare const SANDBOX_MODEL_PROVIDER_HOSTS: readonly ["api.anthropic.com", "api.openai.com", "generativelanguage.googleapis.com", "openrouter.ai", "models.dev", "opencode.ai"];
48
+ /**
49
+ * Package registries. Narrowed from the npm / pypi / rust / golang groups of
50
+ * `DEFAULT_ALLOWLIST` to the hostnames a package install actually contacts —
51
+ * the wildcard CDN entries in those groups are excluded on purpose (see the
52
+ * module comment).
53
+ */
54
+ export declare const SANDBOX_PACKAGE_REGISTRY_HOSTS: readonly ["registry.npmjs.org", "nodejs.org", "pypi.org", "files.pythonhosted.org", "crates.io", "static.crates.io", "index.crates.io", "proxy.golang.org", "sum.golang.org"];
55
+ /** The deployment-independent floor: providers + registries. */
56
+ export declare const SANDBOX_BASELINE_EGRESS_HOSTS: readonly ["api.anthropic.com", "api.openai.com", "generativelanguage.googleapis.com", "openrouter.ai", "models.dev", "opencode.ai", "registry.npmjs.org", "nodejs.org", "pypi.org", "files.pythonhosted.org", "crates.io", "static.crates.io", "index.crates.io", "proxy.golang.org", "sum.golang.org"];
57
+ /**
58
+ * The git host this workspace clones from.
59
+ *
60
+ * Scoped to the ONE repository host in the request rather than to a forge
61
+ * allowlist, matching how `authenticatedGitHubCloneSource` scopes the clone
62
+ * credential it brokers (`hosts: ["github.com"]`) — the credential and the
63
+ * network policy should describe the same reachable surface.
64
+ */
65
+ export declare function sandboxSourceHost(source: SandboxSource | undefined): string | undefined;
66
+ export type HostedSandboxNetworkPolicyInput = {
67
+ /**
68
+ * Control-plane and relay endpoints the runtime reports back to: the relay
69
+ * it tunnels through and fetches `WORKSPACE_RUNTIME_RELAY_JWKS_URL` from,
70
+ * and the control-plane origin serving `WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL`
71
+ * plus register/heartbeat. URLs or bare hostnames; blanks are ignored.
72
+ */
73
+ controlPlane: Array<string | undefined>;
74
+ /** Workspace source — its git host is allowed, nothing else on that forge. */
75
+ source?: SandboxSource;
76
+ /** Operator escape hatch for deployment-specific hosts. */
77
+ extraHosts?: readonly string[];
78
+ };
79
+ /**
80
+ * Build the restricted policy for a hosted sandbox.
81
+ *
82
+ * Always `mode: "restricted"`. There is no input that makes this return
83
+ * allow-all — a hosted sandbox with an empty or unresolvable control-plane URL
84
+ * is a misconfiguration, and answering it with "then allow everything" is how
85
+ * the gap this closes came to exist in the first place.
86
+ */
87
+ export declare function hostedSandboxNetworkPolicy(input: HostedSandboxNetworkPolicyInput): SandboxNetworkPolicy;