@namzu/sandbox 1.1.0 → 2.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +234 -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 +138 -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 +60 -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 +204 -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 +66 -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
@@ -0,0 +1,294 @@
1
+ import { createServer, request as httpRequest } from 'node:http'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import { request as httpsRequest } from 'node:https'
4
+ import { connect as netConnect } from 'node:net'
5
+ import type { Duplex } from 'node:stream'
6
+
7
+ import { isHostAllowed, splitAuthority } from './allowlist.js'
8
+
9
+ /**
10
+ * The network boundary a sandbox's egress policy is enforced at.
11
+ *
12
+ * Until this existed, an egress policy could be *declared* and only two of
13
+ * its four shapes could be honoured anywhere: the container backend refuses
14
+ * a host allowlist outright because it has no proxy to filter through, and
15
+ * only the microVM backend forwards one. `deny-all` and `allow-all` are the
16
+ * whole spectrum a container-tier sandbox could express — all or nothing.
17
+ *
18
+ * It also settles where credentials live. Any token the agent needs in
19
+ * order to reach an allowed host used to have to be inside the sandbox, in
20
+ * the environment, readable by the untrusted code it is meant to be
21
+ * isolated from — via `/proc/self/environ`, or via a prompt injection that
22
+ * exfiltrates it over the very egress the policy permits. Here the token
23
+ * never enters the sandbox: a placeholder does, and the real value is
24
+ * stamped on at this boundary.
25
+ */
26
+
27
+ /** A credential the proxy stamps on, and the host it may be sent to. */
28
+ export interface BrokeredCredential {
29
+ /**
30
+ * Host this credential is for. Same matching rules as the allowlist,
31
+ * so `.example.com` covers subdomains.
32
+ *
33
+ * Scoped per host on purpose: a credential attached to every request
34
+ * is a credential handed to whichever host the agent was talked into
35
+ * contacting.
36
+ */
37
+ readonly host: string
38
+ /** Header to set, e.g. `authorization`. */
39
+ readonly header: string
40
+ /** The real value. Never leaves this process. */
41
+ readonly value: string
42
+ }
43
+
44
+ export interface EgressProxyOptions {
45
+ /** Resolved at request time, so a rotating allowlist is honoured. */
46
+ readonly allowedHosts: () => Promise<readonly string[]>
47
+ readonly credentials?: readonly BrokeredCredential[]
48
+ /**
49
+ * Rewrite a plain-HTTP request to HTTPS upstream.
50
+ *
51
+ * This is what makes brokering work at all. A credential cannot be
52
+ * injected into a CONNECT tunnel — the bytes are already encrypted by
53
+ * the time they reach the proxy, and reading them would mean
54
+ * terminating TLS with a CA the sandbox trusts, which is a far larger
55
+ * and far more dangerous thing to build. So the sandbox speaks plain
56
+ * HTTP to the proxy, and the proxy speaks HTTPS to the world.
57
+ *
58
+ * Default `true`. Turn it off only for a genuinely plaintext upstream.
59
+ */
60
+ readonly upgradeToHttps?: boolean
61
+ readonly onDenied?: (host: string, reason: string) => void
62
+ }
63
+
64
+ export interface RunningEgressProxy {
65
+ readonly port: number
66
+ /** `http://127.0.0.1:<port>` — what a sandbox sets `HTTP_PROXY` to. */
67
+ readonly url: string
68
+ /** Swap the allowlist on a live proxy. See `setNetworkPolicy`. */
69
+ setAllowedHosts(resolve: () => Promise<readonly string[]>): void
70
+ close(): Promise<void>
71
+ }
72
+
73
+ const DENIED_STATUS = 403
74
+
75
+ export class EgressProxy {
76
+ private resolveAllowed: () => Promise<readonly string[]>
77
+ private readonly credentials: readonly BrokeredCredential[]
78
+ private readonly upgradeToHttps: boolean
79
+ private readonly onDenied: ((host: string, reason: string) => void) | undefined
80
+ /** See the loop guard in `listen`. */
81
+ private selfPort: number | undefined
82
+
83
+ constructor(options: EgressProxyOptions) {
84
+ this.resolveAllowed = options.allowedHosts
85
+ this.credentials = options.credentials ?? []
86
+ this.upgradeToHttps = options.upgradeToHttps ?? true
87
+ this.onDenied = options.onDenied
88
+ }
89
+
90
+ async listen(port = 0): Promise<RunningEgressProxy> {
91
+ const server = createServer((req, res) => {
92
+ void this.handleRequest(req, res)
93
+ })
94
+ server.on('connect', (req, socket, head) => {
95
+ void this.handleConnect(req, socket, head)
96
+ })
97
+ await new Promise<void>((resolve, reject) => {
98
+ server.once('error', reject)
99
+ // Loopback only. A proxy that holds real credentials and binds
100
+ // every interface is reachable by anything on the network, which
101
+ // is the opposite of what it exists for.
102
+ server.listen(port, '127.0.0.1', () => {
103
+ server.off('error', reject)
104
+ resolve()
105
+ })
106
+ })
107
+
108
+ const address = server.address()
109
+ const boundPort = typeof address === 'object' && address !== null ? address.port : port
110
+ // Refuse to forward to ourselves. A client that sends its `Host` as
111
+ // the proxy's own address — which is what a library that rewrites
112
+ // `Host` on a proxied request produces — would otherwise make the
113
+ // proxy call itself forever, holding a socket per hop until the
114
+ // process runs out. Found by a test that hung rather than failed,
115
+ // which is the shape this failure takes in production too.
116
+ this.selfPort = boundPort
117
+
118
+ return {
119
+ port: boundPort,
120
+ url: `http://127.0.0.1:${boundPort}`,
121
+ setAllowedHosts: (resolve) => {
122
+ this.resolveAllowed = resolve
123
+ },
124
+ close: () =>
125
+ new Promise<void>((resolve) => {
126
+ server.close(() => resolve())
127
+ }),
128
+ }
129
+ }
130
+
131
+ private async allowed(host: string): Promise<boolean> {
132
+ try {
133
+ return isHostAllowed(host, await this.resolveAllowed())
134
+ } catch {
135
+ // A resolver that throws is a policy that could not be read.
136
+ // Denying is the only safe reading: an allowlist that fails open
137
+ // is not an allowlist.
138
+ return false
139
+ }
140
+ }
141
+
142
+ private deny(host: string, reason: string): void {
143
+ this.onDenied?.(host, reason)
144
+ }
145
+
146
+ /** Plain HTTP. The only path where a credential can be stamped on. */
147
+ private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
148
+ const target = parseTarget(req)
149
+ if (!target) {
150
+ res.writeHead(400, { 'content-type': 'text/plain' })
151
+ res.end('Malformed proxy request: no absolute URL and no Host header.\n')
152
+ return
153
+ }
154
+
155
+ if (this.isSelf(target.host, target.port)) {
156
+ res.writeHead(400, { 'content-type': 'text/plain' })
157
+ res.end(
158
+ 'Refusing to proxy a request addressed to the proxy itself; forwarding it would loop until the process ran out of sockets. Send an absolute URL in the request line, which is what a proxied client does.\n',
159
+ )
160
+ return
161
+ }
162
+
163
+ if (!(await this.allowed(target.host))) {
164
+ this.deny(target.host, 'not on the egress allowlist')
165
+ res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
166
+ // Named, not a generic refusal: an agent that cannot tell "this
167
+ // host is not permitted" from "the network is down" will retry
168
+ // forever, and a human debugging it has nothing to go on.
169
+ res.end(`Egress denied: ${target.host} is not on this sandbox's allowlist.\n`)
170
+ return
171
+ }
172
+
173
+ const headers = { ...req.headers }
174
+ // The proxy re-issues the request, so hop-by-hop headers about the
175
+ // hop that just ended must not be forwarded.
176
+ for (const hop of ['proxy-authorization', 'proxy-connection', 'connection', 'keep-alive']) {
177
+ delete headers[hop]
178
+ }
179
+
180
+ const credential = this.credentialFor(target.host)
181
+ if (credential) {
182
+ headers[credential.header.toLowerCase()] = credential.value
183
+ }
184
+
185
+ const secure = this.upgradeToHttps || target.protocol === 'https:'
186
+ const send = secure ? httpsRequest : httpRequest
187
+ const upstream = send(
188
+ {
189
+ protocol: secure ? 'https:' : 'http:',
190
+ host: target.host,
191
+ port: target.port ?? (secure ? 443 : 80),
192
+ method: req.method,
193
+ path: target.path,
194
+ headers,
195
+ },
196
+ (response) => {
197
+ res.writeHead(response.statusCode ?? 502, response.headers)
198
+ response.pipe(res)
199
+ },
200
+ )
201
+
202
+ upstream.on('error', (err) => {
203
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
204
+ res.end(`Upstream request failed: ${err.message}\n`)
205
+ })
206
+ req.pipe(upstream)
207
+ }
208
+
209
+ /**
210
+ * HTTPS, tunnelled.
211
+ *
212
+ * The allowlist is enforceable here because the CONNECT target names
213
+ * the host in clear text. Credential brokering is NOT: the tunnel is
214
+ * opaque, and injecting into it would mean terminating TLS with a CA
215
+ * the sandbox trusts — which would let this process read every byte the
216
+ * agent sends anywhere, a strictly larger risk than the one being
217
+ * mitigated. A workload that needs brokering speaks plain HTTP to the
218
+ * proxy and lets it upgrade upstream.
219
+ */
220
+ private async handleConnect(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
221
+ const { host, port } = splitAuthority(req.url ?? '')
222
+
223
+ if (!(await this.allowed(host))) {
224
+ this.deny(host, 'not on the egress allowlist')
225
+ socket.write(
226
+ `HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\nEgress denied: ${host} is not on this sandbox's allowlist.\n`,
227
+ )
228
+ socket.end()
229
+ return
230
+ }
231
+
232
+ const upstream = netConnect(port ?? 443, host, () => {
233
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
234
+ if (head.length > 0) upstream.write(head)
235
+ upstream.pipe(socket)
236
+ socket.pipe(upstream)
237
+ })
238
+
239
+ upstream.on('error', () => {
240
+ socket.end()
241
+ })
242
+ socket.on('error', () => {
243
+ upstream.destroy()
244
+ })
245
+ }
246
+
247
+ /** Whether a target names this proxy. See the loop guard in `listen`. */
248
+ private isSelf(host: string, port?: number): boolean {
249
+ if (port === undefined || port !== this.selfPort) return false
250
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1'
251
+ }
252
+
253
+ private credentialFor(host: string): BrokeredCredential | undefined {
254
+ return this.credentials.find((c) => isHostAllowed(host, [c.host]))
255
+ }
256
+ }
257
+
258
+ interface ProxyTarget {
259
+ readonly host: string
260
+ readonly port?: number
261
+ readonly path: string
262
+ readonly protocol: string
263
+ }
264
+
265
+ /**
266
+ * Read the target from a proxied request.
267
+ *
268
+ * A proxy receives an absolute URL in the request line; a client that
269
+ * forgot it still sends `Host`, and honouring that is what makes a plain
270
+ * `fetch` through `HTTP_PROXY` work.
271
+ */
272
+ function parseTarget(req: IncomingMessage): ProxyTarget | null {
273
+ const raw = req.url ?? ''
274
+
275
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
276
+ const url = new URL(raw)
277
+ return {
278
+ host: url.hostname,
279
+ ...(url.port ? { port: Number(url.port) } : {}),
280
+ path: `${url.pathname}${url.search}`,
281
+ protocol: url.protocol,
282
+ }
283
+ }
284
+
285
+ const hostHeader = req.headers.host
286
+ if (!hostHeader) return null
287
+ const { host, port } = splitAuthority(hostHeader)
288
+ return {
289
+ host,
290
+ ...(port !== undefined ? { port } : {}),
291
+ path: raw || '/',
292
+ protocol: 'http:',
293
+ }
294
+ }
package/src/index.test.ts CHANGED
@@ -77,50 +77,28 @@ describe('createSandboxProvider', () => {
77
77
  expect(provider.id).toContain('docker')
78
78
  })
