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