@namzu/sandbox 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +21 -531
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @namzu/sandbox
|
|
2
2
|
|
|
3
|
+
## 6.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 03e363c: Declare the Node floor these packages already had, and export a type `TelemetryConfig` already required.
|
|
8
|
+
|
|
9
|
+
**`engines.node: ">=20.0.0"`.** Only `@namzu/cli` declared one; the other fourteen published without any, so npm could not warn a consumer installing onto an unsupported runtime — they got a crash at some later import instead. The floor is not new: `@namzu/cli` has declared it since it shipped and `install.sh` has enforced it since it existed. This makes the other fourteen say the same thing.
|
|
10
|
+
|
|
11
|
+
If you install with `engine-strict=true` on Node 18, an install that previously emitted nothing will now fail. Upgrade to Node 20 or newer, which the code already assumed. Everyone else sees no change, or an `EBADENGINE` warning that replaces a later crash.
|
|
12
|
+
|
|
13
|
+
Worth stating plainly: CI verifies Node 22 and 24. The 20 floor is a declared minimum, not a tested one.
|
|
14
|
+
|
|
15
|
+
**`SpanProcessorLike` is now exported from `@namzu/telemetry`.** `TelemetryConfig.spanProcessors` takes `readonly SpanProcessorLike[]`, and the type had no export — a field on the public surface whose type was not on it, so a host supplying the value had to inline the shape or reach for `any`.
|
|
16
|
+
|
|
17
|
+
## 6.0.1
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- b2c005c: Make each README an npm package page rather than the package's manual.
|
|
22
|
+
|
|
23
|
+
`@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.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
No API change.
|
|
30
|
+
|
|
3
31
|
## 6.0.0
|
|
4
32
|
|
|
5
33
|
### 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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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,87 +15,37 @@ diataxis: reference
|
|
|
16
15
|
|
|
17
16
|
<h1>@namzu/sandbox</h1>
|
|
18
17
|
|
|
19
|
-
**
|
|
18
|
+
**Container and process isolation for Namzu runs.**
|
|
20
19
|
|
|
21
|
-
[](https://www.npmjs.com/package/@namzu/sandbox)
|
|
21
|
+
[](https://github.com/cogitave/namzu/actions/workflows/ci.yml)
|
|
22
|
+
[](https://github.com/cogitave/namzu/blob/main/LICENSE.md)
|
|
23
23
|
|
|
24
|
-
[Install](#install) · [
|
|
24
|
+
[Install](#install) · [Usage](#usage) · [Documentation](#documentation)
|
|
25
25
|
|
|
26
26
|
</div>
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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/
|
|
37
|
+
pnpm add @namzu/sdk @namzu/sandbox
|
|
54
38
|
```
|
|
55
39
|
|
|
56
|
-
`@namzu/sdk`
|
|
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
|
-
|
|
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'
|
|
99
46
|
|
|
47
|
+
declare const taskId: string
|
|
48
|
+
|
|
100
49
|
const provider = createSandboxProvider({
|
|
101
50
|
backend: {
|
|
102
51
|
tier: 'container',
|
|
@@ -115,470 +64,11 @@ const provider = createSandboxProvider({
|
|
|
115
64
|
})
|
|
116
65
|
```
|
|
117
66
|
|
|
118
|
-
|
|
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.
|
|
67
|
+
## Documentation
|
|
575
68
|
|
|
576
|
-
The
|
|
577
|
-
|
|
578
|
-
that drift apart. `VsockAgentTransport` and the layout types are re-exported for
|
|
579
|
-
hosts that wire the microVM control path themselves.
|
|
69
|
+
- [The sandbox — tiers, backends, mounts and the egress boundary](https://github.com/cogitave/namzu/blob/main/docs/packages/sandbox.md)
|
|
70
|
+
- [Namzu docs](https://github.com/cogitave/namzu/tree/main/docs)
|
|
580
71
|
|
|
581
72
|
## License
|
|
582
73
|
|
|
583
|
-
FSL-1.1-MIT, converting to MIT two years after each release.
|
|
584
|
-
`@namzu/sdk`.
|
|
74
|
+
FSL-1.1-MIT, converting to MIT two years after each release.
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namzu/sandbox",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
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
|
+
"engines": {
|
|
7
|
+
"node": ">=20.0.0"
|
|
8
|
+
},
|
|
6
9
|
"type": "module",
|
|
7
10
|
"homepage": "https://github.com/cogitave/namzu#readme",
|
|
8
11
|
"repository": {
|
|
@@ -50,7 +53,7 @@
|
|
|
50
53
|
"@types/node": "^22.19.17",
|
|
51
54
|
"typescript": "^5.5.0",
|
|
52
55
|
"vitest": "^3.2.6",
|
|
53
|
-
"@namzu/sdk": "^
|
|
56
|
+
"@namzu/sdk": "^30.1.0"
|
|
54
57
|
},
|
|
55
58
|
"scripts": {
|
|
56
59
|
"build": "tsc --build",
|