79
79
 
80
- it('throws SandboxBackendNotImplementedError for microvm:e2b until P3.3 lands', () => {
81
- expect(() =>
82
- createSandboxProvider({
83
- backend: { tier: 'microvm', service: 'e2b', apiKey: 'test' },
84
- }),
85
- ).toThrow(SandboxBackendNotImplementedError)
86
-
87
- try {
88
- createSandboxProvider({
89
- backend: { tier: 'microvm', service: 'fly-machines', apiToken: 't', app: 'a', image: 'i' },
90
- })
91
- } catch (err) {
92
- expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
93
- expect((err as SandboxBackendNotImplementedError).backend).toBe('microvm:fly-machines')
94
- }
95
- })
96
-
97
- it('throws for process tier until P3.4 lands, naming the engine in the label', () => {
98
- try {
99
- createSandboxProvider({ backend: { tier: 'process', engine: 'bubblewrap' } })
100
- } catch (err) {
101
- expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
102
- expect((err as SandboxBackendNotImplementedError).backend).toBe('process:bubblewrap')
103
- }
104
- })
80
+ it('builds the userspace-kernel runtime rather than refusing it', () => {
81
+ // This case was pinned by a test shaped `try { … } catch (e) { expect(e) }`
82
+ // with no failure branch, so once the runtime landed the test kept
83
+ // passing while asserting nothing at all.
84
+ const provider = createSandboxProvider({
85
+ backend: { tier: 'container', runtime: 'runsc', image: 'i' },
86
+ layout: validLayout(),
87
+ })
105
88
 
106
- it('throws for container:runsc until P3.5 lands', () => {
107
- try {
108
- createSandboxProvider({
109
- backend: { tier: 'container', runtime: 'runsc', image: 'i' },
110
- layout: validLayout(),
111
- })
112
- } catch (err) {
113
- expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
114
- expect((err as SandboxBackendNotImplementedError).backend).toBe('container:runsc')
115
- }
89
+ expect(provider.name).toContain('container:runsc')
116
90
  })
117
91
 
118
- it('throws for passthrough tier until it lands', () => {
92
+ it('refuses a tier it does not implement, by name', () => {
93
+ // Reachable only from untyped callers now that every shape in the
94
+ // union has an implementation — which is the point of keeping it: a
95
+ // JS host that invents a tier gets a named refusal, not a provider
96
+ // that silently confines nothing.
119
97
  try {
120
- createSandboxProvider({ backend: { tier: 'passthrough' } })
98
+ createSandboxProvider({ backend: { tier: 'passthrough' } } as never)
99
+ expect.unreachable('a tier with no backend must not construct')
121
100
  } catch (err) {
122
101
  expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
123
- expect((err as SandboxBackendNotImplementedError).backend).toBe('passthrough')
124
102
  }
125
103
  })
126
104
 
@@ -437,7 +415,7 @@ describe('serializeSandboxError — transport-safe error envelope', () => {
437
415
  expect(serializeSandboxError(true).message).toBe('true')
438
416
  })
439
417
 
440
- // Codex round 5 (C): the envelope must defend against values that
418
+ // the envelope must defend against values that
441
419
  // JSON.stringify either drops silently or that structuredClone
442
420
  // throws on. Each of these cases asserts a specific envelope shape
443
421
  // AND that the result survives both transport channels.
@@ -484,7 +462,7 @@ describe('serializeSandboxError — transport-safe error envelope', () => {
484
462
  }
485
463
  })
486
464
 
487
- // Codex round 5 (B): cycle guard. The previous round-4 implementation
465
+ // cycle guard. The previous round-4 implementation
488
466
  // recursed unconditionally on `cause`, so a cycle would either
489
467
  // stack-overflow (synchronous) or trip JSON.stringify's circular
490
468
  // detection (async via the receiver). The WeakSet path replaces
@@ -561,7 +539,7 @@ describe('serializeSandboxError — transport-safe error envelope', () => {
561
539
  })
562
540
 
563
541
  describe('public exports — runtime import paths', () => {
564
- // Codex round 4 (E): Vandal-side prompt template generators must
542
+ // Vandal-side prompt template generators must
565
543
  // be able to import the default-path constants from the sandbox
566
544
  // package via the SDK's root barrel. `@namzu/sdk` exposes only
567
545
  // `"."` in its package.json `exports`; subpath imports like