@namzu/sandbox 6.0.0 → 6.0.1

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 (3) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +19 -531
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @namzu/sandbox
2
2
 
3
+ ## 6.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - b2c005c: Make each README an npm package page rather than the package's manual.
8
+
9
+ `@namzu/sdk`'s README was a twenty-four-section architecture tour, 45 KB of it; the others ran to several hundred lines each. That is the right shape for a single-package repository, where the README _is_ the documentation, and the wrong one here — it duplicated a `docs/` tree that already existed, and nothing checked that the two agreed.
10
+
11
+ Each README is now what a reader needs in the first minute: what the package is, install with its Node requirement, one working example, and links. The long-form material moved into `docs/` whole — `docs/sdk/architecture.md`, `docs/cli/reference.md`, `docs/packages/<name>.md` — where the doc gates cover it.
12
+
13
+ Two documentation defects fell out of the move, both in `@namzu/telemetry`'s session-export example, and both had been shipping: the config field is `redactors` and takes a list, not `redactor` taking one; and `secretRedactor` is a factory that has to be called. The required `destination` field was missing from the example entirely. They surfaced because a README is gated by nothing and `docs/` is compiled against the built SDK.
14
+
15
+ No API change.
16
+
3
17
  ## 6.0.0
4
18
 
5
19
  ### Major Changes
package/README.md CHANGED
@@ -2,11 +2,10 @@
2
2
  type: Reference
3
3
  title: "@namzu/sandbox"
4
4
  description: >-
5
- Container and microVM containment for @namzu/sdk. Runs a task inside one OCI
6
- container or one hardware-virtualized guest behind the kernel's own
7
- SandboxProvider interface, with an egress boundary that decides by resolved
8
- address. Separate so the kernel holds no opinion about the trust boundary.
9
- tags: [readme, package, sandbox, containment, egress]
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
7
+ cannot talk its way past.
8
+ tags: [readme, package, sandbox, isolation]
10
9
  timestamp: 2026-08-17T00:00:00Z
11
10
  status: active
12
11
  diataxis: reference
@@ -16,83 +15,31 @@ diataxis: reference
16
15
 
17
16
  <h1>@namzu/sandbox</h1>
18
17
 
