@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +277 -0
  2. package/README.md +205 -105
  3. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +2 -0
  4. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +1 -0
  5. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +61 -0
  6. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +1 -0
  7. package/dist/backends/aci-standby-pool/index.d.ts +2 -1
  8. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  9. package/dist/backends/aci-standby-pool/index.js +36 -1
  10. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  11. package/dist/backends/docker/__tests__/hardening.test.d.ts +2 -0
  12. package/dist/backends/docker/__tests__/hardening.test.d.ts.map +1 -0
  13. package/dist/backends/docker/__tests__/hardening.test.js +32 -0
  14. package/dist/backends/docker/__tests__/hardening.test.js.map +1 -0
  15. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +1 -1
  16. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +1 -1
  17. package/dist/backends/docker/index.d.ts +47 -3
  18. package/dist/backends/docker/index.d.ts.map +1 -1
  19. package/dist/backends/docker/index.js +144 -5
  20. package/dist/backends/docker/index.js.map +1 -1
  21. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +16 -0
  22. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +1 -0
  23. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +37 -0
  24. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +1 -0
  25. package/dist/backends/firecracker/__tests__/backend.test.js +11 -3
  26. package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -1
  27. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +10 -2
  28. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -1
  29. package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +2 -0
  30. package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +1 -0
  31. package/dist/backends/firecracker/__tests__/egress-policy.test.js +67 -0
  32. package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +1 -0
  33. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +21 -0
  34. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +1 -0
  35. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +30 -0
  36. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +1 -0
  37. package/dist/backends/firecracker/__tests__/protocol.test.js +7 -17
  38. package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -1
  39. package/dist/backends/firecracker/__tests__/transport.test.js +11 -3
  40. package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -1
  41. package/dist/backends/firecracker/index.d.ts +20 -1
  42. package/dist/backends/firecracker/index.d.ts.map +1 -1
  43. package/dist/backends/firecracker/index.js +70 -15
  44. package/dist/backends/firecracker/index.js.map +1 -1
  45. package/dist/egress/__tests__/allowlist.test.d.ts +2 -0
  46. package/dist/egress/__tests__/allowlist.test.d.ts.map +1 -0
  47. package/dist/egress/__tests__/allowlist.test.js +85 -0
  48. package/dist/egress/__tests__/allowlist.test.js.map +1 -0
  49. package/dist/egress/__tests__/proxy.test.d.ts +2 -0
  50. package/dist/egress/__tests__/proxy.test.d.ts.map +1 -0
  51. package/dist/egress/__tests__/proxy.test.js +177 -0
  52. package/dist/egress/__tests__/proxy.test.js.map +1 -0
  53. package/dist/egress/allowlist.d.ts +40 -0
  54. package/dist/egress/allowlist.d.ts.map +1 -0
  55. package/dist/egress/allowlist.js +81 -0
  56. package/dist/egress/allowlist.js.map +1 -0
  57. package/dist/egress/index.d.ts +4 -0
  58. package/dist/egress/index.d.ts.map +1 -0
  59. package/dist/egress/index.js +3 -0
  60. package/dist/egress/index.js.map +1 -0
  61. package/dist/egress/proxy.d.ts +90 -0
  62. package/dist/egress/proxy.d.ts.map +1 -0
  63. package/dist/egress/proxy.js +194 -0
  64. package/dist/egress/proxy.js.map +1 -0
  65. package/dist/index.d.ts +101 -190
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +62 -80
  68. package/dist/index.js.map +1 -1
  69. package/dist/index.test.js +18 -39
  70. package/dist/index.test.js.map +1 -1
  71. package/package.json +5 -4
  72. package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +69 -0
  73. package/src/backends/aci-standby-pool/index.ts +42 -1
  74. package/src/backends/docker/__tests__/hardening.test.ts +43 -0
  75. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +1 -1
  76. package/src/backends/docker/index.ts +210 -12
  77. package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +48 -0
  78. package/src/backends/firecracker/__tests__/backend.test.ts +76 -65
  79. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +10 -2
  80. package/src/backends/firecracker/__tests__/egress-policy.test.ts +91 -0
  81. package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +31 -0
  82. package/src/backends/firecracker/__tests__/protocol.test.ts +8 -23
  83. package/src/backends/firecracker/__tests__/transport.test.ts +11 -3
  84. package/src/backends/firecracker/index.ts +76 -13
  85. package/src/egress/__tests__/allowlist.test.ts +103 -0
  86. package/src/egress/__tests__/proxy.test.ts +212 -0
  87. package/src/egress/allowlist.ts +82 -0
  88. package/src/egress/index.ts +7 -0
  89. package/src/egress/proxy.ts +294 -0
  90. package/src/index.test.ts +19 -41
  91. 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 sandbox provider for [`@namzu/sdk`](../sdk). Four tiers,
