@namzu/sandbox 1.1.0 → 2.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.
- package/CHANGELOG.md +277 -0
- package/README.md +205 -105
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +2 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +1 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +61 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +1 -0
- package/dist/backends/aci-standby-pool/index.d.ts +2 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +36 -1
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/__tests__/hardening.test.d.ts +2 -0
- package/dist/backends/docker/__tests__/hardening.test.d.ts.map +1 -0
- package/dist/backends/docker/__tests__/hardening.test.js +32 -0
- package/dist/backends/docker/__tests__/hardening.test.js.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +1 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +1 -1
- package/dist/backends/docker/index.d.ts +47 -3
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +144 -5
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +16 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +37 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.js +11 -3
- package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +10 -2
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +2 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.js +67 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +21 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +30 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js +7 -17
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/transport.test.js +11 -3
- package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts +20 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +70 -15
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/egress/__tests__/allowlist.test.d.ts +2 -0
- package/dist/egress/__tests__/allowlist.test.d.ts.map +1 -0
- package/dist/egress/__tests__/allowlist.test.js +85 -0
- package/dist/egress/__tests__/allowlist.test.js.map +1 -0
- package/dist/egress/__tests__/proxy.test.d.ts +2 -0
- package/dist/egress/__tests__/proxy.test.d.ts.map +1 -0
- package/dist/egress/__tests__/proxy.test.js +177 -0
- package/dist/egress/__tests__/proxy.test.js.map +1 -0
- package/dist/egress/allowlist.d.ts +40 -0
- package/dist/egress/allowlist.d.ts.map +1 -0
- package/dist/egress/allowlist.js +81 -0
- package/dist/egress/allowlist.js.map +1 -0
- package/dist/egress/index.d.ts +4 -0
- package/dist/egress/index.d.ts.map +1 -0
- package/dist/egress/index.js +3 -0
- package/dist/egress/index.js.map +1 -0
- package/dist/egress/proxy.d.ts +90 -0
- package/dist/egress/proxy.d.ts.map +1 -0
- package/dist/egress/proxy.js +194 -0
- package/dist/egress/proxy.js.map +1 -0
- package/dist/index.d.ts +101 -190
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -80
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +18 -39
- package/dist/index.test.js.map +1 -1
- package/package.json +5 -4
- package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +69 -0
- package/src/backends/aci-standby-pool/index.ts +42 -1
- package/src/backends/docker/__tests__/hardening.test.ts +43 -0
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +1 -1
- package/src/backends/docker/index.ts +210 -12
- package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +48 -0
- package/src/backends/firecracker/__tests__/backend.test.ts +76 -65
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +10 -2
- package/src/backends/firecracker/__tests__/egress-policy.test.ts +91 -0
- package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +31 -0
- package/src/backends/firecracker/__tests__/protocol.test.ts +8 -23
- package/src/backends/firecracker/__tests__/transport.test.ts +11 -3
- package/src/backends/firecracker/index.ts +76 -13
- package/src/egress/__tests__/allowlist.test.ts +103 -0
- package/src/egress/__tests__/proxy.test.ts +212 -0
- package/src/egress/allowlist.ts +82 -0
- package/src/egress/index.ts +7 -0
- package/src/egress/proxy.ts +294 -0
- package/src/index.test.ts +19 -41
- package/src/index.ts +170 -259
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,282 @@
|
|
|
1
1
|
# @namzu/sandbox
|
|
2
2
|
|
|
3
|
+
## 2.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4be54ca: Three sandbox and delegation gaps, all of the same kind: something declared,
|
|
8
|
+
threaded through types, and never driven.
|
|
9
|
+
|
|
10
|
+
**`SandboxExecOptions.signal` now works — on the backend where it can.** The
|
|
11
|
+
option was declared, documented and exported, with a docstring stating that
|
|
12
|
+
without it "a Stop could only ever abandon the _wait_ — the sandboxed process
|
|
13
|
+
kept running after the host believed the run had been cancelled". Every
|
|
14
|
+
backend dropped it, so that is exactly what happened. The local sandbox now
|
|
15
|
+
merges the caller's signal with the call's own deadline and hands the result to
|
|
16
|
+
`spawn`, so the child actually dies; a cancelled run is no longer reported as
|
|
17
|
+
`timedOut`, because a run someone stopped did not run too long, and telling the
|
|
18
|
+
model otherwise invites a retry with a bigger budget.
|
|
19
|
+
|
|
20
|
+
The remote backends still ignore it, now explicitly and with the reason in the
|
|
21
|
+
source. Their wire has no cancel op, so aborting the request would abandon the
|
|
22
|
+
wait while the command kept running — the original failure, wearing the
|
|
23
|
+
appearance of a fix. `SandboxExecOptions.signal` documents which backends
|
|
24
|
+
honour it.
|
|
25
|
+
|
|
26
|
+
**`ls` respects the sandbox.** It read the host through `node:fs` and named
|
|
27
|
+
`context.sandbox` nowhere, in the one builtin whose whole job is telling the
|
|
28
|
+
model what exists — so under a container or microVM backend the model's picture
|
|
29
|
+
of the filesystem was the host's. Its paths were host-relative too, while
|
|
30
|
+
`read`, `grep` and `glob` all resolve inside the sandbox, so an ls-to-read
|
|
31
|
+
handoff either failed or opened a different file than the one listed. `glob`
|
|
32
|
+
had the identical defect, was fixed, and its fix notes that "every sibling
|
|
33
|
+
builtin already remembers this branch"; this was the sibling that did not.
|
|
34
|
+
|
|
35
|
+
One behaviour difference worth knowing: inside a sandbox, directories are
|
|
36
|
+
derived from file paths, because `listFiles` reports files. An empty directory
|
|
37
|
+
is invisible there.
|
|
38
|
+
|
|
39
|
+
**The `Agent` tool's header described a design that no longer exists.** It told
|
|
40
|
+
readers to prefer `Agent` because `create_task` was a non-blocking trio driven
|
|
41
|
+
by notification callbacks. `create_task` blocks and returns the worker's output
|
|
42
|
+
as its own result, and `continue_task` / `cancel_task` are not registered at
|
|
43
|
+
all. The two tools are separated by how much of the coordinator surface they
|
|
44
|
+
bring, not by timing.
|
|
45
|
+
|
|
46
|
+
## 2.0.0
|
|
47
|
+
|
|
48
|
+
### Major Changes
|
|
49
|
+
|
|
50
|
+
- 935b8f3: **Breaking:** `@namzu/sandbox` declares only the backends it has.
|
|
51
|
+
|
|
52
|
+
Four of the shapes this package offered could type-check and then throw: a `process` tier, a `passthrough` tier, and two adapters to third-party managed schedulers, none of which was ever written. Each demanded required configuration for a call that was never made — the `self-hosted` microvm arm went further and required three fields belonging to a local-daemon path that does not exist, while the two fields the working path needs were optional. So the only configuration that ran had to supply three values nothing reads, and omitting the two that matter compiled its way to a runtime throw.
|
|
53
|
+
|
|
54
|
+
`SandboxTier` is now `container | microvm`. `MicroVMBackendConfig` is one shape whose `orchestratorEndpoint` and `getToken` are required. `SandboxBackendNotImplementedError` stays exported and thrown: a JS host that invents a tier gets a named refusal rather than a provider that confines nothing.
|
|
55
|
+
|
|
56
|
+
The `sandbox.platform` health check now asks the provider what this host enforces instead of answering from a table keyed on the OS name. That table had drifted both ways — it called the Linux probe unimplemented long after the provider began probing real flags, and it told a Windows operator that sandboxing is "not supported", which is true of the in-process tier and silent about the container tier that runs there. Every non-passing result now names the missing controls and what to do about them.
|
|
57
|
+
|
|
58
|
+
`SANDBOX_ISOLATION_CONTROLS` is exported as a value from `@namzu/sdk`. It was reachable only through `export type *`, so importing it type-checked and then failed on the first line of a built binary.
|
|
59
|
+
|
|
60
|
+
### Minor Changes
|
|
61
|
+
|
|
62
|
+
- 935b8f3: The two gaps that were deferred as needing their own design session.
|
|
63
|
+
|
|
64
|
+
**A question raised inside a tool is now durable, and the answer reaches
|
|
65
|
+
the tool that asked.** `ask_user_question` parked through the raw handler
|
|
66
|
+
under a synthetic `cp_question_<toolUseId>` id that was never written
|
|
67
|
+
anywhere. The checkpoint did not exist: nothing on disk said a human owed
|
|
68
|
+
this run an answer, the pending-checkpoint lookup could never return it,
|
|
69
|
+
and a remote host could not even _observe_ the question except through the
|
|
70
|
+
in-process callback. Kill the process while somebody is looking at the card
|
|
71
|
+
and the answer could never be applied — the restore path stripped the whole
|
|
72
|
+
assistant turn, discarding work that sibling tools in the same batch had
|
|
73
|
+
already finished, and re-billed the turn.
|
|
74
|
+
|
|
75
|
+
The park is now a real checkpoint, with `user_question_asked` /
|
|
76
|
+
`user_question_answered` on the event stream, `question.asked` /
|
|
77
|
+
`question.answered` on the SSE wire, and an `input-required` A2A status —
|
|
78
|
+
the same surfaces a tool-review park has always had.
|
|
79
|
+
|
|
80
|
+
The re-entry contract was the deferred half, and it turned out to reuse
|
|
81
|
+
machinery that already exists. A question checkpoint is written
|
|
82
|
+
mid-execution, so it holds the assistant turn with its `tool_use` blocks
|
|
83
|
+
unanswered — the same shape a tool-review park leaves. Re-executing that
|
|
84
|
+
batch is _how_ the asking tool gets re-entered; a carried-answer registry
|
|
85
|
+
is what makes the re-entry return the recorded answer instead of parking a
|
|
86
|
+
second time; and every sibling that already completed is answered from the
|
|
87
|
+
transcript by the crash-resume recovery, so nothing runs twice. An answer
|
|
88
|
+
that does not name a call in this turn is refused rather than delivered to
|
|
89
|
+
whichever tool now holds that slot.
|
|
90
|
+
|
|
91
|
+
**The egress policy has a boundary to be enforced at.** Two of its four
|
|
92
|
+
shapes were honourable nowhere: the container backend refused a host
|
|
93
|
+
allowlist outright because it had nothing to filter through, so `deny-all`
|
|
94
|
+
and `allow-all` were the whole spectrum — all or nothing.
|
|
95
|
+
|
|
96
|
+
`EgressProxy` enforces the other two. Matching has exactly two forms —
|
|
97
|
+
exact host, and `.example.com` for a domain and its subdomains — and
|
|
98
|
+
substring is deliberately not one of them: `host.includes(entry)` would
|
|
99
|
+
admit `example.com.attacker.net`, and plain suffix matching would admit
|
|
100
|
+
`notexample.com`. A policy that cannot be read denies, because an allowlist
|
|
101
|
+
that fails open is not an allowlist. A request addressed to the proxy
|
|
102
|
+
itself is refused rather than forwarded — found by a test that hung instead
|
|
103
|
+
of failing, which is exactly the shape that failure takes in production.
|
|
104
|
+
|
|
105
|
+
`Sandbox.setNetworkPolicy` narrows or widens a **live** sandbox, so "clone
|
|
106
|
+
with a token, then drop to deny-all before running untrusted build scripts"
|
|
107
|
+
is expressible; it was not, because the policy was frozen at provider
|
|
108
|
+
construction. A backend that cannot enforce it throws.
|
|
109
|
+
|
|
110
|
+
And `brokeredCredentials` settles where the token lives. Any credential the
|
|
111
|
+
agent needed to reach an allowed host had to be inside the sandbox, in the
|
|
112
|
+
environment, readable by the untrusted code it is meant to be isolated from
|
|
113
|
+
— via `/proc/self/environ`, or via a prompt injection that exfiltrates it
|
|
114
|
+
over the very egress the policy permits. The real value is now held
|
|
115
|
+
host-side and applied at the boundary, scoped per host: a credential
|
|
116
|
+
attached to every request is a credential handed to whichever host the
|
|
117
|
+
agent was talked into contacting.
|
|
118
|
+
|
|
119
|
+
One limit, stated rather than hidden: a credential cannot be injected into
|
|
120
|
+
a CONNECT tunnel, because reading those bytes would mean terminating TLS
|
|
121
|
+
with a CA the sandbox trusts — a strictly larger risk than the one being
|
|
122
|
+
mitigated. A workload that needs brokering speaks plain HTTP to the proxy
|
|
123
|
+
and lets it upgrade upstream. The allowlist is enforced on CONNECT either
|
|
124
|
+
way, since the target names the host in clear text.
|
|
125
|
+
|
|
126
|
+
- 935b8f3: Two blast-radius controls that were accepted and silently dropped.
|
|
127
|
+
|
|
128
|
+
**The standby-pool backend discarded every per-sandbox control.** Its create
|
|
129
|
+
function took its options parameter underscore-prefixed and never read it,
|
|
130
|
+
and the request body it assembled carried no resources, no environment
|
|
131
|
+
variables and no network policy — while the provider faithfully assembled
|
|
132
|
+
all of them first. A host that set `deny-all` and a 512 MB cap got full
|
|
133
|
+
outbound network, no memory cap and no process cap, with no error and no
|
|
134
|
+
warning, from the same call shape that **is** enforced on the sibling
|
|
135
|
+
container backend. Switching backends silently removed the controls.
|
|
136
|
+
|
|
137
|
+
The claim API rejects every property override except a config map, so these
|
|
138
|
+
genuinely cannot ride through per sandbox — which makes refusing the honest
|
|
139
|
+
fix rather than a missing feature. It now throws, naming every field it
|
|
140
|
+
cannot honour rather than the first, and saying where the limits do belong
|
|
141
|
+
(the container group profile the pool is built from). namzu already held
|
|
142
|
+
this norm next door, with the rationale in that backend's own comment: a
|
|
143
|
+
policy accepted and quietly ignored is worse than one that is refused.
|
|
144
|
+
|
|
145
|
+
**`allow-all` and `resolver` encoded identically on the microVM backend.**
|
|
146
|
+
Both resolved to an omitted allowlist, so one encoding carried two opposite
|
|
147
|
+
intentions — and the `resolve()` callback that produces a tenant-scoped list
|
|
148
|
+
was never invoked anywhere in the repo. Whichever way the orchestrator reads
|
|
149
|
+
an omitted field, one of the two was always mis-enforced, and the one that
|
|
150
|
+
failed **open** was the one whose entire purpose is restriction.
|
|
151
|
+
|
|
152
|
+
Each variant now has its own encoding: `allow-all` omits, `deny-all` sends
|
|
153
|
+
an explicitly empty list, `static` forwards its hosts, and `resolver` calls
|
|
154
|
+
`resolve()` and forwards the result — including an empty result, which is a
|
|
155
|
+
real deny-all and not an absence. The switch is exhaustive, so a new variant
|
|
156
|
+
fails to compile rather than falling through to unrestricted, and a resolver
|
|
157
|
+
that throws propagates instead of degrading to open.
|
|
158
|
+
|
|
159
|
+
The README's backend-by-policy table was wrong in both directions and is now
|
|
160
|
+
accurate. Neither backend had a test directory; both do now.
|
|
161
|
+
|
|
162
|
+
### Patch Changes
|
|
163
|
+
|
|
164
|
+
- 935b8f3: Four defects an adversarial audit confirmed
|
|
165
|
+
|
|
166
|
+
**A task could be created and then never found again.** `DiskTaskStore` writes under the run that created it and read only under the store's default run, so every lookup missed as soon as the two differed — the normal case, since the task tools are built with the live run id while a long-lived host constructs the store once with a fixed default. `create` succeeded, `list` succeeded, and `update`, `delete`, `claim` and every dependency link answered "not found" for a task the caller could see. The in-memory store keys by task id alone, which is why nothing caught it.
|
|
167
|
+
|
|
168
|
+
**A sub-agent's token reservation was never returned.** The debit at spawn reserves headroom so siblings cannot each be promised the same tokens, and nothing credited back the unused part — so a pool shrank by the full allocation on every spawn no matter what the child used. At a half-pool fraction, ten delegations left a parent with a thousandth of its budget and the next spawn was refused for a budget that had barely been spent. The debit also ran before provisioning, so a spawn rejected for capacity still burned its allocation — the one state change the comment there promised would not happen.
|
|
169
|
+
|
|
170
|
+
**A failed sandbox create leaked a proxy holding real credentials.** The egress proxy starts before the container and its only close was in `destroy()`, which a create that never returned can never reach. Every failure in between left a listening server on loopback stamping credential headers, plus a retained event-loop handle, one per retry.
|
|
171
|
+
|
|
172
|
+
**A remembered approval could overrule the operator.** The grant check ran before the verification gate and returned, so a remembered approval skipped the gate entirely — and because a tool-scoped grant matches any arguments, approving one harmless invocation authorised every other one, past a rule written to stop exactly that. The gate now runs first, and a grant can satisfy a review but never a denial.
|
|
173
|
+
|
|
174
|
+
- 935b8f3: Stop dropping tool-failure status on Bedrock, and stop accepting a sandbox
|
|
175
|
+
egress policy this backend cannot enforce.
|
|
176
|
+
|
|
177
|
+
- **Bedrock** flattened every failed tool result into an ordinary success.
|
|
178
|
+
The executor computed `isError`, the SSE and A2A bridges carried it, and
|
|
179
|
+
the driver dropped it — even though Converse has a first-class
|
|
180
|
+
`toolResult.status`. The model's trained tool-failure recovery path keys
|
|
181
|
+
off that field, so namzu was relying on prose formatting to convey "that
|
|
182
|
+
call failed".
|
|
183
|
+
|
|
184
|
+
Scope note: the five OpenAI-shaped drivers are NOT affected, because
|
|
185
|
+
Chat Completions has no error field on a tool message at all. The error
|
|
186
|
+
reaches those models inside the result text, which is the only channel
|
|
187
|
+
the protocol has.
|
|
188
|
+
|
|
189
|
+
- **Docker sandbox** accepted `EgressPolicy` and silently ignored it. A
|
|
190
|
+
host that set `deny-all` believed the container had no network and it had
|
|
191
|
+
whatever `network` was configured. A security control that is accepted
|
|
192
|
+
and ignored is worse than one that does not exist. Now: `deny-all` maps
|
|
193
|
+
to `--network none` (which Docker enforces natively), `allow-all` keeps
|
|
194
|
+
the configured network, and `static` / `resolver` **throw** — this
|
|
195
|
+
backend has no proxy to filter hosts through, and downgrading a
|
|
196
|
+
restrictive policy to "allow everything" is exactly the failure worth
|
|
197
|
+
refusing.
|
|
198
|
+
|
|
199
|
+
- **Docker sandbox** containers now run with `--cap-drop=ALL` and
|
|
200
|
+
`--security-opt=no-new-privileges`, plus an opt-in `runAsUser`.
|
|
201
|
+
`CAP_DAC_OVERRIDE` alone walks past the read-only bind mounts the layout
|
|
202
|
+
sets up, and without `no-new-privileges` a setuid binary in the image
|
|
203
|
+
re-escalates.
|
|
204
|
+
|
|
205
|
+
- 935b8f3: Five places where namzu gave up, or claimed to recover, too early.
|
|
206
|
+
|
|
207
|
+
**A transient failure now pauses instead of failing.** A 503 that survived
|
|
208
|
+
every in-turn recovery — retry with jitter, the one-shot compaction relief,
|
|
209
|
+
mid-stream salvage — settled the run as `failed`, identically to a bad API
|
|
210
|
+
key. The host could not tell them apart, and recovering meant knowing about
|
|
211
|
+
checkpoints and driving replay itself. The state was never the problem:
|
|
212
|
+
checkpoints are written every iteration by default and the failed run is
|
|
213
|
+
persisted with full messages. Only the settle and the signal were missing.
|
|
214
|
+
|
|
215
|
+
A retryable failure with a checkpoint to resume from now emits `run_paused`
|
|
216
|
+
naming that checkpoint, leaves the span OK rather than ERROR, and sets
|
|
217
|
+
`stopReason: 'paused'`. Both conditions are required — pausing on a
|
|
218
|
+
permanent error would invite a resume that cannot work, and pausing with
|
|
219
|
+
nowhere to resume from produces a run nobody can ever pick up.
|
|
220
|
+
|
|
221
|
+
**A forced compaction pass can no longer decline to do anything.** A forced
|
|
222
|
+
pass runs because the provider _rejected_ the prompt as too long, and two
|
|
223
|
+
things let it treat that as advisory. It re-applied the chars/4 estimate
|
|
224
|
+
after clearing stale tool results — the estimate the provider had just
|
|
225
|
+
refuted — and returned early if that said the context was fine. And relief
|
|
226
|
+
reported success on ANY positive shed, so clearing one short result counted
|
|
227
|
+
and the retry burned a whole model call to be told the same thing. The
|
|
228
|
+
early return is now force-gated, and a shed has to clear a floor (a
|
|
229
|
+
fraction of the prompt, at least a couple of thousand characters) to count.
|
|
230
|
+
|
|
231
|
+
Separately, the relief latch is per **stuck point**, not per run. It exists
|
|
232
|
+
to stop a second overflow immediately after a successful compaction from
|
|
233
|
+
looping; as a run-scoped flag it meant one relief at iteration 3 disarmed
|
|
234
|
+
the mechanism for the rest of the run, leaving iteration 40 to die with
|
|
235
|
+
obvious moves left. It is now cleared by a turn that actually succeeded.
|
|
236
|
+
|
|
237
|
+
**An eval case can no longer hang the suite.** `executeCase` was a bare
|
|
238
|
+
await, so a `run` closure that never settled blocked its worker and
|
|
239
|
+
`runExperiment` never returned — no report, no partial results, nothing to
|
|
240
|
+
read. `ExperimentConfig.timeoutMs` bounds a case and hands `run` an
|
|
241
|
+
`AbortSignal` as a third argument; a timed-out case is reported and the
|
|
242
|
+
suite continues, exactly like a case that threw, with its real elapsed time
|
|
243
|
+
rather than zero. Unset means no deadline, which is today's behaviour. The
|
|
244
|
+
documented path already inherits deadlines from the runtime it drives; this
|
|
245
|
+
covers what those cannot see — a closure that does not go through
|
|
246
|
+
`query()`, and a mid-iteration provider stall.
|
|
247
|
+
|
|
248
|
+
**A malformed content block is named, not smuggled.** One driver built an
|
|
249
|
+
image block by calling `String()` on whatever `data` and `mediaType`
|
|
250
|
+
happened to be, behind only a truthiness check — so a non-string `data`
|
|
251
|
+
became the literal `"[object Object]"` as the base64 payload, and the wire
|
|
252
|
+
rejected the whole request with nothing naming the block at fault. That is
|
|
253
|
+
reachable: a remote tool result is cast without validation on the way in.
|
|
254
|
+
It now type- and media-type-guards and degrades to a named placeholder,
|
|
255
|
+
matching the sibling driver that already did, and without inlining the
|
|
256
|
+
payload it refused to send.
|
|
257
|
+
|
|
258
|
+
**Failures have somewhere to grow remediation.** A stale API key surfaced
|
|
259
|
+
as whatever prose the vendor SDK happened to write: no id to grep in logs,
|
|
260
|
+
no instruction on what to change, and no growth point — a newly-observed
|
|
261
|
+
failure shape could only be given curated copy by editing the classifier.
|
|
262
|
+
`explainError` adds an ordered, id-keyed rule layer matching on
|
|
263
|
+
**structural** signals (code, status, an explicit hint) rather than
|
|
264
|
+
volatile vendor prose. `run_failed` carries the result as `explanation`;
|
|
265
|
+
`withHint(err, '…')` lets a throw site attach what only it knows, and
|
|
266
|
+
outranks every generic rule. It returns `null` when no rule claims the
|
|
267
|
+
failure — inventing advice for something uncharacterised is worse than
|
|
268
|
+
saying nothing, because it sends the reader somewhere specific and wrong.
|
|
269
|
+
The container backend's readiness, port-mapping and worker-fetch failures
|
|
270
|
+
now carry hints.
|
|
271
|
+
|
|
272
|
+
- 935b8f3: Close every open code-scanning finding
|
|
273
|
+
|
|
274
|
+
**Breaking:** `LocalExecutionContext.executeCommand` no longer interprets its arguments as shell syntax. `shell` defaulted to `true`, and spawning with a shell re-joins the command and its argument array into a single `sh -c` string — so every metacharacter inside an argument became syntax. An `args` array reads argv-safe and was not. The default is now `false`; `shell: true` remains available where a caller genuinely wants a pipeline. A consumer passing `"ls -la"` as one command string, or relying on glob expansion without asking for a shell, must now pass `shell: true`.
|
|
275
|
+
|
|
276
|
+
**A sandbox timeout is bounded, and an out-of-range one is refused.** The bash tool's `timeout` argument is a number the model writes, with no ceiling of its own, and it reached both sandbox transports unmodified — so a single call could pin a container or a guest for as long as the platform's timer honours. Both transports now refuse a non-finite, non-positive or over-thirty-minute request rather than clamping it: running under a deadline the caller never chose, and never learns about, is the "accepted and silently not applied" failure this codebase treats as worse than not offering the control at all.
|
|
277
|
+
|
|
278
|
+
**Seven quadratic-backtracking regexes are now linear scans**, each on a path an attacker can reach: shell output the agent captured, a tenant-supplied connector URL, a host-supplied workspace root, a model completion, and three endpoint strings that cross the same trust boundary. The worst measured over thirty seconds on a single pathological input, on a shared event loop. Three of the seven were not flagged by the scanner — the same pattern, the same boundary — and were fixed with the rest rather than left to be rediscovered.
|
|
279
|
+
|
|
3
280
|
## 1.1.0
|
|
4
281
|
|
|
5
282
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,73 +1,98 @@
|
|
|
1
1
|
# @namzu/sandbox
|
|
2
2
|
|
|
3
|
-
Pluggable
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
|
12
|
-
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
- **
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`@namzu/sandbox` exposes all four as separate tiers so the host
|
|
51
|
-
picks the trust boundary that matches its threat model.
|
|
52
|
-
|
|
53
|
-
**What we deliberately do NOT build** is yet-another Firecracker
|
|
54
|
-
scheduler. That is E2B's and Fly's entire product, and writing
|
|
55
|
-
our own would be a years-long detour. We adapt to theirs and
|
|
56
|
-
reserve the `self-hosted` option for hosts that need to own the
|
|
57
|
-
scheduler for compliance or air-gap reasons.
|
|
3
|
+
Pluggable containment for [`@namzu/sdk`](../sdk). Two tiers, one
|
|
4
|
+
`SandboxProvider` surface: swapping the trust boundary is a config
|
|
5
|
+
change, not an integration rewrite.
|
|
6
|
+
|
|
7
|
+
## Tiers
|
|
8
|
+
|
|
9
|
+
| Tier | Trust boundary | Use it when | Cold start |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| `container` (`docker`) | Kernel namespaces + seccomp, tmpfs workdir, no network unless asked | The model is yours and the user is your customer | 0.5–2s |
|
|
12
|
+
| `container` (`runsc`) | A userspace kernel serves the guest's syscalls | Same tenancy, stronger boundary, commodity Linux without nested virtualization | container start + ~100ms |
|
|
13
|
+
| `container` (`aci-standby-pool`) | The managed provider's isolation host | You cannot reach a container daemon, and a ~1.5s claim is acceptable | ~1.5s from a warm pool |
|
|
14
|
+
| `microvm` (`self-hosted`) | Hardware virtualization | The prompt itself is the attacker | <300ms, resuming a snapshot |
|
|
15
|
+
|
|
16
|
+
Every shape above is implemented. That is worth stating because it
|
|
17
|
+
used not to be: this package once advertised four tiers and six
|
|
18
|
+
backends, and four of those shapes type-checked and then threw. A
|
|
19
|
+
configuration that compiles and cannot run teaches the reader the
|
|
20
|
+
wrong thing about what is here, so the ones that were never built
|
|
21
|
+
are gone rather than pending.
|
|
22
|
+
|
|
23
|
+
## Choosing a tier
|
|
24
|
+
|
|
25
|
+
The question is not which tier is strongest, it is **who you are
|
|
26
|
+
defending against**.
|
|
27
|
+
|
|
28
|
+
- **The prompt is the attacker** — untrusted input reaching code
|
|
29
|
+
execution, or tenants who must not reach each other. Take the
|
|
30
|
+
hardware boundary: a guest kernel per task is the only mainstream
|
|
31
|
+
primitive whose escape surface is the hypervisor rather than a
|
|
32
|
+
shared kernel, and snapshot-resume makes starting one cost
|
|
33
|
+
milliseconds rather than seconds.
|
|
34
|
+
- **The tenant is trusted, the code is not** — your own model, your
|
|
35
|
+
own users, arbitrary tool calls. A userspace kernel is the good
|
|
36
|
+
trade: near-zero cold start on commodity Linux, at the cost that a
|
|
37
|
+
bug in that kernel is a tenant escape where a hypervisor bug
|
|
38
|
+
usually is not.
|
|
39
|
+
- **Single tenant, or tenants who already trust each other** —
|
|
40
|
+
namespaces and a seccomp profile are adequate, and they run
|
|
41
|
+
everywhere with no special runtime.
|
|
42
|
+
- **One operator on their own machine** — the threat is the agent
|
|
43
|
+
reading `~/.ssh`, not tenant A reading tenant B. That is the SDK's
|
|
44
|
+
local sandbox provider, not this package.
|
|
45
|
+
|
|
46
|
+
namzu does not build a microVM scheduler. Starting guests fast and
|
|
47
|
+
safely is an entire product on its own, and the boundary a guest
|
|
48
|
+
gives is the same whoever started it — so the microvm tier is an
|
|
49
|
+
interface to a scheduler, and the one it speaks to is namzu's own.
|
|
58
50
|
|
|
59
51
|
## Cloud portability
|
|
60
52
|
|
|
61
|
-
The interface
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
that's the host's call, not the SDK's.
|
|
53
|
+
The interface carries no cloud in it. The container tier over a
|
|
54
|
+
local daemon runs anywhere; the managed-pool runtime and the microvm
|
|
55
|
+
tier need infrastructure the host chooses. Picking a stronger
|
|
56
|
+
boundary may imply picking different infrastructure — that is the
|
|
57
|
+
host's call, not the SDK's.
|
|
67
58
|
|
|
68
59
|
## Egress allowlist policy
|
|
69
60
|
|
|
70
|
-
Every backend
|
|
61
|
+
Every backend accepts the same `EgressPolicy` shape, but they do **not**
|
|
62
|
+
all enforce every variant, and a backend that cannot enforce one now
|
|
63
|
+
throws instead of quietly ignoring it:
|
|
64
|
+
|
|
65
|
+
| Backend | `deny-all` | `allow-all` | `static` | `resolver` |
|
|
66
|
+
|---|---|---|---|---|
|
|
67
|
+
| `container:docker` | enforced (`--network none`) | enforced | **throws** — no proxy to filter through | **throws** |
|
|
68
|
+
| `container:standby-pool` | **throws** | **throws** | **throws** | **throws** |
|
|
69
|
+
| `microvm:firecracker` | enforced (empty allowlist) | enforced (no allowlist) | enforced | enforced — `resolve()` is called and its result forwarded |
|
|
70
|
+
|
|
71
|
+
Two rows carry the same lesson from opposite directions.
|
|
72
|
+
|
|
73
|
+
The docker row used to accept a restrictive policy and silently grant the
|
|
74
|
+
configured network, which is worse than not supporting the feature: the
|
|
75
|
+
host believes it is protected and stops looking. Refusing loudly is the
|
|
76
|
+
only honest option for a control the backend cannot implement.
|
|
77
|
+
|
|
78
|
+
The standby-pool row is the same failure found later. Its claim API rejects
|
|
79
|
+
every property override except a config map, so a memory cap, a process
|
|
80
|
+
cap, environment variables and an egress policy have nowhere to ride
|
|
81
|
+
through — and all four were accepted and dropped. Set them on the container
|
|
82
|
+
group profile the pool is built from; the backend now refuses them per
|
|
83
|
+
sandbox rather than pretending.
|
|
84
|
+
|
|
85
|
+
The firecracker `resolver` column is a third variant of it. `allow-all` and
|
|
86
|
+
`resolver` both used to encode as an omitted allowlist, so one encoding
|
|
87
|
+
carried two opposite intentions and the callback that produces the
|
|
88
|
+
tenant-scoped list was never called anywhere. Whichever way the
|
|
89
|
+
orchestrator reads an omitted field, one of the two was always
|
|
90
|
+
mis-enforced — and the one that failed **open** was the one whose entire
|
|
91
|
+
purpose is restriction. Each variant now has its own encoding: `allow-all`
|
|
92
|
+
omits, `deny-all` sends an explicitly empty list, `resolver` sends what
|
|
93
|
+
`resolve()` returned.
|
|
94
|
+
|
|
95
|
+
The shape itself:
|
|
71
96
|
|
|
72
97
|
```ts
|
|
73
98
|
type EgressPolicy =
|
|
@@ -86,63 +111,138 @@ avoids the "where does the resolver get its context from"
|
|
|
86
111
|
plumbing problem; the host owns the closure, the SDK runtime
|
|
87
112
|
doesn't have to forward identity through `provider.create`.
|
|
88
113
|
|
|
114
|
+
## Container confinement (`container:docker`)
|
|
115
|
+
|
|
116
|
+
Every container is launched with:
|
|
117
|
+
|
|
118
|
+
- `--cap-drop=ALL` — `CAP_DAC_OVERRIDE` alone walks past the read-only bind
|
|
119
|
+
mounts the layout sets up, so the default capability set makes the mount
|
|
120
|
+
layout advisory rather than enforced.
|
|
121
|
+
- `--security-opt=no-new-privileges` — without it a setuid binary in the
|
|
122
|
+
image re-escalates after the drop.
|
|
123
|
+
- `--network none` by default (see the egress table above).
|
|
124
|
+
|
|
125
|
+
There is deliberately **no re-add list** for capabilities. A workload that
|
|
126
|
+
genuinely needs one should say so somewhere a reviewer sees it, not inherit
|
|
127
|
+
it from a default.
|
|
128
|
+
|
|
129
|
+
`runAsUser` (`--user`) is opt-in rather than defaulted, because the correct
|
|
130
|
+
uid depends on the image's own filesystem ownership and forcing one breaks
|
|
131
|
+
every image that expects root at startup. Set it whenever the image
|
|
132
|
+
supports a non-root user — a container running as root is one bind-mount
|
|
133
|
+
misconfiguration away from writing the host.
|
|
134
|
+
|
|
89
135
|
## Status
|
|
90
136
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`SandboxBackendNotImplementedError` until backends land.
|
|
98
|
-
- ⏳ **P3.1** — `container:docker` backend. Universal local-dev
|
|
99
|
-
default; ships first.
|
|
100
|
-
- ⏳ **P3.2** — `EgressPolicy` plumbing + reference egress proxy
|
|
101
|
-
(compass-platform pattern: HTTP CONNECT tunnel + JWT-claim
|
|
102
|
-
allowlist).
|
|
103
|
-
- ⏳ **P3.3** — `microvm:e2b` and `microvm:fly-machines` adapters.
|
|
104
|
-
Phase 2 production tier.
|
|
105
|
-
- ⏳ **P3.4** — `process` backend (Anthropic sandbox-runtime
|
|
106
|
-
adapter — bubblewrap/Seatbelt).
|
|
107
|
-
- ⏳ **P3.5** — `container:runsc` (gVisor) and
|
|
108
|
-
`microvm:self-hosted` (firecracker-containerd). Phase 3
|
|
109
|
-
adversarial-multi-tenant.
|
|
110
|
-
|
|
111
|
-
The interface here is what every backend implements; the staged
|
|
112
|
-
rollout is purely about turning each tier on, not about reshaping
|
|
113
|
-
the contract.
|
|
114
|
-
|
|
115
|
-
## Usage (post-implementation)
|
|
137
|
+
Every backend this package declares is implemented, and
|
|
138
|
+
`createSandboxProvider` refuses anything else BY NAME at construction
|
|
139
|
+
rather than handing back a provider that confines nothing — so a
|
|
140
|
+
mistake surfaces while the host is wiring, not mid-run.
|
|
141
|
+
|
|
142
|
+
## Usage
|
|
116
143
|
|
|
117
144
|
```ts
|
|
118
145
|
import { createSandboxProvider } from '@namzu/sandbox'
|
|
119
146
|
|
|
120
|
-
//
|
|
121
|
-
const
|
|
147
|
+
// A container per task, on a local daemon. Runs anywhere.
|
|
148
|
+
const contained = createSandboxProvider({
|
|
122
149
|
backend: { tier: 'container', runtime: 'docker', image: 'namzu-worker:latest' },
|
|
123
|
-
|
|
150
|
+
layout,
|
|
151
|
+
defaultEgress: { kind: 'static', allowedHosts: ['api.example.com'] },
|
|
124
152
|
})
|
|
125
153
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
kind: 'resolver',
|
|
131
|
-
resolve: async () => fetchAllowlistForTenant(tenantId),
|
|
132
|
-
},
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
// Phase 3: adversarial multi-tenant, self-hosted Firecracker on KVM
|
|
136
|
-
const sandbox = createSandboxProvider({
|
|
154
|
+
// A guest per task, when the prompt itself is the attacker. The
|
|
155
|
+
// allowlist is resolved per tenant, so the boundary is not fixed at
|
|
156
|
+
// construction.
|
|
157
|
+
const virtualized = createSandboxProvider({
|
|
137
158
|
backend: {
|
|
138
159
|
tier: 'microvm',
|
|
139
160
|
service: 'self-hosted',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
161
|
+
orchestratorEndpoint: 'https://sandbox-control.internal',
|
|
162
|
+
getToken: async () => mintOrchestratorBearer(),
|
|
163
|
+
template: 'golden-rev-7',
|
|
164
|
+
},
|
|
165
|
+
defaultEgress: {
|
|
166
|
+
kind: 'resolver',
|
|
167
|
+
resolve: async () => fetchAllowlistForTenant(tenantId),
|
|
143
168
|
},
|
|
144
169
|
})
|
|
145
170
|
|
|
146
171
|
// Wire into drainQuery / agent run config:
|
|
147
|
-
// sandboxProvider:
|
|
172
|
+
// sandboxProvider: contained
|
|
148
173
|
```
|
|
174
|
+
|
|
175
|
+
## The egress boundary
|
|
176
|
+
|
|
177
|
+
An egress policy could be *declared* long before it could be *enforced*.
|
|
178
|
+
Only two of its four shapes were honourable anywhere: this backend refused
|
|
179
|
+
a host allowlist outright because it had nothing to filter through, and
|
|
180
|
+
only the microVM backend forwarded one. `deny-all` and `allow-all` were
|
|
181
|
+
the whole spectrum a container-tier sandbox could express — all or nothing.
|
|
182
|
+
|
|
183
|
+
`EgressProxy` is the boundary the other two shapes are enforced at. When a
|
|
184
|
+
policy is `static` or `resolver`, the backend starts one on host loopback
|
|
185
|
+
and points the container at it through `HTTP_PROXY` / `HTTPS_PROXY` (both
|
|
186
|
+
spellings, because tooling is split between them and a workload reading
|
|
187
|
+
only the missing one would bypass the boundary while looking like the
|
|
188
|
+
policy worked).
|
|
189
|
+
|
|
190
|
+
Matching has exactly two forms, and substring is deliberately not one of
|
|
191
|
+
them:
|
|
192
|
+
|
|
193
|
+
| Entry | Matches |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `api.example.com` | that host only |
|
|
196
|
+
| `.example.com` | that domain and any subdomain |
|
|
197
|
+
|
|
198
|
+
`host.includes(entry)` is the obvious implementation and it is a hole: an
|
|
199
|
+
entry of `example.com` would admit `example.com.attacker.net`, a domain
|
|
200
|
+
the attacker owns. Plain suffix matching has the same hole without the
|
|
201
|
+
leading dot — `notexample.com` ends with `example.com` — which is why the
|
|
202
|
+
wildcard form requires it. Comparison ignores case and a trailing dot,
|
|
203
|
+
because DNS does and an allowlist that did not would be bypassable by
|
|
204
|
+
typing the host differently.
|
|
205
|
+
|
|
206
|
+
A policy that cannot be read **denies**. An allowlist that fails open is
|
|
207
|
+
not an allowlist.
|
|
208
|
+
|
|
209
|
+
### Changing the policy while the sandbox runs
|
|
210
|
+
|
|
211
|
+
`sandbox.setNetworkPolicy({ allowedHosts })` narrows or widens a live
|
|
212
|
+
sandbox. The shape this exists for — "clone with a token, then drop to
|
|
213
|
+
deny-all before running anything the repository contains" — was not
|
|
214
|
+
expressible at all: the policy was frozen at provider construction, so a
|
|
215
|
+
host had to build a second provider and a second sandbox and copy the work
|
|
216
|
+
across.
|
|
217
|
+
|
|
218
|
+
A backend that cannot enforce it **throws**. A network policy accepted and
|
|
219
|
+
not applied is worse than one never offered: the caller stops looking, and
|
|
220
|
+
the run proceeds believing it is confined.
|
|
221
|
+
|
|
222
|
+
### Credentials that never enter the sandbox
|
|
223
|
+
|
|
224
|
+
Any token the agent needed to reach an allowed host had to be inside the
|
|
225
|
+
container, in the environment — readable by the untrusted code it is meant
|
|
226
|
+
to be isolated from, via `/proc/self/environ`, or via a prompt injection
|
|
227
|
+
that exfiltrates it over the very egress the policy permits.
|
|
228
|
+
|
|
229
|
+
`brokeredCredentials` holds the real value host-side and stamps it on at
|
|
230
|
+
the boundary, scoped per host:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
brokeredCredentials: [
|
|
234
|
+
{ host: 'api.example.com', header: 'authorization', value: process.env.TOKEN! },
|
|
235
|
+
]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Per host, not globally: a credential attached to every request is a
|
|
239
|
+
credential handed to whichever host the agent was talked into contacting.
|
|
240
|
+
|
|
241
|
+
One honest limit. A credential **cannot** be injected into a CONNECT
|
|
242
|
+
tunnel — by the time those bytes reach the proxy they are encrypted, and
|
|
243
|
+
reading them would mean terminating TLS with a CA the sandbox trusts, which
|
|
244
|
+
would let the proxy read every byte the agent sends anywhere. That is a
|
|
245
|
+
strictly larger risk than the one being mitigated, so it is not built. A
|
|
246
|
+
workload that needs brokering speaks plain HTTP to the proxy and lets it
|
|
247
|
+
upgrade to HTTPS upstream. The allowlist is still enforced on CONNECT,
|
|
248
|
+
because the target names the host in clear text.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unenforceable-controls.test.d.ts","sourceRoot":"","sources":["../../../../src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts"],"names":[],"mappings":""}
|