19
- **Containment for [`@namzu/sdk`](https://www.npmjs.com/package/@namzu/sdk): one container or one guest per task, chosen by configuration.**
18
+ **Container and process isolation for Namzu runs.**
20
19
 
21
- [![License: FSL-1.1-MIT](https://img.shields.io/badge/license-FSL--1.1--MIT-blue.svg)](https://github.com/cogitave/namzu/blob/main/LICENSE.md)
22
- [![npm](https://img.shields.io/npm/v/@namzu/sandbox.svg?label=%40namzu%2Fsandbox)](https://www.npmjs.com/package/@namzu/sandbox)
20
+ [![npm](https://img.shields.io/npm/v/@namzu/sandbox.svg)](https://www.npmjs.com/package/@namzu/sandbox)
21
+ [![build](https://github.com/cogitave/namzu/actions/workflows/ci.yml/badge.svg)](https://github.com/cogitave/namzu/actions/workflows/ci.yml)
22
+ [![license](https://img.shields.io/badge/license-FSL--1.1--MIT-blue.svg)](https://github.com/cogitave/namzu/blob/main/LICENSE.md)
23
23
 
24
- [Install](#install) · [Tiers](#two-tiers-four-backends) · [Wire it up](#wire-it-up) · [The image](#the-container-image) · [Mounts](#the-mount-layout) · [Egress](#the-egress-boundary) · [Confinement](#what-every-container-is-launched-with) · [Exports](#exports)
24
+ [Install](#install) · [Usage](#usage) · [Documentation](#documentation)
25
25
 
26
26
  </div>
27
27
 
28
28
  ---
29
29
 
30
- ## What this is
31
-
32
- `@namzu/sdk` declares the `SandboxProvider` shape and ships one implementation
33
- of it: a local provider that confines an agent to the operator's own machine.
34
- That is the right boundary when the threat is the agent reading `~/.ssh`. It is
35
- not a boundary between tenants, and it is not a boundary against a prompt that
36
- is itself hostile.
37
-
38
- This package implements the same interface with backends that put a kernel or a
39
- hypervisor in the way. Swapping the trust boundary is a change to the object you
40
- pass `createSandboxProvider`, not an integration rewrite: the sandbox the kernel
41
- receives has the same `exec` / `readFile` / `writeFile` / `listFiles` surface
42
- whichever backend produced it.
43
-
44
- Every backend named below is implemented. That is worth saying because it used
45
- not to be — a `process` tier, a `passthrough` tier and two adapters to
46
- third-party schedulers were declared here and never written, so four of the
47
- shapes this package offered could only type-check and then throw. They are gone
48
- rather than pending.
30
+ Runs a tool call somewhere that is not your process. Two isolation tiers
31
+ over four backends, a bounded filesystem view, and an egress boundary the
32
+ run cannot talk its way past.
49
33
 
50
34
  ## Install
51
35
 
52
36
  ```bash
53
- pnpm add @namzu/sandbox @namzu/sdk
37
+ pnpm add @namzu/sdk @namzu/sandbox
54
38
  ```
55
39
 
56
- `@namzu/sdk` (>=1.0.0) is a peer dependency. This package has **no runtime
57
- dependencies of its own**: the container tier drives the `docker` CLI through
58
- `node:child_process`, the egress boundary is `node:http`, `node:https` and
59
- `node:net`, and the microVM tier speaks HTTP to an orchestrator and a framed
60
- protocol to a guest agent. Credentials arrive as closures you supply, so no
61
- cloud SDK is pulled in behind your back.
62
-
63
- ## Two tiers, four backends
64
-
65
- A tier is the kind of boundary. A backend is the mechanism that produces one.
66
-
67
- | Backend | What the boundary is | Reach for it when |
68
- |---|---|---|
69
- | `container`, `runtime: 'docker'` | Kernel namespaces and cgroups, plus whatever seccomp profile the daemon applies by default — this package sets none of its own. Runs anywhere a daemon does. | The tenant is trusted and the code is not: your own model, your own users, arbitrary tool calls. |
70
- | `container`, `runtime: 'runsc'` | A userspace kernel serves the guest's syscalls. Needs that runtime registered on the daemon, which means Linux. | Same tenancy, a stronger boundary, and you would rather not run virtualization machinery. |
71
- | `container`, `runtime: 'aci-standby-pool'` | A managed provider's isolation host, claimed pre-warmed from a standby pool. See [its own section](#the-standby-pool-backend) — it is real, and it is narrower than the other three. | You cannot reach a container daemon at all. |
72
- | `microvm`, `service: 'self-hosted'` | Hardware virtualization. One guest kernel per task, resumed copy-on-write from a golden snapshot by an orchestrator you run. | The prompt itself is the attacker, or tenants must not be able to reach each other. |
73
-
74
- The question is not which tier is strongest, it is **who you are defending
75
- against**. A guest per task is the only mainstream primitive whose escape
76
- surface is the hypervisor rather than a shared kernel; a userspace kernel is the
77
- good middle trade, at the cost that a bug in that kernel is a tenant escape
78
- where a hypervisor bug usually is not; namespaces are adequate for tenants who
79
- already trust each other and run everywhere with no special runtime. One
80
- operator on their own laptop wants none of these — that is the SDK's local
81
- sandbox provider.
82
-
83
- namzu does not build a microVM scheduler. Starting guests quickly and safely is
84
- an entire product, and the boundary a guest gives is the same whoever started
85
- it. So the `microvm` tier is an **interface to** a scheduler, and the one it
86
- speaks to is namzu's own control plane.
40
+ `@namzu/sdk` is a peer dependency. Install both.
87
41
 
88
- The interface carries no cloud in it. The container tier over a local daemon
89
- runs anywhere; the microVM tier needs infrastructure you choose and operate.
90
- Picking a stronger boundary can imply picking different infrastructure — that is
91
- the host's call, not the kernel's.
92
-
93
- ## Wire it up
94
-
95
- A container per task, on a local daemon:
42
+ ## Usage
96
43
 
97
44
  ```ts
98
45
  import { createSandboxProvider } from '@namzu/sandbox'
@@ -115,470 +62,11 @@ const provider = createSandboxProvider({
115
62
  })
116
63
  ```
117
64
 
118
- A guest per task, when the prompt itself is the attacker. The allowlist is
119
- resolved per run, so the boundary is not fixed at construction:
120
-
121
- ```ts
122
- import { createSandboxProvider } from '@namzu/sandbox'
123
-
124
- const provider = createSandboxProvider({
125
- backend: {
126
- tier: 'microvm',
127
- service: 'self-hosted',
128
- orchestratorEndpoint: 'https://sandbox-control.internal',
129
- getToken: async () => await mintOrchestratorBearer(),
130
- template: 'golden-rev-7',
131
- },
132
- defaultEgress: {
133
- kind: 'resolver',
134
- resolve: async () => await fetchAllowlistForTenant(tenantId),
135
- },
136
- })
137
- ```
138
-
139
- Either object goes to the kernel as `sandboxProvider` — the field is on
140
- `QueryParams` (so `query` and `drainQuery` take it) and on
141
- `ReactiveAgentConfig`. The kernel calls `provider.create()` before the iteration
142
- loop and `sandbox.destroy()` when the run ends.
143
-
144
- **The layout is bound at construction, not per call**, and the type enforces it:
145
- a container-tier config without one does not compile. Layouts are per task —
146
- different host paths for different runs — so a host that runs many tasks
147
- constructs one provider per task, in the same closure that knows the paths.
148
-
149
- | Provider config | Meaning |
150
- |---|---|
151
- | `backend` | The tier and its concrete settings; the tables below. |
152
- | `layout` | Required for the container tier. Not part of the microVM shape at all — that tier seeds its workspace over the control channel, so there is nothing to bind. |
153
- | `defaultEgress` | Applied to every sandbox this provider creates. There is no per-call egress override. |
154
- | `defaultMemoryLimitMb`, `defaultMaxProcesses` | `--memory` and `--pids-limit` on the container tier; forwarded in the create request on the microVM tier. Used only when the kernel's own per-run value is absent. |
155
- | `defaultTimeoutMs` | Forwarded to the microVM orchestrator. The container tier does not read it — a per-command deadline goes on `SandboxExecOptions.timeout` instead, which the worker enforces. |
156
-
157
- | `ContainerBackendConfig` | Default | Notes |
158
- |---|---|---|
159
- | `image` | — | Required. Must run the worker; see below. |
160
- | `runtime` | daemon default | `'docker'` or `'runsc'`. |
161
- | `network` | `'none'` | The docker network to attach. **The default is one of the pairings `create()` refuses** — see the network table under egress. |
162
- | `hostReachability` | `'host-port'` | `'container-network'` when the consumer is itself a container reaching a sibling by name. |
163
- | `allowInwardFor` | — | Allowlisted hosts permitted to resolve to a private address anyway. |
164
- | `labels` | — | `--label key=value` pairs, so out-of-band reapers can find the container. A key that is empty or contains `=` is refused. |
165
-
166
- | `MicroVMBackendConfig` | Default | Notes |
167
- |---|---|---|
168
- | `orchestratorEndpoint` | — | Required. Control-plane base URL. |
169
- | `getToken` | — | Required. Called on every control-plane request, so a long sandbox survives rotation. |
170
- | `template` | orchestrator's | Golden snapshot revision to resume from. |
171
- | `agentSnapshot` | — | `{ orgId, agentId, version }` — resume this agent's captured snapshot instead of a fresh golden boot. |
172
- | `agentVsockPort` | `1024` | Guest port the in-VM agent listens on. |
173
- | `readyTimeoutMs`, `readyPollIntervalMs` | `60_000`, `250` | How long `create()` waits for the guest's own health check to answer, and how often it asks. The orchestrator's 2xx is not the clock — it returns before the guest is running. |
174
- | `mtls` | — | Client material for a network-mode agent handle; merged onto the handle the orchestrator returns. |
175
- | `controlPlaneMtls` | — | Client material for the control-plane calls themselves, for when the endpoint is reached over the public internet. The bearer is still sent. |
176
-
177
- Both mTLS blocks are `{ ca, cert, key, servername? }` and are **injected by
178
- you**, never read from disk or fetched here — the same boundary `getToken` draws.
179
-
180
- `createSandboxProvider` refuses an unrecognised backend **by name**, at
181
- construction, with `SandboxBackendNotImplementedError`. An untyped host that
182
- invents a tier gets a refusal while it is wiring, not a provider that confines
183
- nothing.
184
-
185
- ## The container image
186
-
187
- `image` must name an image whose entrypoint runs the sandbox worker: an HTTP
188
- server on port **2024** serving
189
-
190
- - `GET /healthz` — `create()` polls this and does not return until it answers,
191
- - `POST /execute` — a request per command, answered with an NDJSON stream of
192
- `stdout_delta`, `stderr_delta` and a final `result` event,
193
- - `POST /read-file` and `POST /write-file` — base64 bodies.
194
-
195
- The worker reads `NAMZU_SANDBOX_WORKSPACE`, `NAMZU_SANDBOX_READ_ROOTS` and
196
- `NAMZU_SANDBOX_WRITE_ROOTS`, which the backend sets from the layout, and refuses
197
- a path outside those roots — lexically first, then again against the resolved
198
- `realpath`, so a symlink inside the workspace cannot point out of it.
199
-
200
- Three behaviours of the worker are worth knowing before you build an image
201
- against it. A per-command `timeoutMs` above 30 minutes
202
- (`NAMZU_SANDBOX_MAX_TIMEOUT_MS`) is **refused, not clamped** — running under a
203
- deadline the caller did not ask for is the shape this codebase treats as worse
204
- than refusing. It exits by itself after 5 idle minutes
205
- (`NAMZU_SANDBOX_IDLE_TIMEOUT_MS`, `0` disables), so a container orphaned by a
206
- host crash still goes away. And `listFiles` is implemented by running `find`
207
- with `-printf`, so the image needs a `find` that has it.
208
-
209
- **It authenticates nobody**, and it binds every interface by default because a
210
- consumer that is itself a container has to reach it by container name over a
211
- shared bridge. So the boundary is the network the container is attached to, not
212
- the bind address, and an image of this kind must never sit on a network the
213
- world can route to.
214
-
215
- The worker and a reference image live in this repository under `worker/`. They
216
- are **not** in the published npm tarball, which carries `dist/` and `src/` only:
217
- build the image from the repository, or write your own against the endpoints
218
- above.
219
-
220
- ## The mount layout
221
-
222
- A container needs one place the user will see and several the user will not, and
223
- the difference has to be legible to the model from the path alone.
224
-
225
- | Mount | Mode | Default container path |
226
- |---|---|---|
227
- | `outputs` (**required**) | rw | `/mnt/user-data/outputs` |
228
- | `scratch` | rw | `/mnt/user-data/scratch` |
229
- | `uploads` | ro | `/mnt/user-data/uploads` |
230
- | `toolResults` | ro | `/mnt/user-data/tool_results` |
231
- | `transcripts` | ro | `/mnt/transcripts` |
232
- | `skills[]` | ro | `/mnt/skills/<id>` |
233
-
234
- `outputs.containerPath` is the workspace root and the sandbox's `rootDir`. Only
235
- `outputs` and `scratch` are writable; the read-only mounts stay out of the write
236
- roots so an agent's `write` cannot clobber files the host considers immutable.
237
-
238
- `scratch` is a sibling of `outputs` rather than a child, and it is meant to stay
239
- one on the host side too: a deliverables collector scans the outputs directory,
240
- so giving `scratch` a host path outside that tree is what makes it invisible to
241
- the user. The agent needs somewhere to think out loud that is not the answer.
242
-
243
- Every source is `{ type: 'hostDir', hostPath }` for the docker and `runsc`
244
- runtimes. The layout is validated once, at construction, and
245
- `ContainerSandboxLayoutValidationError` carries **every** violation in one pass —
246
- a missing `outputs`, a skill id outside `[A-Za-z0-9_.-]` or containing `..`, a
247
- duplicate skill id, two mounts claiming the same container path. Fixing one
248
- problem per run is a loop worth not having.
249
-
250
- There is no scratchpad knob beyond `scratch`, and no way to declare the
251
- container's own home directory: no backend bind-mounts it, and a field the
252
- runtime cannot honour is worse than an absent one.
253
-
254
- ## The egress boundary
255
-
256
- ```ts
257
- type EgressPolicy =
258
- | { kind: 'deny-all' }
259
- | { kind: 'allow-all' } // tests only
260
- | { kind: 'static'; allowedHosts: readonly string[] }
261
- | { kind: 'resolver'; resolve: () => Promise<readonly string[]> }
262
- ```
263
-
264
- **Omitting `defaultEgress` is not `deny-all`.** No policy reaches the backend at
265
- all: the container keeps whatever network you named, and the microVM
266
- orchestrator receives no allowlist, which it reads as unrestricted. If you want
267
- nothing to leave, say so.
268
-
269
- Every backend takes the same shape and they do **not** all enforce every
270
- variant. A backend that cannot enforce one throws rather than accepting it
271
- quietly:
272
-
273
- | Backend | `deny-all` | `allow-all` | `static` | `resolver` |
274
- |---|---|---|---|---|
275
- | `container:docker`, `container:runsc` | Enforced by an `--internal` network, checked against the daemon rather than trusted. Impossible under `hostReachability: 'host-port'`, and refused. | The configured network, unfiltered. | Enforced at a loopback egress proxy the backend starts and tears down with the sandbox. | Same, and `resolve()` is called per request so a rotating allowlist is honoured. |
276
- | `container:aci-standby-pool` | Refused | Refused | Refused | Refused |
277
- | `microvm:self-hosted` | An explicitly empty allowlist is forwarded | No allowlist is forwarded | The allowlist is forwarded | `resolve()` is called and its result forwarded |
278
-
279
- Three rows, three versions of the same lesson.
280
-
281
- The container rows once accepted a restrictive policy and silently granted the
282
- configured network, which is worse than not supporting the feature: the host
283
- believes it is protected and stops looking. The standby-pool row is the same
284
- failure found later — its claim API rejects every property override except a
285
- config map, so an egress policy, a memory cap, a process cap and environment
286
- variables had nowhere to ride through, and all four were accepted and dropped.
287
- Set them on the container group profile the pool is built from. The microVM row
288
- is a third variant: `allow-all` and `resolver` both used to encode as an omitted
289
- allowlist, so one encoding carried two opposite intentions and the callback that
290
- produces the tenant-scoped list was never called anywhere. Whichever way the
291
- orchestrator reads an omitted field, one of the two was always mis-enforced —
292
- and the one that failed **open** was the one whose entire purpose is restriction.
293
-
294
- ### The network has to be able to carry the policy
295
-
296
- `network` and `hostReachability` are not independent of the egress policy, and
297
- pairing them wrongly used to produce a container nobody could reach:
298
-
299
- | You want | `network` | `hostReachability` |
300
- |---|---|---|
301
- | no egress, enforced | a network created `--internal` | `container-network` |
302
- | egress, restricted by allowlist | a bridge, plus the egress proxy | either |
303
- | egress, unrestricted | a bridge | either |
304
-
305
- Docker binds a published port to the container's address by NAT, so a container
306
- with no route out has no address to bind to and **nothing is published** — that
307
- holds for `--network none` and for an `--internal` network alike. `deny-all`
308
- needs exactly such a network. The two requirements are opposites, so *no egress
309
- plus a published host port* is impossible rather than unsupported; closing it
310
- means moving the worker's control channel off TCP.
311
-
312
- `create()` checks both against the daemon and refuses with the reason before
313
- starting anything. The `network` default of `'none'` is one of the pairings it
314
- refuses.
315
-
316
- ### What the allowlist matches
317
-
318
- Two forms, and substring is deliberately not one of them:
319
-
320
- | Entry | Matches |
321
- |---|---|
322
- | `api.example.com` | that host only |
323
- | `.example.com` | that domain and any subdomain, the apex included |
324
-
325
- `host.includes(entry)` is the obvious implementation and it is a hole: an entry
326
- of `example.com` would admit `example.com.attacker.net`, a domain the attacker
327
- owns. Plain suffix matching has the same hole without the leading dot —
328
- `notexample.com` ends with `example.com` — which is why the wildcard form
329
- requires it. Comparison ignores case and a trailing dot, because DNS does and an
330
- allowlist that did not would be bypassable by typing the host differently.
331
-
332
- A policy that cannot be read **denies**: a `resolve()` that throws is a policy
333
- nobody could read, and an allowlist that fails open is not an allowlist.
334
-
335
- ### Where the name goes, not just what it is called
336
-
337
- An allowlist entry names a host. **DNS decides where that host goes**, and the
338
- caller may control it — so an allowlisted name is a permitted *spelling* until
339
- something has looked at the address behind it. A permitted name with an `A`
340
- record pointing at `169.254.169.254` is the instance metadata service wearing
341
- it, and on the plain-HTTP path a brokered credential goes on the request before
342
- it is sent. Without an address check the brokering design is the delivery
343
- mechanism.
344
-
345
- So the boundary refuses these, whatever the allowlist says:
346
-
347
- | Refused | IPv4 | IPv6 |
348
- |---|---|---|
349
- | loopback | `127.0.0.0/8` | `::1` |
350
- | private / unique-local | `10/8`, `172.16/12`, `192.168/16` | `fc00::/7` |
351
- | link-local (metadata) | `169.254.0.0/16` | `fe80::/10` |
352
- | shared address space | `100.64.0.0/10` | — |
353
- | benchmarking | `198.18.0.0/15` | — |
354
- | this-host, multicast, reserved | `0/8`, `224/4`, `240/4` | `::`, `ff00::/8` |
355
-
356
- A v4 address written as IPv6 — `::ffff:169.254.169.254`, its hexadecimal
357
- spelling, or the deprecated v4-compatible form — is the same address and is
358
- refused the same way. A v4-only screen passes all of them, which is a known way
359
- through this kind of filter rather than an oversight. Prefixes are matched as
360
- numbers and not as text, because `fd::1` is an ordinary global address that
361
- `/^f[cd]/` would have deleted a slice of the internet for.
362
-
363
- **The screening happens inside the resolution the socket performs**, not before
364
- it. Resolve-check-then-connect leaves the socket free to resolve a second time,
365
- and the second answer is the one that decides where the bytes go — so a name
366
- that alternates records walks through a check that passed a moment earlier.
367
- Every address in the record set is screened, not only the one that would have
368
- been used, because a set mixing a public address with an inward one is the
369
- ordinary shape of this and screening the winner alone makes the outcome depend
370
- on resolver ordering. The request keeps the **name**, so SNI and certificate
371
- validation still check what the allowlist approved.
372
-
373
- ### When the private address is the point
374
-
375
- Some deployments genuinely proxy to a service on a private network. Name it:
376
-
377
- ```ts
378
- allowInwardFor: ['.internal.example'],
379
- ```
380
-
381
- Per host, matched by the allowlist's own rules. There is deliberately no switch
382
- that turns the screen off — one would hand every other allowlisted name the same
383
- reach, which is the hole the screen exists to close. A refusal says which kind of
384
- address it was, because `loopback`, `link-local` and `private` are different
385
- mistakes with different fixes.
386
-
387
- **On the tunnel path this bounds the destination and nothing more.** The
388
- allowlist reads the name in the `CONNECT` line — the only part of that exchange
389
- ever in clear text — and the address screen makes that a real bound on where the
390
- tunnel terminates. What travels inside it afterwards is opaque to this process,
391
- including the name the caller puts in its own TLS handshake. A tunnel to an
392
- allowlisted host is not a guarantee that only allowlisted traffic crosses it.
393
-
394
- ### Changing the policy while a sandbox runs
395
-
396
- ```ts
397
- await sandbox.setNetworkPolicy?.({ allowedHosts: [] })
398
- ```
399
-
400
- Narrows or widens a live sandbox — the shape this exists for is "clone with a
401
- token, then drop to deny-all before running anything the repository contains",
402
- which was previously not expressible at all: the policy was frozen at provider
403
- construction, so a host had to build a second provider and a second sandbox and
404
- copy the work across.
405
-
406
- The method is optional on the `Sandbox` contract and is **implemented by the
407
- docker and `runsc` runtimes only**, and there only when the policy in force
408
- started an egress proxy; without one the container's network was fixed at
409
- creation and there is nothing to narrow, so the call throws. A policy accepted
410
- and not applied is worse than one never offered: the caller stops looking, and
411
- the run proceeds believing it is confined.
412
-
413
- ### Credentials that never enter the sandbox
414
-
415
- Any token the agent needs to reach an allowed host would otherwise have to be
416
- inside the container, in the environment — readable by the untrusted code it is
417
- meant to be isolated from, via `/proc/self/environ`, or via a prompt injection
418
- that exfiltrates it over the very egress the policy permits.
419
-
420
- The proxy holds the real value host-side and stamps it on at the boundary,
421
- scoped per host: a credential attached to every request is a credential handed
422
- to whichever host the agent was talked into contacting.
423
-
424
- ```ts
425
- import { EgressProxy } from '@namzu/sandbox'
426
-
427
- const proxy = await new EgressProxy({
428
- allowedHosts: async () => ['api.example.com'],
429
- credentials: [
430
- { host: 'api.example.com', header: 'authorization', value: process.env.API_TOKEN ?? '' },
431
- ],
432
- onDenied: (host, reason) => log.warn({ host, reason }, 'egress denied'),
433
- }).listen()
434
-
435
- // proxy.url is 'http://127.0.0.1:<port>' — loopback only, on purpose.
436
- await proxy.close()
437
- ```
438
-
439
- `EgressProxy` is exported and can be run directly, and **today that is the only
440
- way to broker a credential**: the container backend reads a brokered-credential
441
- list from its internal config, and `createSandboxProvider` has never had a field
442
- that fills it. The proxy the provider starts for you enforces the allowlist and
443
- the address screen; it carries no credentials.
444
-
445
- The container reaches that proxy by an added host alias resolving to the docker
446
- host gateway, with `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` set in both
447
- spellings so tooling inside routes through it whichever one it reads.
448
-
449
- One honest limit, wherever it runs. A credential **cannot** be injected into a
450
- CONNECT tunnel — by the time those bytes reach the proxy they are encrypted, and
451
- reading them would mean terminating TLS with a CA the sandbox trusts, which
452
- would let the proxy read every byte the agent sends anywhere. That is a strictly
453
- larger risk than the one being mitigated, so it is not built. A workload that
454
- needs brokering speaks plain HTTP to the proxy and lets it upgrade to HTTPS
455
- upstream, which is the default.
456
-
457
- ## What every container is launched with
458
-
459
- - `--cap-drop=ALL`, and it carries two independent loads. `CAP_DAC_OVERRIDE`
460
- alone walks past the read-only bind mounts the layout sets up, which would
461
- make the whole mount layout advisory. Separately, an `--internal` network
462
- denies egress by giving the container no route out — and restoring one is a
463
- single `ip route add`, refused only because `NET_ADMIN` is absent. The network
464
- removes the route; this flag removes the ability to put it back.
465
- - `--security-opt=no-new-privileges`, without which a setuid binary in the image
466
- re-escalates after the drop.
467
- - The configured network, and the layout's mounts with `rw` or `ro` as the table
468
- above says.
469
- - `--memory` and `--pids-limit`, when a memory or process cap was configured.
470
-
471
- There is deliberately **no re-add list** for capabilities. A workload that
472
- genuinely needs one should say so somewhere a reviewer sees it rather than
473
- inherit it from a default.
474
-
475
- `--user` is a different case, and the honest statement is narrower than "there
476
- is none". The backend can pass one, but no field on `ContainerBackendConfig`
477
- fills it, so nothing reachable through `createSandboxProvider` sets it — the
478
- container runs as whatever user the image declares. Forcing a uid here would
479
- break every image that expects root at startup, and the correct value depends on
480
- the image's own filesystem ownership. Build the image to run as a non-root user.
481
-
482
- ### The environment a command runs in
483
-
484
- The worker strips every variable prefixed `NAMZU_SANDBOX_` before spawning a
485
- command. Those are its own configuration — the workspace root and both root
486
- lists among them — and passing them on handed the confinement layout to the code
487
- being confined.
488
-
489
- Everything else is inherited, which is deliberate:
490
-
491
- - The proxy variables above are set on the container on purpose. A workload that
492
- read only the missing spelling would stop being proxied, which looks exactly
493
- like the policy working.
494
- - Anything you passed as the sandbox's `env` is meant to reach commands, and
495
- once it is in the worker's environment the prefix is the only thing separating
496
- it from the worker's own settings.
497
-
498
- Per-command `env` is applied after the strip and is **not** filtered, so a caller
499
- who deliberately sets a prefixed name still gets it. A workload that needs the
500
- workspace root can read it as the command's `cwd`.
501
-
502
- ## The standby-pool backend
503
-
504
- The fourth backend, dispatched on `runtime: 'aci-standby-pool'`, claims a
505
- pre-warmed container group from an Azure standby pool instead of running a local
506
- daemon. It is implemented, and it is what to use when you cannot reach a
507
- container daemon at all. Three things to know before you plan around it:
508
-
509
- - **The provider config type does not admit its shape.** `SandboxProviderConfig`
510
- is a union of the container and microVM shapes, so `ACIStandbyPoolBackendConfig`
511
- reaches the dispatcher only through a cast. Treat it as unreleased surface.
512
- - **`subnetId` is required through this package.** Without a subnet the platform
513
- assigns a public address, and the worker answering there authenticates nobody;
514
- the backend refuses to claim rather than put an unauthenticated execute
515
- endpoint on the internet. Its internal config has an explicit
516
- `allowPublicAddress` opt-out for benchmarks, and no public field fills that
517
- either — so through `createSandboxProvider` the subnet is not optional.
518
- - **Mount sources are `azureFileShare` or `inImage`, never `hostDir`** — there is
519
- no host filesystem to bind. A warm claim in particular has to use `inImage`:
520
- the pool's claim API rejects a `volumes[]` override, so the container's own
521
- filesystem carries the run and the host walks the outputs back out through the
522
- worker before `destroy()`.
523
-
524
- Per-sandbox egress, memory caps, process caps and environment variables are
525
- refused here, as the table above says.
526
-
527
- ## What is not honoured
528
-
529
- Two gaps, written down rather than implied away, because both would otherwise
530
- look like features that work.
531
-
532
- `SandboxExecOptions.signal` is accepted and **not** forwarded by either tier.
533
- Neither wire has a cancel operation: aborting the request here would abandon the
534
- wait while the command kept running inside the container or the guest, which is
535
- verbatim the failure that option exists to prevent — except it would then look
536
- honoured. A per-command `timeout` is enforced, by the worker on the container
537
- tier and by the guest agent on the microVM tier; both refuse a value above their
538
- 30-minute ceiling rather than silently running under a shorter one.
539
-
540
- `Sandbox.openTerminal` is not implemented by any backend in this package. The
541
- contract's rule is that a backend which cannot open a real pseudo-terminal must
542
- throw rather than hand back a pipe, and leaving the optional method absent is
543
- how that reads here.
544
-
545
- ## Exports
546
-
547
- ```ts
548
- import {
549
- createSandboxProvider,
550
- EgressProxy,
551
- isHostAllowed,
552
- splitAuthority,
553
- serializeSandboxError,
554
- ContainerSandboxLayoutValidationError,
555
- SandboxBackendNotImplementedError,
556
- VsockAgentTransport,
557
- SANDBOX_DEFAULT_OUTPUTS_PATH,
558
- SANDBOX_DEFAULT_UPLOADS_PATH,
559
- SANDBOX_DEFAULT_TOOL_RESULTS_PATH,
560
- SANDBOX_DEFAULT_TRANSCRIPTS_PATH,
561
- SANDBOX_DEFAULT_SKILLS_PARENT,
562
- } from '@namzu/sandbox'
563
- ```
564
-
565
- `isHostAllowed` and `splitAuthority` are the allowlist matcher and the
566
- authority parser, exported so a host can apply the same rules its sandbox will
567
- apply rather than reimplement them slightly differently.
568
-
569
- `serializeSandboxError` turns any error this package raises — and its whole
570
- `cause` chain — into a plain object that is safe through `JSON.stringify`,
571
- `structuredClone` and `postMessage` alike, with cycles replaced by a sentinel
572
- and `ContainerSandboxLayoutValidationError`'s `reasons` preserved. `Error`
573
- subclasses survive none of those channels on their own, which is how a
574
- supervisor architecture loses the one field that said what was wrong.
65
+ ## Documentation
575
66
 
576
- The path constants are exported so a prompt template can say "write outputs to
577
- `${SANDBOX_DEFAULT_OUTPUTS_PATH}`" instead of hard-coding a string in two places
578
- that drift apart. `VsockAgentTransport` and the layout types are re-exported for
579
- hosts that wire the microVM control path themselves.
67
+ - [The sandbox — tiers, backends, mounts and the egress boundary](https://github.com/cogitave/namzu/blob/main/docs/packages/sandbox.md)
68
+ - [Namzu docs](https://github.com/cogitave/namzu/tree/main/docs)
580
69
 
581
70
  ## License
582
71
 
583
- FSL-1.1-MIT, converting to MIT two years after each release. Same as
584
- `@namzu/sdk`.
72
+ FSL-1.1-MIT, converting to MIT two years after each release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namzu/sandbox",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Pluggable sandbox provider for @namzu/sdk. Process-level isolation (bubblewrap on Linux, Seatbelt on macOS, via @anthropic-ai/sandbox-runtime) for developer dev loops; container-level isolation (HTTP worker + a loopback-bound egress proxy that allows by resolved address and brokers outbound credentials so they never enter the sandbox) for multi-tenant production. Backend selected at construction time; the SDK consumes both through the same SandboxProvider interface.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -50,7 +50,7 @@
50
50
  "@types/node": "^22.19.17",
51
51
  "typescript": "^5.5.0",
52
52
  "vitest": "^3.2.6",
53
- "@namzu/sdk": "^28.0.0"
53
+ "@namzu/sdk": "^30.0.1"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "tsc --build",