@namzu/sandbox 4.0.0 → 6.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.
- package/CHANGELOG.md +86 -0
- package/README.md +513 -238
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +2 -1
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts +50 -4
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +130 -8
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +7 -0
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/backends/aci-standby-pool/index.ts +2 -1
- package/src/backends/docker/index.ts +147 -7
- package/src/backends/firecracker/index.ts +7 -0
- package/src/index.ts +13 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# @namzu/sandbox
|
|
2
2
|
|
|
3
|
+
## 6.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- 7425f11: The sandbox worker no longer hands its own configuration to the code it contains
|
|
8
|
+
|
|
9
|
+
Every command the agent runs was spawned with `{ ...process.env, ...body.env }` — the worker's **entire** environment, copied into every child by construction, on every call, and visible in a bare `env` in any shell transcript.
|
|
10
|
+
|
|
11
|
+
That is a stronger exposure than "untrusted code could read `/proc/self/environ` if it thought to look". It is active propagation: the agent does not have to go looking.
|
|
12
|
+
|
|
13
|
+
What rode along: `NAMZU_SANDBOX_WORKSPACE`, `NAMZU_SANDBOX_READ_ROOTS` and `NAMZU_SANDBOX_WRITE_ROOTS` — **the confinement layout itself, handed to the code being confined** — plus every other worker setting. The boundary announced its own shape to the thing it was drawn around.
|
|
14
|
+
|
|
15
|
+
Variables prefixed `NAMZU_SANDBOX_` are now stripped from the inherited environment.
|
|
16
|
+
|
|
17
|
+
**Stripping by prefix rather than by an allowlist of known-safe names is the load-bearing choice**, and it is the difference between this working and this quietly breaking egress:
|
|
18
|
+
|
|
19
|
+
- `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` are set on the container **on purpose**, so tooling inside routes through the egress boundary. An allowlist assembled from first principles drops them, and every proxied workload silently stops being proxied — which looks exactly like the policy working.
|
|
20
|
+
- A host's own `options.env` arrives on the same channel and is meant to reach commands. Once both are in `process.env` it is indistinguishable from the worker's config; the prefix is the only thing that tells them apart.
|
|
21
|
+
|
|
22
|
+
`body.env` is applied **after** the strip and is not filtered. Inheritance is implicit and gets the default; an explicit per-call value is a caller deciding, including one that deliberately sets a prefixed name.
|
|
23
|
+
|
|
24
|
+
**What changes for you.** A command that read `NAMZU_SANDBOX_WORKSPACE`, `NAMZU_SANDBOX_READ_ROOTS` or `NAMZU_SANDBOX_WRITE_ROOTS` out of its own environment no longer sees them. Pass the value explicitly — `exec`'s `env`, or the provider's `options.env` under a name of your own — if a workload genuinely needs it. The workspace root is also the command's `cwd`, which is how most callers were getting it already.
|
|
25
|
+
|
|
26
|
+
`major` because the environment a spawned command observes is behaviour a consumer can depend on, even though nothing in the type surface changed.
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 7aaa35d: Strings that were asserted into ids now go through the checked constructors, and three defects the assertions were hiding are fixed.
|
|
31
|
+
|
|
32
|
+
**A docker sandbox's id had the wrong prefix.** `SandboxId` is `` `sbx_${string}` ``; `@namzu/sandbox`'s docker backend minted `sandbox_...` and an `as SandboxId` was the only reason that compiled. Every docker sandbox in the tree carried an id its own type says is impossible — the ACI backend already minted `sbx_`. Both now mint through `asSandboxId`, which is the call that would have caught it. **The container name derives from this** (`namzu-sandbox-${id}`), so a container started by this release is named differently from one an older build started. Nothing matches on the old spelling — teardown computes the name from the id it just minted, in the same process — but it is visible in `docker ps`, and any external tooling that pattern-matched `namzu-sandbox-sandbox_` needs updating.
|
|
33
|
+
|
|
34
|
+
**A corrupt migration marker was honoured instead of refused.** `readMarker`'s shape check validated the envelope — `version`, `at`, and that `migratedThreads` is an array — and never looked inside the array. `{"migratedThreads":[null]}` therefore parsed cleanly and produced an entry whose `newProjectId` was `undefined` wearing a `ProjectId` annotation, which then reached a path join. Each element is now checked, and a bad one returns `null` — which is exactly what this function already promised to do about corruption, so the caller re-runs the migration rather than trusting it.
|
|
35
|
+
|
|
36
|
+
**`namzu drain` accepted a mistyped scope flag.** `--tenant`, `--project` and `--session` were asserted straight into their id types, so `--tenant prj_a` reached the store and listed nothing — and "no runs" is the same output as a scope that really is empty, which made the typo invisible. Each flag is now prefix-checked, and the refusal names the prefix it wanted, in the same operator-readable shape the command's other refusals use.
|
|
37
|
+
|
|
38
|
+
**Model-authored ids are checked before they become store keys.** `read_memory`, `task_update` and the RAG tool took an id straight from the model's tool input and asserted it. A malformed one read back as "not found", telling the model its record had disappeared rather than that it named the wrong thing. All three now refuse with `InvalidIdError`, whose message says which prefix was expected.
|
|
39
|
+
|
|
40
|
+
Nothing here changes an exported type, a signature or a default. Sites where a cast is still correct — a value already guarded by an explicit prefix check, an id minted by a service outside this repo, a sentinel the type cannot express — keep the cast and now carry the reason next to it.
|
|
41
|
+
|
|
42
|
+
- 701bd02: Bring the worker and guest agent under the linter, and document what they already do
|
|
43
|
+
|
|
44
|
+
`biome.json` restricted `files.include` to `src/**/*.ts` and the lint script ran `biome check src/`. Two independent exclusions of the same directories, so `worker/server.js` and `agent/agent.cjs` were checked by nothing — including the worker, which is the HTTP surface that executes commands inside the container and has no type checking either, being plain CommonJS.
|
|
45
|
+
|
|
46
|
+
Turning it on immediately found dead code: an unused `readNdjson` helper in the worker's own test file. The rest were `useOptionalChain` rewrites in crash handlers, applied and reviewed one at a time — `err && err.stack ? err.stack : err` and `err?.stack ? err.stack : err` take the same branch for every input, including a non-`Error` throw.
|
|
47
|
+
|
|
48
|
+
`noConsole` is off for these two directories. They are standalone processes, not modules this package imports, and stdout is their only channel: the host's readiness path and the test harness both wait on the worker's `listening on` line, and the crash handlers exist so an unhandled rejection is diagnosable rather than a silent exit. A logger abstraction would mean a dependency in files that deliberately have none. (The reason lives here and in the commit rather than beside the setting, because `biome.json` is strict JSON and rejects both comments and unknown keys.)
|
|
49
|
+
|
|
50
|
+
Two documentation debts from earlier changes are cleared in the same pass:
|
|
51
|
+
|
|
52
|
+
- The README's `--cap-drop=ALL` bullet carried only one of its two reasons. It also stops an `--internal` network's egress denial from being undone by a single `ip route add`, which is refused only because `NET_ADMIN` is absent.
|
|
53
|
+
- The README said nothing about the environment a spawned command sees, which changed materially when the worker stopped passing on its own configuration. It now says what is stripped, what is inherited and why the proxy variables and `options.env` must be.
|
|
54
|
+
|
|
55
|
+
No behaviour change.
|
|
56
|
+
|
|
57
|
+
## 5.0.0
|
|
58
|
+
|
|
59
|
+
### Major Changes
|
|
60
|
+
|
|
61
|
+
- a208ba8: The docker backend's default configuration could not create a sandbox, and the test that would have caught it had never run
|
|
62
|
+
|
|
63
|
+
`create()` failed on the documented defaults — `network: 'none'`, `hostReachability: 'host-port'` — with `index of untyped nil` thrown out of a `docker inspect` template, reported as "the container exited immediately". The container was alive and well. Docker binds a published port to the container's address by NAT, so a container with no route out has no address to bind to and nothing is published; measured against Docker 29.6, `--network none --publish 127.0.0.1::2024` is _accepted_ and `NetworkSettings.Ports` comes back `{"2024/tcp":[]}`. An `--internal` network behaves the same way.
|
|
64
|
+
|
|
65
|
+
`deny-all` had the same defect from the other side. It answered `--network none`, which reads as the strictest possible answer and removes the interface the worker is reached on — so it denied the way _in_ along with the way out, in both reachability modes.
|
|
66
|
+
|
|
67
|
+
**Why no one noticed.** `packages/sandbox/vitest.config.ts` excludes `**/*.smoke.test.ts` from every run it governs, including the `test:smoke` run that exists to run them; naming the files as CLI arguments does not re-include them, because positional arguments filter what discovery already found. With `--passWithNoTests`, `pnpm sandbox:smoke` printed `No test files found, exiting with code 0` and the workflow went green — after building a Debian image with a browser and an office suite in it to run nothing. The suite's own fail-fast guard for a misconfigured CI could not fire either: it lives inside a file that was never loaded.
|
|
68
|
+
|
|
69
|
+
**What changes for you.**
|
|
70
|
+
|
|
71
|
+
- The smoke suite has its own config and no `--passWithNoTests`, so an empty run is now a failure.
|
|
72
|
+
- `create()` checks the container's network against the daemon before starting anything, and refuses with the reason. **The `network` default of `'none'` is one of the pairings it refuses** — name a bridge to reach the worker by host port, or set `hostReachability: 'container-network'`.
|
|
73
|
+
- `deny-all` now keeps the configured network and **requires it to be one created with `docker network create --internal`**, verified rather than trusted. That is a real boundary: outbound gets `Network unreachable` from the kernel, not from an environment variable a workload may decline to read, while sibling containers still reach the worker by name.
|
|
74
|
+
- Consequently **`deny-all` over a published host port is refused as impossible**, not unsupported: a published port needs a route out and `deny-all` needs none. Closing that means moving the worker's control channel off TCP, which is tracked separately.
|
|
75
|
+
- `resolveNetwork` no longer returns `'none'` for `deny-all`. `assertNetworkCarriesThePolicy` and `isInternalNetwork` are exported alongside it.
|
|
76
|
+
|
|
77
|
+
### Patch Changes
|
|
78
|
+
|
|
79
|
+
- 3e591c7: Record why the capability drop is load-bearing for egress denial
|
|
80
|
+
|
|
81
|
+
Comment only; no behaviour change.
|
|
82
|
+
|
|
83
|
+
`deny-all` is now enforced by the container's network being `--internal`, and it was worth measuring what that is actually worth. A container on such a network has no default route — but `ip route add default via <sibling>` is refused with `Operation not permitted` under docker's **default** capability set, before `--cap-drop=ALL` is applied at all. `NET_ADMIN` is what would lift that.
|
|
84
|
+
|
|
85
|
+
So the internal network removes the route and the capability drop removes the ability to put one back. Both are needed, and `HARDENING_ARGS` previously justified the drop only on unrelated grounds (`CAP_DAC_OVERRIDE` walking past read-only binds) — a rationale that would survive softening the flag, while this one would not.
|
|
86
|
+
|
|
87
|
+
Also recorded: given `NET_ADMIN` and a manually installed route, a dual-homed sibling _does_ forward the packet (`net.ipv4.ip_forward` is `1` inside a container). No connection establishes because nothing masquerades the internal subnet, but the docblock says plainly that this is not a security property — one-way egress is enough for exfiltration, and what was measured is a failed handshake, not a dropped packet.
|
|
88
|
+
|
|
3
89
|
## 4.0.0
|
|
4
90
|
|
|
5
91
|
### Major Changes
|