4
- each backed by the industrial-standard primitive for that
5
- deployment shape. Same `SandboxProvider` surface the SDK consumes
6
- across all of them — swapping tiers is a config change, not an
7
- integration rewrite.
8
-
9
- ## Tier matrix (2026 industrial standard)
10
-
11
- | Tier | Use case | Primitive | Cold-start | Local dev |
12
- |---|---|---|---|---|
13
- | `process` | Agent runs on the developer's own host (Claude Code-style "don't read `~/.ssh`") | bubblewrap (Linux/WSL2) or Seatbelt (macOS), via [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropic-experimental/sandbox-runtime) | Process spawn (~ms) | Native — no infra |
14
- | `container` (`docker`) | App in `docker compose` locally or single-tenant prod replica | OCI container, seccomp default profile, tmpfs workdir, no-network default | 0.5–2s | `docker compose up` |
15
- | `container` (`runsc`) | Trusted-tenant SaaS — what OpenAI Code Interpreter and [Modal](https://modal.com/blog/gvisor-savings-article) ship | Google [gVisor](https://gvisor.dev/docs) userspace kernel as Docker runtime | container start + ~100ms | Linux Docker only (no Docker Desktop on macOS) |
16
- | `microvm` (`e2b`) | Adversarial multi-tenant SaaS, Python-REPL workloads | Firecracker microVM via [E2B](https://e2b.dev/docs/sandbox) managed service | ~150ms (snapshot/restore) | E2B API key from any laptop |
17
- | `microvm` (`fly-machines`) | Adversarial multi-tenant SaaS, arbitrary tool-call workloads | Firecracker microVM via [Fly Machines](https://fly.io/docs/machines) | 250ms–1s | Fly API token from any laptop |
18
- | `microvm` (`self-hosted`) | Same threat model, host insists on owning the scheduler | [`firecracker-containerd`](https://github.com/firecracker-microvm/firecracker-containerd) on KVM-enabled Linux | <300ms with snapshot restore | Lima/Colima Linux VM on macOS |
19
- | `passthrough` | Tests and explicitly trusted environments | Direct host process — no isolation | n/a | n/a |
20
-
21
- ## Why these tiers (and not others)
22
-
23
- The 2026 consensus across production agent platforms (AWS
24
- Lambda/Fargate, Fly Machines, Replit, E2B, Modal, OpenAI Code
25
- Interpreter, Anthropic Code Execution, Daytona) bifurcates cleanly
26
- along the **trust boundary**:
27
-
28
- - **Adversarial multi-tenant code execution → Firecracker microVMs.**
29
- AWS, Fly, Replit, E2B, Daytona all converged here. The argument
30
- is in [Fly's "Sandboxing and Workload Isolation"](https://fly.io/blog/sandboxing-and-workload-isolation)
31
- and the [original Firecracker paper](https://www.usenix.org/conference/nsdi20/presentation/agache):
32
- KVM-backed VMs are the only mainstream primitive with a
33
- kernel-level trust boundary, and `jailer` plus snapshot/restore
34
- makes them boot in 125ms.
35
- - **Trusted-tenant or first-party workloads → gVisor.** Google's
36
- GKE Sandbox, Modal, OpenAI Code Interpreter run gVisor's `runsc`.
37
- Near-zero cold-start, runs on commodity Linux without nested
38
- virt. Tradeoff: a userspace-kernel CVE is a tenant escape; a
39
- Firecracker CVE generally is not.
40
- - **Single-user dev workstation → bubblewrap / Seatbelt.** What
41
- Anthropic itself ships with Claude Code via
42
- `@anthropic-ai/sandbox-runtime`. The threat model is "don't let
43
- the agent read `~/.ssh` or run `rm -rf ~`," not "tenant A vs
44
- tenant B." Process-spawn cold-start.
45
- - **Single-tenant or co-trusted tenants → plain Docker + seccomp.**
46
- Northflank, Railway, Render, Compass-platform, GitHub Actions
47
- runners. Adequate when the model is your model and the user is
48
- your customer; insufficient when the prompt is the attacker.
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 is cloud-agnostic. `docker` works on every cloud,
62
- `e2b` and `fly-machines` are managed services not tied to any
63
- cloud, `runsc` and `firecracker:self-hosted` need infrastructure
64
- the host chooses (GKE Sandbox, AWS Fargate, self-hosted KVM, etc.).
65
- Picking a stronger backend may imply picking a different cloud —
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 supports the same `EgressPolicy` shape:
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
- This package is being built out across the `ses_004-native-agentic-runtime-and-sandbox`
92
- design session in phases. Each phase ships one tier, fully
93
- implemented + tested + documented:
94
-
95
- - ✅ **P3.0** — Public surface (this commit). Backend interfaces,
96
- tier discriminator, egress policy. Factory throws
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
- // Phase 1: ship now, works on every dev's laptop
121
- const sandbox = createSandboxProvider({
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
- defaultEgress: { kind: 'static', allowedHosts: ['api.openai.com', 'api.anthropic.com'] },
150
+ layout,
151
+ defaultEgress: { kind: 'static', allowedHosts: ['api.example.com'] },
124
152
  })
125
153
 
126
- // Phase 2: production, adversarial multi-tenant, managed Firecracker
127
- const sandbox = createSandboxProvider({
128
- backend: { tier: 'microvm', service: 'e2b', apiKey: process.env.E2B_API_KEY! },
129
- defaultEgress: {
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
- firecrackerBinary: '/usr/local/bin/firecracker',
141
- kernelImage: '/var/lib/namzu/vmlinux',
142
- rootfsImage: '/var/lib/namzu/rootfs.ext4',
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: sandbox
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=unenforceable-controls.test.d.ts.map
@@ -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":""}