@namzu/sandbox 18.1.1 → 19.0.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 (43) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +35 -5
  3. package/dist/backends/docker/index.d.ts +46 -1
  4. package/dist/backends/docker/index.d.ts.map +1 -1
  5. package/dist/backends/docker/index.js +187 -51
  6. package/dist/backends/docker/index.js.map +1 -1
  7. package/dist/egress/index.d.ts +1 -0
  8. package/dist/egress/index.d.ts.map +1 -1
  9. package/dist/egress/index.js +1 -0
  10. package/dist/egress/index.js.map +1 -1
  11. package/dist/egress/profile-wiring.d.ts +40 -0
  12. package/dist/egress/profile-wiring.d.ts.map +1 -0
  13. package/dist/egress/profile-wiring.js +75 -0
  14. package/dist/egress/profile-wiring.js.map +1 -0
  15. package/dist/egress/profile.d.ts +153 -0
  16. package/dist/egress/profile.d.ts.map +1 -0
  17. package/dist/egress/profile.js +244 -0
  18. package/dist/egress/profile.js.map +1 -0
  19. package/dist/egress/proxy.d.ts +16 -0
  20. package/dist/egress/proxy.d.ts.map +1 -1
  21. package/dist/egress/proxy.js +37 -3
  22. package/dist/egress/proxy.js.map +1 -1
  23. package/dist/index.d.ts +19 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +12 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/seed/index.d.ts +165 -0
  28. package/dist/seed/index.d.ts.map +1 -0
  29. package/dist/seed/index.js +505 -0
  30. package/dist/seed/index.js.map +1 -0
  31. package/dist/testing/sandbox-conformance.d.ts +30 -1
  32. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  33. package/dist/testing/sandbox-conformance.js +18 -0
  34. package/dist/testing/sandbox-conformance.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/backends/docker/index.ts +246 -51
  37. package/src/egress/index.ts +1 -0
  38. package/src/egress/profile-wiring.ts +125 -0
  39. package/src/egress/profile.ts +380 -0
  40. package/src/egress/proxy.ts +53 -3
  41. package/src/index.ts +54 -5
  42. package/src/seed/index.ts +710 -0
  43. package/src/testing/sandbox-conformance.ts +52 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # @namzu/sandbox
2
2
 
3
+ ## 19.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 3e7a97b: No API change. The peer dependency on `@namzu/sdk` moves to the 44 major,
8
+ because the SDK itself had a major release (its run became a turn inside a
9
+ session). `@namzu/sandbox` reads no renamed field, but its peer range is
10
+ published as a caret on the SDK version it was built with, so the SDK major
11
+ takes it out of range and forces this bump.
12
+
13
+ What to do: upgrade `@namzu/sdk` and `@namzu/sandbox` together. Nothing in your
14
+ code changes.
15
+
16
+ ### Minor Changes
17
+
18
+ - 0a89444: New: egress profiles. `defineEgressProfile({ name, hosts: [{ host, ports? }] })`
19
+ validates one named host allowlist, and `createSandboxProvider` takes it as
20
+ `egressProfile` instead of `defaultEgress`. On docker, runsc and firecracker a
21
+ profile becomes `deny-all` (no hosts) or a `static` allowlist. Whatever a
22
+ backend cannot honour is refused at construction with
23
+ `SandboxEgressProfileError`: ports on firecracker, any profile on the ACI
24
+ standby pool or beside `defaultEgress`, and a
25
+ `brokeredCredentials` host the profile does not allow. On docker and runsc a
26
+ live `setNetworkPolicy` under a profile may only name hosts the profile covers.
27
+ On kubernetes, `createSandboxProvider` refuses `egressProfile`; put
28
+ `kubernetesEgressFromProfile(profile, { engine: 'cilium' })` in
29
+ `backend.egress` instead, which also bounds workspaces and writes the profile
30
+ label only with `profileLabel: true`.
31
+
32
+ Nothing changes for a config without `egressProfile`.
33
+
34
+ - aee81a6: The docker and runsc egress proxy now enforces the `ports` of an egress
35
+ profile, on the port it actually dials: an upgraded `http://host/` and a
36
+ `CONNECT` with no port are checked on 443. A host may use the union of the
37
+ ports of every rule that matches it. The same check is available on
38
+ `EgressProxy` as `allowedPorts`, and `egressPortsForRules(profile.hosts)`
39
+ builds that option from a profile's rules with the same union rule.
40
+
41
+ **Rebuild `egressProxyImage` from `packages/sandbox/egress-proxy/Dockerfile`
42
+ before using `ports`.** A profile with ports sends the proxy a new
43
+ configuration variable, `NAMZU_EGRESS_PROXY_CONFIG_V2`, and the backend first
44
+ reads the image's `ai.namzu.egress-proxy.config` label with
45
+ `docker image inspect`, refusing an image that does not declare version 2 (or
46
+ that is not on the daemon: `docker image inspect` does not pull). Profiles
47
+ without ports, and every policy without a profile, need nothing: the proxy
48
+ gets the same configuration and argv as before.
49
+
50
+ - c366ca7: New: sandbox seeds. `ensureSandboxSeed(sandbox, seed, { root })` makes the git
51
+ repositories of a `defineSandboxSeed({ name, repositories })` present under
52
+ `root` inside any sandbox, through `exec` only. Every call checks each
53
+ repository (origin URL, and that its pinned or recorded commit is an ancestor
54
+ of HEAD; a `ref` changed since the clone, or a pin dropped, is drift unless
55
+ the ref's commit is exactly HEAD) and clones only what is missing, so running it after each create or
56
+ workspace resume costs one check when nothing changed. Drift is refused before
57
+ anything is cloned (`onDrift: 'report'` records it instead), and nothing is
58
+ ever deleted or re-cloned. `root` is required; on docker, use
59
+ `layout.scratch`, not the outputs root. A repository directory may not sit
60
+ inside another's, nor under `.namzu/`. URLs with a user name or password,
61
+ `ssh://` and `git@host:path` are refused, so no credential enters the guest.
62
+ The guest needs `sh`, `git`, `find`, `mkdir`, `mktemp`, `rm` and GNU `mv`.
63
+
64
+ Nothing changes for code that does not call it.
65
+
66
+ ### Patch Changes
67
+
68
+ - 251d815: On the docker backend, two overlapping `setNetworkPolicy()` calls on one
69
+ sandbox could leave no egress proxy running while a caller was told its policy
70
+ was in force, or resolve one call while the other call's policy was the one
71
+ applied. Calls on one sandbox now run one at a time, in the order they were
72
+ made, and each resolves only once its own policy is running. A call that asks
73
+ for the allowlist already in force no longer restarts the proxy. No action is
74
+ needed.
75
+ - Updated dependencies [8805360]
76
+ - Updated dependencies [9355755]
77
+ - Updated dependencies [755a81a]
78
+ - Updated dependencies [cb1f00c]
79
+ - Updated dependencies [933ba6d]
80
+ - Updated dependencies [3e7a97b]
81
+ - Updated dependencies [a84dc1c]
82
+ - Updated dependencies [b064cea]
83
+ - Updated dependencies [9238347]
84
+ - Updated dependencies [a14b013]
85
+ - Updated dependencies [3641102]
86
+ - @namzu/sdk@44.0.0
87
+
3
88
  ## 18.1.1
4
89
 
5
90
  ### Patch Changes
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
  type: Reference
3
3
  title: "@namzu/sandbox"
4
4
  description: >-
5
- Container and process isolation for Namzu runs. Two isolation tiers over
6
- four backends, a bounded filesystem view, and an egress boundary the run
5
+ Container and process isolation for Namzu agents. Two isolation tiers over
6
+ four backends, a bounded filesystem view, and an egress boundary the agent
7
7
  cannot talk its way past.
8
8
  tags: [readme, package, sandbox, isolation]
9
9
  status: stable
@@ -14,7 +14,7 @@ generated: { by: human:bahadirarda, at: 2026-08-30T00:00:00Z }
14
14
 
15
15
  <h1>@namzu/sandbox</h1>
16
16
 
17
- **Container and process isolation for Namzu runs.**
17
+ **Container and process isolation for Namzu agents.**
18
18
 
19
19
  [![npm](https://img.shields.io/npm/v/@namzu/sandbox.svg)](https://www.npmjs.com/package/@namzu/sandbox)
20
20
  [![build](https://github.com/cogitave/namzu/actions/workflows/ci.yml/badge.svg)](https://github.com/cogitave/namzu/actions/workflows/ci.yml)
@@ -28,7 +28,7 @@ generated: { by: human:bahadirarda, at: 2026-08-30T00:00:00Z }
28
28
 
29
29
  Runs a tool call somewhere that is not your process. Two isolation tiers
30
30
  over four backends, a bounded filesystem view, and an egress boundary the
31
- run cannot talk its way past.
31
+ agent cannot talk its way past.
32
32
 
33
33
  ## Install
34
34
 
@@ -98,7 +98,7 @@ off nothing else: `--cap-drop=ALL`, `--security-opt=no-new-privileges` and
98
98
  Because those four paths are tmpfs, they are RAM, not the container's writable
99
99
  layer: scratch larger than half the host's RAM (or than `--memory`, the tighter
100
100
  of the two when the host sets one) fails with `ENOSPC` rather than spilling onto
101
- the host's disk. A run that writes temp files bigger than its memory budget does
101
+ the host's disk. A workload that writes temp files bigger than its memory budget does
102
102
  not have to give up the baseline over it: `layout.scratch` is a bind to a host
103
103
  directory and stays disk-backed, so a host with room on disk mounts one there
104
104
  and points the workload at it — `TMPDIR` set to that container path through the
@@ -761,6 +761,36 @@ Both methods are capability-checked. A backend that cannot preserve the same
761
761
  isolation and ownership boundary omits them; callers must not fall back to a
762
762
  host process or a different sandbox.
763
763
 
764
+ ## Egress profiles
765
+
766
+ `defineEgressProfile({ name, hosts: [{ host, ports? }] })` is one named,
767
+ validated allowlist a host can give any backend. `createSandboxProvider({
768
+ egressProfile })` turns it into `deny-all` (no hosts) or a `static` allowlist
769
+ on docker, runsc and firecracker, and refuses at construction, before any I/O,
770
+ what a backend cannot honour: ports on firecracker, any profile on the ACI
771
+ standby pool, a profile beside `defaultEgress`, and a brokered credential for a
772
+ host outside the profile. On kubernetes, put
773
+ `kubernetesEgressFromProfile(profile, { engine: 'cilium' })` in
774
+ `backend.egress`, which also covers workspaces. On docker and runsc the egress
775
+ proxy enforces a rule's `ports` on the port it dials; rebuild the proxy image
776
+ from `egress-proxy/Dockerfile` first, since the backend refuses an image
777
+ without its `ai.namzu.egress-proxy.config="2"` label. A profile carries no
778
+ credentials and has no wildcard. See
779
+ [docs/sdk/sandbox-egress-profiles.md](../../docs/sdk/sandbox-egress-profiles.md).
780
+
781
+ ## Sandbox seeds
782
+
783
+ `ensureSandboxSeed(sandbox, defineSandboxSeed({ name, repositories }), { root })`
784
+ makes git repositories present under `root`, doing only what is missing: every
785
+ call checks each repository (origin, and that its pinned or recorded commit is
786
+ an ancestor of HEAD; a changed `ref` is drift unless the checkout already holds
787
+ it), refuses drift without touching anything, clones what is
788
+ missing into a partial directory and moves it into place. `root` is required:
789
+ put it on a kubernetes workspace's disk mount, or `layout.scratch` on docker,
790
+ never the docker outputs root. URLs with credentials, `ssh://` and `git@` are
791
+ refused, so nothing secret enters the guest. See
792
+ [docs/sdk/sandbox-seeds.md](../../docs/sdk/sandbox-seeds.md).
793
+
764
794
  ## Firecracker network policy
765
795
 
766
796
  At microVM creation, the Firecracker backend maps the resolved egress decision
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { type ContainerSandboxLayout, type ResolvedContainerSandboxLayout } from '@namzu/sdk';
44
44
  import type { BrokeredCredential } from '../../egress/index.js';
45
+ import { type SandboxEgressProfile } from '../../egress/profile.js';
45
46
  import { type EgressPolicy, type SandboxBackend, type SandboxBackendOptions } from '../../index.js';
46
47
  /**
47
48
  * Backend-specific tuning. Most hosts use the defaults; advanced
@@ -248,6 +249,15 @@ export interface DockerBackendInternalConfig {
248
249
  * credentials with a route to the internet.
249
250
  */
250
251
  readonly labels?: Readonly<Record<string, string>>;
252
+ /**
253
+ * The egress profile the provider was built with, already validated by
254
+ * `defineEgressProfile`. The create-time policy is derived from it by the
255
+ * provider; the backend reads it for what a policy cannot carry. Under a
256
+ * profile, a brokered credential for a host outside it is refused at
257
+ * construction, and a live `setNetworkPolicy` may only name hosts the
258
+ * profile covers.
259
+ */
260
+ readonly egressProfile?: SandboxEgressProfile;
251
261
  }
252
262
  /**
253
263
  * Build a {@link SandboxBackend} backed by Docker. Construction is
@@ -378,6 +388,17 @@ export interface EgressProxyContainerConfig {
378
388
  * network alias, which is the name the sandbox actually dials.
379
389
  */
380
390
  readonly selfNames?: readonly string[];
391
+ /**
392
+ * The egress profile's rules, every one of them, when the profile carries
393
+ * ports. Present only then, and only in the V2 configuration
394
+ * ({@link EGRESS_PROXY_CONFIG_V2_ENV}): the proxy computes a host's
395
+ * allowed ports as the union over every rule that matches it, so a rule
396
+ * without ports is listed too.
397
+ */
398
+ readonly hostPorts?: readonly {
399
+ readonly host: string;
400
+ readonly ports?: readonly number[];
401
+ }[];
381
402
  }
382
403
  /**
383
404
  * Build the proxy container's configuration.
@@ -391,7 +412,31 @@ export interface EgressProxyContainerConfig {
391
412
  * is one the sandbox can reach too, which would let the sandbox ask for its
392
413
  * own allowlist to be widened. See `docs/sdk/sandbox-egress.md`.
393
414
  */
394
- export declare function egressProxyContainerConfig(config: Pick<DockerBackendInternalConfig, 'brokeredCredentials' | 'allowInwardFor'>, allowedHosts: readonly string[], port: number): EgressProxyContainerConfig;
415
+ export declare function egressProxyContainerConfig(config: Pick<DockerBackendInternalConfig, 'brokeredCredentials' | 'allowInwardFor' | 'egressProfile'>, allowedHosts: readonly string[], port: number): EgressProxyContainerConfig;
416
+ /**
417
+ * Whether the proxy has port rules to enforce: a profile with at least one
418
+ * rule that carries `ports`. Only then is the configuration sent as V2, and
419
+ * only then is the image's label checked, so every other policy starts the
420
+ * proxy with exactly the V1 configuration and argv it always had.
421
+ */
422
+ export declare function usesPortRules(config: Pick<DockerBackendInternalConfig, 'egressProfile'>): boolean;
423
+ /** The environment variable the proxy's configuration travels in. See {@link usesPortRules}. */
424
+ export declare function egressProxyConfigEnvName(config: Pick<DockerBackendInternalConfig, 'egressProfile'>): string;
425
+ /**
426
+ * The V2 configuration, which adds port rules. Sent INSTEAD of the V1 one,
427
+ * never beside it: an image that predates port rules reads only V1, finds it
428
+ * unset and exits, so it fails closed rather than enforcing hosts without
429
+ * ports. `egress-proxy/server.mjs` is the reader.
430
+ */
431
+ export declare const EGRESS_PROXY_CONFIG_V2_ENV = "NAMZU_EGRESS_PROXY_CONFIG_V2";
432
+ /**
433
+ * The image label saying which configuration version the proxy image reads.
434
+ * `egress-proxy/Dockerfile` sets it to `2`. Checked with `docker image
435
+ * inspect` before a proxy with port rules is started, because the other
436
+ * signal, the container exiting, can arrive after the readiness check has
437
+ * already passed.
438
+ */
439
+ export declare const EGRESS_PROXY_IMAGE_CONFIG_LABEL = "ai.namzu.egress-proxy.config";
395
440
  /** Everything {@link renderEgressProxyRunArgs} renders, as a value. */
396
441
  export interface EgressProxyArgvInput {
397
442
  readonly config: DockerBackendInternalConfig;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/docker/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/docker/index.ts"],"names":[],"mappings":"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"}
@@ -43,6 +43,8 @@
43
43
  import { spawn } from 'node:child_process';
44
44
  import { randomBytes } from 'node:crypto';
45
45
  import { SANDBOX_DEFAULT_OUTPUTS_PATH, SANDBOX_DEFAULT_SCRATCH_PATH, SANDBOX_DEFAULT_SKILLS_PARENT, SANDBOX_DEFAULT_TOOL_RESULTS_PATH, SANDBOX_DEFAULT_TRANSCRIPTS_PATH, SANDBOX_DEFAULT_UPLOADS_PATH, generateSandboxId, walkFilesViaExec, withHint, } from '@namzu/sdk';
46
+ import { assertBrokeredCredentialsFitProfile } from '../../egress/profile-wiring.js';
47
+ import { SandboxEgressProfileError, egressProfileCoversEntry, egressProfileHasPorts, } from '../../egress/profile.js';
46
48
  import { ContainerSandboxLayoutValidationError, } from '../../index.js';
47
49
  import { HttpWorkerClient, WORKER_UNAUTHORIZED_HINT, workerAuthorization, } from '../http-worker-client.js';
48
50
  import { OperationDeadline, OperationDeadlineExpired, probeHttpHealth, resolveReadinessOptions, runFailureCleanup, } from '../readiness.js';
@@ -64,6 +66,9 @@ export function buildDockerBackend(config) {
64
66
  // argv is built, because that is the only place a caller cannot skip them.
65
67
  assertCpuLimitIsRenderable(config.cpuLimit);
66
68
  assertRootfsOptionsAreCoherent(config);
69
+ if (config.egressProfile !== undefined) {
70
+ assertBrokeredCredentialsFitProfile(config.egressProfile, config.brokeredCredentials, config.runtime === 'runsc' ? 'runsc' : 'docker');
71
+ }
67
72
  const readiness = resolveReadinessOptions('docker', config.readyTimeoutMs, config.readyPollIntervalMs, {
68
73
  timeoutMs: DEFAULT_READY_TIMEOUT_MS,
69
74
  pollIntervalMs: DEFAULT_READY_POLL_MS,
@@ -134,6 +139,15 @@ export async function resolveAllowedHosts(egress) {
134
139
  return await egress.resolve();
135
140
  throw new Error(`Egress policy of kind "${egress.kind}" does not describe a host allowlist and must not be routed through the proxy.`);
136
141
  }
142
+ /**
143
+ * Whether two allowlists are the same list: same hosts, same order, duplicates
144
+ * kept. Deliberately not a set comparison. The proxy is handed the list as
145
+ * written, and a reordered list is a different configuration even when it
146
+ * permits the same hosts; the only cost of calling it different is one swap.
147
+ */
148
+ function sameHostList(a, b) {
149
+ return a.length === b.length && a.every((host, index) => host === b[index]);
150
+ }
137
151
  /** Whether a policy needs a boundary before it can be enforced at all. */
138
152
  export function needsEgressProxy(egress) {
139
153
  return egress?.kind === 'static' || egress?.kind === 'resolver';
@@ -228,8 +242,24 @@ export function egressProxyContainerConfig(config, allowedHosts, port) {
228
242
  credentials: config.brokeredCredentials ?? [],
229
243
  ...(config.allowInwardFor ? { allowInwardFor: config.allowInwardFor } : {}),
230
244
  selfNames: [PROXY_HOST_ALIAS],
245
+ ...(usesPortRules(config) && config.egressProfile
246
+ ? { hostPorts: config.egressProfile.hosts }
247
+ : {}),
231
248
  };
232
249
  }
250
+ /**
251
+ * Whether the proxy has port rules to enforce: a profile with at least one
252
+ * rule that carries `ports`. Only then is the configuration sent as V2, and
253
+ * only then is the image's label checked, so every other policy starts the
254
+ * proxy with exactly the V1 configuration and argv it always had.
255
+ */
256
+ export function usesPortRules(config) {
257
+ return config.egressProfile !== undefined && egressProfileHasPorts(config.egressProfile);
258
+ }
259
+ /** The environment variable the proxy's configuration travels in. See {@link usesPortRules}. */
260
+ export function egressProxyConfigEnvName(config) {
261
+ return usesPortRules(config) ? EGRESS_PROXY_CONFIG_V2_ENV : EGRESS_PROXY_CONFIG_ENV;
262
+ }
233
263
  /**
234
264
  * `--label key=value` flags, validated before they reach the daemon.
235
265
  *
@@ -258,6 +288,21 @@ function renderLabelArgs(labels) {
258
288
  * that pair rather than a literal written twice.
259
289
  */
260
290
  const EGRESS_PROXY_CONFIG_ENV = 'NAMZU_EGRESS_PROXY_CONFIG';
291
+ /**
292
+ * The V2 configuration, which adds port rules. Sent INSTEAD of the V1 one,
293
+ * never beside it: an image that predates port rules reads only V1, finds it
294
+ * unset and exits, so it fails closed rather than enforcing hosts without
295
+ * ports. `egress-proxy/server.mjs` is the reader.
296
+ */
297
+ export const EGRESS_PROXY_CONFIG_V2_ENV = 'NAMZU_EGRESS_PROXY_CONFIG_V2';
298
+ /**
299
+ * The image label saying which configuration version the proxy image reads.
300
+ * `egress-proxy/Dockerfile` sets it to `2`. Checked with `docker image
301
+ * inspect` before a proxy with port rules is started, because the other
302
+ * signal, the container exiting, can arrive after the readiness check has
303
+ * already passed.
304
+ */
305
+ export const EGRESS_PROXY_IMAGE_CONFIG_LABEL = 'ai.namzu.egress-proxy.config';
261
306
  /**
262
307
  * The `docker run` argv for the egress proxy container, as a value.
263
308
  *
@@ -329,7 +374,7 @@ export function renderEgressProxyRunArgs(input) {
329
374
  // anything with access to the daemon, through `docker inspect`, and
330
375
  // `docs/sdk/sandbox-egress.md` says so.
331
376
  '--env',
332
- EGRESS_PROXY_CONFIG_ENV,
377
+ egressProxyConfigEnvName(config),
333
378
  config.egressProxyImage,
334
379
  ];
335
380
  }
@@ -950,6 +995,11 @@ async function spawnDockerSandbox(config, options, readiness) {
950
995
  const containerName = `namzu-sandbox-${id}`;
951
996
  /** The proxy container's name, once it is being started. */
952
997
  let egressProxyContainer;
998
+ /**
999
+ * The allowlist the running proxy container was started with, or
1000
+ * `undefined` when that is not known. See `setNetworkPolicy`.
1001
+ */
1002
+ let appliedHosts;
953
1003
  // All bind sources come from the consumer-supplied layout. The
954
1004
  // backend never allocates host directories and never removes them
955
1005
  // — that pre-existing single-mount mkdtemp path was the source of
@@ -1016,6 +1066,12 @@ async function spawnDockerSandbox(config, options, readiness) {
1016
1066
  // does that itself — so an abort before this point reaches the same
1017
1067
  // place by the same route.
1018
1068
  if (proxyPlanned && options.egress) {
1069
+ // Before anything is started, and only when there are port rules:
1070
+ // an image that cannot read them is refused by name here, rather
1071
+ // than started and left to exit after the readiness check passed.
1072
+ if (usesPortRules(config)) {
1073
+ await assertEgressProxyImageReadsPortRules(docker, config.egressProxyImage, options.signal);
1074
+ }
1019
1075
  egressProxyContainer = egressProxyContainerName(id);
1020
1076
  // Resolved once, here, rather than per request — see
1021
1077
  // `egressProxyContainerConfig` for what that costs and why it is paid.
@@ -1029,6 +1085,7 @@ async function spawnDockerSandbox(config, options, readiness) {
1029
1085
  allowedHosts,
1030
1086
  signal: options.signal,
1031
1087
  });
1088
+ appliedHosts = Object.freeze([...allowedHosts]);
1032
1089
  options.signal?.throwIfAborted();
1033
1090
  }
1034
1091
  const args = buildDockerRunArgs({
@@ -1063,6 +1120,11 @@ async function spawnDockerSandbox(config, options, readiness) {
1063
1120
  let teardownPromise;
1064
1121
  let teardownComplete = false;
1065
1122
  const workerClient = new HttpWorkerClient(baseUrl, workerToken);
1123
+ /**
1124
+ * The tail of this sandbox's `setNetworkPolicy` calls. Each call chains
1125
+ * onto it, so at most one swap is in flight. See `setNetworkPolicy`.
1126
+ */
1127
+ let policyQueue = Promise.resolve();
1066
1128
  const assertActive = () => {
1067
1129
  if (lifecycle !== 'active') {
1068
1130
  throw new Error(`Sandbox ${id} is ${lifecycle}; no new worker operation can be admitted`);
@@ -1192,58 +1254,103 @@ async function spawnDockerSandbox(config, options, readiness) {
1192
1254
  // raced a failing create would otherwise call `assertActive()` and
1193
1255
  // then name `undefined` in the removal.
1194
1256
  const proxyContainer = egressProxyContainer;
1195
- try {
1196
- await restartEgressProxyContainer({
1197
- docker,
1198
- config,
1199
- containerName: proxyContainer,
1200
- internalNetwork: network,
1201
- allowedHosts: policy.allowedHosts,
1257
+ // Copied now, so a caller that mutates its array while the call waits
1258
+ // its turn does not change what the call applies.
1259
+ const requested = Object.freeze([...policy.allowedHosts]);
1260
+ // Under a profile, a live change narrows within it and never widens
1261
+ // past it: the profile is what the host declared this sandbox may
1262
+ // reach, and a call that names more is refused before it is queued.
1263
+ const profile = config.egressProfile;
1264
+ if (profile !== undefined) {
1265
+ requested.forEach((entry, index) => {
1266
+ if (egressProfileCoversEntry(profile, entry))
1267
+ return;
1268
+ throw new SandboxEgressProfileError('invalid-host', `allowedHosts[${index}]`, `${JSON.stringify(entry)} is outside egress profile ${JSON.stringify(profile.name)}; setNetworkPolicy may narrow within the profile, not widen past it`, config.runtime === 'runsc' ? 'runsc' : 'docker');
1202
1269
  });
1203
- // A teardown that landed while the replacement was starting
1204
- // leaves a container nothing else will ever remove: `destroy()`
1205
- // is running or has run, `egressProxyContainer` is only removed
1206
- // from `teardownSandbox` and `cleanupOnFailure`, and the
1207
- // `assertActive()` above ran BEFORE the first `await` — before
1208
- // the swap suspended inside `restartEgressProxyContainer`, which
1209
- // is exactly when a teardown lands. So the swap fails here
1210
- // rather than reporting a policy change on a sandbox that no
1211
- // longer exists.
1212
- assertActive();
1213
1270
  }
1214
- finally {
1215
- // ... and the replacement is removed even so, because the check
1216
- // above cannot be where the guarantee lives. It sits AFTER the
1217
- // container comes into existence, and that ordering is what
1218
- // makes it airtight rather than merely narrower than the check
1219
- // at entry. A generation counter read before the `docker run`
1220
- // has the opposite shape: it can only refuse a start it already
1221
- // knows about, and a teardown that begins between that refusal
1222
- // being evaluated and the daemon committing the container is in
1223
- // no check's view — the container exists and nothing has looked
1224
- // since. Here the two orderings partition the space instead. A
1225
- // teardown that began before this point has already set
1226
- // `lifecycle`, synchronously (`teardownSandbox` and `retire()`
1227
- // both do, before their first `await`), so this removal runs. A
1228
- // teardown that begins after it issues its own `rm -f` for this
1229
- // same name — `teardownSandbox` reads `egressProxyContainer`,
1230
- // which is this container — against a container that, at this
1231
- // point, exists. Whichever of the two runs second finds the
1232
- // container and removes it, and both are idempotent.
1233
- //
1234
- // A signal-less remover, and not `runOnce` on some signal, for
1235
- // the reason `removeEgressProxyContainer` exists: the teardown
1236
- // this is racing may be one whose own signal was already
1237
- // aborted, and it removes nothing at all in that case (the whole
1238
- // container set is left, which is what `Sandbox.destroy`
1239
- // promises to settle promptly over). That is the caller's
1240
- // contract and not something to defeat — but a proxy container
1241
- // holding brokered credentials and a live route to the internet
1242
- // is not something to leave with it either.
1243
- if (lifecycle !== 'active') {
1244
- await removeEgressProxyContainer(docker, proxyContainer);
1271
+ // Calls are SERIALIZED per sandbox, first in first out, and each one
1272
+ // applies and verifies its OWN policy. Overlapping swaps used to
1273
+ // interleave remove, run, attach and inspect against one container
1274
+ // name: one call's pre-start removal, or a failure path's removal by
1275
+ // name, could land on the other call's container after that call had
1276
+ // resolved, leaving no proxy at all; and one call's readiness inspect
1277
+ // could read the other's container as running and resolve claiming a
1278
+ // policy that was not in force. There is no coalescing ("last writer
1279
+ // wins") on purpose: a call replaced by a later one would then resolve
1280
+ // while a different policy was in force, which is the same misreport.
1281
+ // The kubernetes backend's per-sandbox setter follows the same rule
1282
+ // (`per-sandbox-policy.ts`).
1283
+ const step = policyQueue.then(async () => {
1284
+ // Again, because a teardown may have landed while this call waited
1285
+ // behind another.
1286
+ assertActive();
1287
+ // A repeat of the policy the running container already enforces is a
1288
+ // no-op. This backend has no adopt path, so the sandbox handle is the
1289
+ // only owner of its proxy and this record is authoritative. It is
1290
+ // cleared before every swap and set again only when the swap
1291
+ // succeeds, because after a failure the state is unknown and the next
1292
+ // call must swap.
1293
+ if (appliedHosts !== undefined && sameHostList(appliedHosts, requested))
1294
+ return;
1295
+ appliedHosts = undefined;
1296
+ try {
1297
+ await restartEgressProxyContainer({
1298
+ docker,
1299
+ config,
1300
+ containerName: proxyContainer,
1301
+ internalNetwork: network,
1302
+ allowedHosts: requested,
1303
+ });
1304
+ // A teardown that landed while the replacement was starting
1305
+ // leaves a container nothing else will ever remove: `destroy()`
1306
+ // is running or has run, `egressProxyContainer` is only removed
1307
+ // from `teardownSandbox` and `cleanupOnFailure`, and the
1308
+ // `assertActive()` above ran BEFORE the first `await` — before
1309
+ // the swap suspended inside `restartEgressProxyContainer`, which
1310
+ // is exactly when a teardown lands. So the swap fails here
1311
+ // rather than reporting a policy change on a sandbox that no
1312
+ // longer exists.
1313
+ assertActive();
1245
1314
  }
1246
- }
1315
+ finally {
1316
+ // ... and the replacement is removed even so, because the check
1317
+ // above cannot be where the guarantee lives. It sits AFTER the
1318
+ // container comes into existence, and that ordering is what
1319
+ // makes it airtight rather than merely narrower than the check
1320
+ // at entry. A generation counter read before the `docker run`
1321
+ // has the opposite shape: it can only refuse a start it already
1322
+ // knows about, and a teardown that begins between that refusal
1323
+ // being evaluated and the daemon committing the container is in
1324
+ // no check's view — the container exists and nothing has looked
1325
+ // since. Here the two orderings partition the space instead. A
1326
+ // teardown that began before this point has already set
1327
+ // `lifecycle`, synchronously (`teardownSandbox` and `retire()`
1328
+ // both do, before their first `await`), so this removal runs. A
1329
+ // teardown that begins after it issues its own `rm -f` for this
1330
+ // same name — `teardownSandbox` reads `egressProxyContainer`,
1331
+ // which is this container — against a container that, at this
1332
+ // point, exists. Whichever of the two runs second finds the
1333
+ // container and removes it, and both are idempotent.
1334
+ //
1335
+ // A signal-less remover, and not `runOnce` on some signal, for
1336
+ // the reason `removeEgressProxyContainer` exists: the teardown
1337
+ // this is racing may be one whose own signal was already
1338
+ // aborted, and it removes nothing at all in that case (the whole
1339
+ // container set is left, which is what `Sandbox.destroy`
1340
+ // promises to settle promptly over). That is the caller's
1341
+ // contract and not something to defeat — but a proxy container
1342
+ // holding brokered credentials and a live route to the internet
1343
+ // is not something to leave with it either.
1344
+ if (lifecycle !== 'active') {
1345
+ await removeEgressProxyContainer(docker, proxyContainer);
1346
+ }
1347
+ }
1348
+ appliedHosts = requested;
1349
+ });
1350
+ // The chain continues past a rejection: one caller's failed swap is
1351
+ // that caller's error, not a reason to refuse every later call.
1352
+ policyQueue = step.then(() => undefined, () => undefined);
1353
+ await step;
1247
1354
  },
1248
1355
  async writeFile(path, content) {
1249
1356
  assertActive();
@@ -1432,7 +1539,7 @@ async function startEgressProxyContainer(input) {
1432
1539
  // `docker` CLI's own. See `renderEgressProxyRunArgs` for why it does not
1433
1540
  // travel in the argv.
1434
1541
  const proxyEnvironment = {
1435
- [EGRESS_PROXY_CONFIG_ENV]: JSON.stringify(egressProxyContainerConfig(config, allowedHosts, EGRESS_PROXY_PORT_INSIDE_CONTAINER)),
1542
+ [egressProxyConfigEnvName(config)]: JSON.stringify(egressProxyContainerConfig(config, allowedHosts, EGRESS_PROXY_PORT_INSIDE_CONTAINER)),
1436
1543
  };
1437
1544
  try {
1438
1545
  await runOnce(docker, renderEgressProxyRunArgs(argvInput), signal, proxyEnvironment);
@@ -1511,6 +1618,35 @@ async function assertEgressProxyContainerIsRunning(docker, containerName, signal
1511
1618
  return;
1512
1619
  throw withHint(new Error(`The egress proxy container '${containerName}' is not running after being started, so this sandbox has no boundary to reach and no other route out.`), 'Almost always the image: it either lacks the compiled module (build the package before the image — `pnpm --filter @namzu/sandbox build`) or the entrypoint refused its configuration. `docker logs <container>` has the line the entrypoint wrote; the container is started with --rm, so an already-exited one is gone and its output with it.');
1513
1620
  }
1621
+ /**
1622
+ * Refuse an egress proxy image that does not declare it reads port rules.
1623
+ *
1624
+ * Reads {@link EGRESS_PROXY_IMAGE_CONFIG_LABEL} off the image with `docker
1625
+ * image inspect`, which needs the image to be present on the daemon: `docker
1626
+ * run` would pull a missing image, and this check does not, so an image that
1627
+ * has never been pulled is refused with that in the hint. Anything other than
1628
+ * `2` or higher, including an unreadable answer, is a refusal.
1629
+ */
1630
+ async function assertEgressProxyImageReadsPortRules(docker, image, signal) {
1631
+ let label = '';
1632
+ try {
1633
+ label = await runOnce(docker, [
1634
+ 'image',
1635
+ 'inspect',
1636
+ '--format',
1637
+ `{{ index .Config.Labels "${EGRESS_PROXY_IMAGE_CONFIG_LABEL}" }}`,
1638
+ image,
1639
+ ], signal);
1640
+ }
1641
+ catch {
1642
+ signal?.throwIfAborted();
1643
+ label = '';
1644
+ }
1645
+ const version = Number(label.trim());
1646
+ if (Number.isInteger(version) && version >= 2)
1647
+ return;
1648
+ throw withHint(new Error(`The egress proxy image '${image}' does not declare that it reads port rules (label ${EGRESS_PROXY_IMAGE_CONFIG_LABEL} is ${JSON.stringify(label.trim())}, and 2 is needed), and this sandbox's egress profile carries ports. Refusing rather than starting a proxy that would exit on a configuration it cannot read.`), 'Rebuild the image from packages/sandbox/egress-proxy/Dockerfile (`pnpm --filter @namzu/sandbox build`, then `docker build -f packages/sandbox/egress-proxy/Dockerfile -t <tag> packages/sandbox`), and pull it onto the daemon first if it lives in a registry. A profile without ports needs no rebuild.');
1649
+ }
1514
1650
  /**
1515
1651
  * Ask Docker which host port it bound to the worker port. Used
1516
1652
  * instead of the pre-reserve-then-publish pattern (which had a