@torkbot/sandbox 0.5.0 → 0.6.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/README.md +312 -282
- package/dist/host-process.d.ts.map +1 -1
- package/dist/host-process.js +38 -20
- package/dist/host-process.js.map +1 -1
- package/dist/index.d.ts +38 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,199 +1,251 @@
|
|
|
1
1
|
# Sandbox
|
|
2
2
|
|
|
3
|
-
Sandbox is a TypeScript-first Node.js library for running work inside
|
|
4
|
-
libkrun-backed microVMs
|
|
3
|
+
Sandbox is a TypeScript-first Node.js library for running AI-agent work inside
|
|
4
|
+
libkrun-backed microVMs. It gives agent builders a small API for booting
|
|
5
|
+
isolated Linux workers, mounting host-controlled filesystems, persisting rootfs
|
|
6
|
+
mutations, and enforcing explicit network egress policy.
|
|
7
|
+
|
|
8
|
+
Use Sandbox when your agent needs to run tools, install packages, clone repos,
|
|
9
|
+
execute untrusted code, or call external APIs without handing the guest broad
|
|
10
|
+
host filesystem or network access.
|
|
11
|
+
|
|
12
|
+
Sandbox is designed for:
|
|
13
|
+
|
|
14
|
+
- agent runtimes that need disposable or durable Linux workspaces,
|
|
15
|
+
- coding agents that need real shells, compilers, package managers, and repo
|
|
16
|
+
checkouts,
|
|
17
|
+
- browser or data agents that need tightly-scoped outbound network access,
|
|
18
|
+
- hosted agent platforms that need per-task isolation with policy and audit
|
|
19
|
+
points in TypeScript,
|
|
20
|
+
- systems that need to broker credentials from the host without putting long
|
|
21
|
+
lived secrets inside the guest.
|
|
22
|
+
|
|
23
|
+
## Example
|
|
24
|
+
|
|
25
|
+
This example creates a durable agent lane, mounts a host-controlled workspace,
|
|
26
|
+
allows DNS through Cloudflare, allows only GitHub API HTTP traffic, and injects
|
|
27
|
+
a short-lived GitHub token from the host before the request leaves the sandbox.
|
|
5
28
|
|
|
6
29
|
```ts
|
|
7
30
|
import {
|
|
8
31
|
defineSandbox,
|
|
9
32
|
fs,
|
|
33
|
+
network,
|
|
10
34
|
rootfs,
|
|
35
|
+
type SandboxBlockStore,
|
|
11
36
|
} from "@torkbot/sandbox";
|
|
12
37
|
|
|
13
|
-
const
|
|
38
|
+
const workspace = fs.memory({
|
|
14
39
|
files: {
|
|
15
|
-
"/
|
|
40
|
+
"/task.txt": "Summarize the current GitHub user\n",
|
|
16
41
|
},
|
|
17
42
|
});
|
|
18
43
|
|
|
44
|
+
const writableRootfs: SandboxBlockStore = new BlobBackedBlockStore({
|
|
45
|
+
bucket: "agent-rootfs-overlays",
|
|
46
|
+
keyPrefix: "lanes/github-worker",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const githubTokens = new GitHubInstallationTokenService({
|
|
50
|
+
installationId: 123456,
|
|
51
|
+
});
|
|
52
|
+
|
|
19
53
|
const sandbox = defineSandbox({
|
|
20
|
-
rootfs: rootfs.
|
|
54
|
+
rootfs: rootfs.cow({
|
|
55
|
+
base: rootfs.builtIn("alpine:3.23"),
|
|
56
|
+
writable: writableRootfs,
|
|
57
|
+
}),
|
|
21
58
|
resources: {
|
|
22
|
-
cpus:
|
|
23
|
-
memoryMiB:
|
|
59
|
+
cpus: 4,
|
|
60
|
+
memoryMiB: 4096,
|
|
24
61
|
},
|
|
62
|
+
network: network.policy(async (conn) => {
|
|
63
|
+
// Let the guest resolve names, but answer DNS via an explicit resolver.
|
|
64
|
+
if (conn.matchDns("1.1.1.1")?.accept()) return;
|
|
65
|
+
|
|
66
|
+
// Only GitHub API HTTP(S) traffic gets HTTP middleware.
|
|
67
|
+
const github = conn.matchHttp("api.github.com");
|
|
68
|
+
if (!github) return;
|
|
69
|
+
|
|
70
|
+
// Keep the credential decision in host-controlled TypeScript.
|
|
71
|
+
if (!(await githubTokens.canServe(github))) return;
|
|
72
|
+
|
|
73
|
+
github.accept(async (request) => {
|
|
74
|
+
request.headers.set(
|
|
75
|
+
"authorization",
|
|
76
|
+
`Bearer ${await githubTokens.tokenForRequest(request)}`,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}),
|
|
25
80
|
});
|
|
26
81
|
|
|
27
82
|
await using lane = await sandbox.boot({
|
|
28
83
|
mounts: {
|
|
29
|
-
"/workspace": fs.virtual(
|
|
84
|
+
"/workspace": fs.virtual(workspace),
|
|
30
85
|
},
|
|
31
86
|
cwd: "/workspace",
|
|
32
87
|
});
|
|
33
88
|
|
|
34
|
-
const result = await lane.exec("
|
|
89
|
+
const result = await lane.exec("sh", [
|
|
90
|
+
"-lc",
|
|
91
|
+
"cat task.txt && curl -fsSL https://api.github.com/user",
|
|
92
|
+
]);
|
|
35
93
|
|
|
36
94
|
if (result.exitCode !== 0) {
|
|
37
95
|
throw new Error(result.stderr);
|
|
38
96
|
}
|
|
39
97
|
```
|
|
40
98
|
|
|
41
|
-
|
|
99
|
+
The guest gets a normal Linux environment. The host keeps control over the
|
|
100
|
+
workspace contents, rootfs persistence, network decisions, and credential
|
|
101
|
+
injection.
|
|
42
102
|
|
|
43
|
-
|
|
44
|
-
the mounts each instance needs:
|
|
103
|
+
## Quick Paths
|
|
45
104
|
|
|
46
|
-
|
|
47
|
-
import {
|
|
48
|
-
defineSandbox,
|
|
49
|
-
fs,
|
|
50
|
-
rootfs,
|
|
51
|
-
} from "@torkbot/sandbox";
|
|
105
|
+
### Run one isolated command
|
|
52
106
|
|
|
53
|
-
|
|
107
|
+
Use a built-in read-only rootfs and a memory-backed workspace:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const workspace = fs.memory({
|
|
111
|
+
files: { "/hello.txt": "hello from the host\n" },
|
|
112
|
+
});
|
|
54
113
|
|
|
55
114
|
const sandbox = defineSandbox({
|
|
56
115
|
rootfs: rootfs.builtIn("alpine:3.23"),
|
|
57
116
|
});
|
|
58
117
|
|
|
59
118
|
await using lane = await sandbox.boot({
|
|
60
|
-
mounts: {
|
|
61
|
-
"/workspace": fs.virtual(workspaceFs),
|
|
62
|
-
},
|
|
119
|
+
mounts: { "/workspace": fs.virtual(workspace) },
|
|
63
120
|
cwd: "/workspace",
|
|
64
121
|
});
|
|
65
122
|
|
|
66
|
-
const result = await lane.exec("
|
|
67
|
-
env: { CI: "1" },
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
if (result.exitCode !== 0) {
|
|
71
|
-
throw new Error(result.stderr);
|
|
72
|
-
}
|
|
123
|
+
const result = await lane.exec("cat", ["hello.txt"]);
|
|
73
124
|
```
|
|
74
125
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
- `defineSandbox(...)` describes reusable machine configuration.
|
|
78
|
-
- `sandbox.boot(...)` creates a runtime instance with per-instance mounts.
|
|
79
|
-
- `lane.exec(...)` runs buffered work inside the booted instance.
|
|
126
|
+
### Give an agent a durable machine
|
|
80
127
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
at runtime.
|
|
85
|
-
|
|
86
|
-
## Durable, Policy-Controlled Instances
|
|
87
|
-
|
|
88
|
-
Sandbox composes durable rootfs mutation with explicit network policy. In this
|
|
89
|
-
example, dirty COW blocks are synchronized to blob storage, public HTTP(S)
|
|
90
|
-
egress is allowed, and only GitHub API requests receive an installation token:
|
|
128
|
+
Use `rootfs.cow(...)` when package installs and rootfs mutations should survive
|
|
129
|
+
across boots. Sandbox owns the block-device protocol; your application owns the
|
|
130
|
+
storage backend.
|
|
91
131
|
|
|
92
132
|
```ts
|
|
93
|
-
import {
|
|
94
|
-
defineSandbox,
|
|
95
|
-
network,
|
|
96
|
-
rootfs,
|
|
97
|
-
type SandboxBlockStore,
|
|
98
|
-
} from "@torkbot/sandbox";
|
|
99
|
-
|
|
100
|
-
const writableRootfs: SandboxBlockStore = new BlobSynchronizedCowBlockStore({
|
|
101
|
-
bucket: "sandbox-rootfs-overlays",
|
|
102
|
-
keyPrefix: "lanes/github-worker",
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const githubTokens = new GitHubInstallationTokenService({
|
|
106
|
-
installationId: 123456,
|
|
107
|
-
});
|
|
108
|
-
|
|
109
133
|
const sandbox = defineSandbox({
|
|
110
134
|
rootfs: rootfs.cow({
|
|
111
135
|
base: rootfs.builtIn("alpine:3.23"),
|
|
112
|
-
writable:
|
|
113
|
-
}),
|
|
114
|
-
resources: {
|
|
115
|
-
cpus: 4,
|
|
116
|
-
memoryMiB: 4096,
|
|
117
|
-
},
|
|
118
|
-
network: network.policy(async (conn) => {
|
|
119
|
-
if (conn.matchDns("10.0.2.1")?.accept()) return;
|
|
120
|
-
|
|
121
|
-
const github = conn.transport === "tcp"
|
|
122
|
-
? conn.matchHttp("api.github.com")
|
|
123
|
-
: undefined;
|
|
124
|
-
if (github) {
|
|
125
|
-
github.accept(async (request) => {
|
|
126
|
-
if (request.destination.hostname !== "api.github.com") return;
|
|
127
|
-
request.headers.set(
|
|
128
|
-
"authorization",
|
|
129
|
-
`Bearer ${await githubTokens.tokenForRequest(request)}`,
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
136
|
+
writable: blockStore,
|
|
134
137
|
}),
|
|
135
138
|
});
|
|
139
|
+
```
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
Attach a writable COW block store to at most one running sandbox instance at a
|
|
142
|
+
time. Create one store per lane, or enforce exclusivity in your storage layer.
|
|
138
143
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
### Mount host-controlled data
|
|
145
|
+
|
|
146
|
+
Mounts are per boot. They are guest-visible paths backed by TypeScript
|
|
147
|
+
filesystem implementations, not host path passthrough.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
await using lane = await sandbox.boot({
|
|
151
|
+
mounts: {
|
|
152
|
+
"/workspace": fs.virtual(workspaceFs),
|
|
153
|
+
"/mnt/shared": fs.virtual(sharedFs),
|
|
154
|
+
},
|
|
155
|
+
cwd: "/workspace",
|
|
156
|
+
});
|
|
143
157
|
```
|
|
144
158
|
|
|
145
|
-
|
|
159
|
+
The built-in Alpine rootfs includes `/workspace`, `/tmp`, and `/mnt`. Mount
|
|
160
|
+
targets must already exist in the selected rootfs.
|
|
161
|
+
|
|
162
|
+
### Control network egress
|
|
146
163
|
|
|
147
|
-
|
|
164
|
+
Networking is default-deny. Policy callbacks grant only the flows they accept:
|
|
148
165
|
|
|
149
166
|
```ts
|
|
150
|
-
|
|
151
|
-
rootfs:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
};
|
|
167
|
+
const sandbox = defineSandbox({
|
|
168
|
+
rootfs: rootfs.builtIn("alpine:3.23"),
|
|
169
|
+
network: network.policy((conn) => {
|
|
170
|
+
if (conn.matchDns("1.1.1.1")?.accept()) return;
|
|
171
|
+
|
|
172
|
+
conn.matchHttp("api.example.com")?.accept((request) => {
|
|
173
|
+
request.headers.set("authorization", `Bearer ${apiToken}`);
|
|
174
|
+
});
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
158
177
|
```
|
|
159
178
|
|
|
160
|
-
`
|
|
161
|
-
|
|
179
|
+
Use `conn.accept()` for raw transport, and protocol match helpers when you want
|
|
180
|
+
Sandbox to handle protocol-specific semantics. `conn.matchHttp(...)` does not
|
|
181
|
+
trust the HTTP `Host` header; it uses trusted destination metadata and then
|
|
182
|
+
routes accepted traffic through HTTP-family enforcement.
|
|
183
|
+
|
|
184
|
+
### Broker credentials from the host
|
|
185
|
+
|
|
186
|
+
Credential injection belongs in HTTP middleware, not in the guest filesystem or
|
|
187
|
+
environment:
|
|
162
188
|
|
|
163
189
|
```ts
|
|
164
|
-
|
|
190
|
+
const github = conn.matchHttp("api.github.com");
|
|
191
|
+
if (!github) return;
|
|
192
|
+
|
|
193
|
+
if (!(await policyManager.allow(github))) return;
|
|
194
|
+
|
|
195
|
+
github.accept(async (request) => {
|
|
196
|
+
request.headers.set(
|
|
197
|
+
"authorization",
|
|
198
|
+
`Bearer ${await policyManager.tokenFor(request)}`,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
165
201
|
```
|
|
166
202
|
|
|
167
|
-
|
|
168
|
-
|
|
203
|
+
This lets the guest run ordinary tools such as `curl`, `git`, package managers,
|
|
204
|
+
or language CLIs while the host decides which outbound requests receive
|
|
205
|
+
credentials.
|
|
206
|
+
|
|
207
|
+
## API Reference
|
|
208
|
+
|
|
209
|
+
### `defineSandbox(options)`
|
|
210
|
+
|
|
211
|
+
Creates reusable machine configuration.
|
|
169
212
|
|
|
170
213
|
```ts
|
|
171
|
-
defineSandbox({
|
|
214
|
+
const sandbox = defineSandbox({
|
|
172
215
|
rootfs: rootfs.builtIn("alpine:3.23"),
|
|
173
216
|
resources: {
|
|
174
217
|
cpus: 4,
|
|
175
218
|
memoryMiB: 4096,
|
|
176
219
|
},
|
|
220
|
+
network: network.policy((conn) => {
|
|
221
|
+
conn.matchDns("1.1.1.1")?.accept();
|
|
222
|
+
}),
|
|
177
223
|
});
|
|
178
224
|
```
|
|
179
225
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
mounts that image read-only in the guest; `rootfs.cow(...)` mounts the same base
|
|
185
|
-
read-write through the host COW block store.
|
|
226
|
+
`defineSandbox(...)` does not boot a VM. It describes rootfs, resource, and
|
|
227
|
+
network policy defaults that can be reused across many boots.
|
|
228
|
+
|
|
229
|
+
### `rootfs`
|
|
186
230
|
|
|
187
231
|
```ts
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
232
|
+
rootfs.builtIn("alpine:3.23");
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Selects a read-only built-in rootfs artifact. Built-in rootfs artifacts are
|
|
236
|
+
prepared at build or install time; Sandbox does not pull container images or
|
|
237
|
+
build root filesystems during `boot()`.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
rootfs.cow({
|
|
241
|
+
base: rootfs.builtIn("alpine:3.23"),
|
|
242
|
+
writable: blockStore,
|
|
193
243
|
});
|
|
194
244
|
```
|
|
195
245
|
|
|
196
|
-
|
|
246
|
+
Mounts a built-in base rootfs through a writable copy-on-write block store.
|
|
247
|
+
Clean base-image blocks are served from the built-in artifact. Dirty blocks are
|
|
248
|
+
read lazily and flushed through your `SandboxBlockStore`.
|
|
197
249
|
|
|
198
250
|
```ts
|
|
199
251
|
interface SandboxBlockStore {
|
|
@@ -211,240 +263,206 @@ interface SandboxBlockStore {
|
|
|
211
263
|
}
|
|
212
264
|
```
|
|
213
265
|
|
|
214
|
-
The `context.base` value identifies the exact built-in base image for
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
state. `list()` returns the block IDs currently present in the COW store. The
|
|
218
|
-
Rust block backend reads that manifest once at boot, so clean base-image blocks
|
|
219
|
-
are served without asking JavaScript. Dirty blocks are read lazily and writes are
|
|
220
|
-
batched back through `write(...)` on flush.
|
|
266
|
+
The `context.base` value identifies the exact built-in base image for the boot,
|
|
267
|
+
so storage layers can namespace blocks, reject mismatched snapshots, or migrate
|
|
268
|
+
state.
|
|
221
269
|
|
|
222
|
-
|
|
223
|
-
instance at a time. Concurrent sandboxes sharing the same writable store are
|
|
224
|
-
undefined behavior; create one store per lane or enforce exclusivity in the
|
|
225
|
-
storage driver.
|
|
270
|
+
### `sandbox.boot(options)`
|
|
226
271
|
|
|
227
|
-
|
|
228
|
-
connection requests and grants only the traffic it explicitly allows:
|
|
272
|
+
Boots a sandbox instance.
|
|
229
273
|
|
|
230
274
|
```ts
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
275
|
+
await using lane = await sandbox.boot({
|
|
276
|
+
mounts: {
|
|
277
|
+
"/workspace": fs.virtual(workspaceFs),
|
|
278
|
+
},
|
|
279
|
+
cwd: "/workspace",
|
|
235
280
|
});
|
|
236
281
|
```
|
|
237
282
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
and does not MITM TLS. `conn.acceptHttp(...)` is TCP-only and explicitly opts
|
|
241
|
-
the flow into Sandbox's HTTP-family enforcement path. If the accepted flow is
|
|
242
|
-
not actually HTTP or HTTPS, it fails closed.
|
|
283
|
+
Boot options are per instance. The same sandbox definition can be booted with
|
|
284
|
+
different mounts and working directories.
|
|
243
285
|
|
|
244
|
-
|
|
245
|
-
const policy = network.policy(async (conn) => {
|
|
246
|
-
const api = conn.transport === "tcp"
|
|
247
|
-
? conn.matchHttp("api.example.com")
|
|
248
|
-
: undefined;
|
|
249
|
-
if (!api) return;
|
|
286
|
+
### Filesystems
|
|
250
287
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
});
|
|
288
|
+
```ts
|
|
289
|
+
const workspaceFs = fs.memory({
|
|
290
|
+
files: {
|
|
291
|
+
"/README.md": "# Task\n",
|
|
292
|
+
},
|
|
257
293
|
});
|
|
258
294
|
```
|
|
259
295
|
|
|
260
|
-
|
|
261
|
-
endpoints:
|
|
296
|
+
`fs.memory(...)` creates an in-memory POSIX filesystem.
|
|
262
297
|
|
|
263
298
|
```ts
|
|
264
|
-
|
|
265
|
-
conn.src.port;
|
|
266
|
-
conn.dst.ip;
|
|
267
|
-
conn.dst.port;
|
|
299
|
+
const mount = fs.virtual(workspaceFs);
|
|
268
300
|
```
|
|
269
301
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
`isPublicInternet()`. `transport` is the TCP/UDP discriminator. Sandbox does
|
|
274
|
-
not expose a best-effort `conn.protocol` classifier.
|
|
302
|
+
`fs.virtual(...)` adapts a compatible JavaScript filesystem for guest mounts.
|
|
303
|
+
Sandbox mounts are host-implemented filesystems, not direct host directory
|
|
304
|
+
mounts.
|
|
275
305
|
|
|
276
|
-
|
|
277
|
-
request URL. `request.destination.hostname` is populated only when Sandbox can
|
|
278
|
-
pin the destination IP to trusted connection metadata, such as its own DNS
|
|
279
|
-
answer cache. IP-addressed requests still work under `acceptHttp(...)`, but they
|
|
280
|
-
do not advertise a hostname. Do not use the HTTP `Host` header as authority for
|
|
281
|
-
policy decisions.
|
|
306
|
+
### Processes
|
|
282
307
|
|
|
283
|
-
|
|
284
|
-
|
|
308
|
+
```ts
|
|
309
|
+
const result = await lane.exec("npm", ["test"], {
|
|
310
|
+
cwd: "/workspace",
|
|
311
|
+
env: { CI: "1" },
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`exec(...)` is the buffered process API. It returns after the command exits with
|
|
316
|
+
`exitCode`, `stdout`, and `stderr`.
|
|
317
|
+
|
|
318
|
+
### Network Policy
|
|
285
319
|
|
|
286
320
|
```ts
|
|
287
|
-
network.policy(async (conn) => {
|
|
288
|
-
if (conn.matchDns("
|
|
321
|
+
const policy = network.policy(async (conn) => {
|
|
322
|
+
if (conn.matchDns("1.1.1.1")?.accept()) return;
|
|
289
323
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
candidate.hostname === "api.github.com"
|
|
293
|
-
);
|
|
294
|
-
if (!http) return;
|
|
324
|
+
const api = conn.matchHttp("api.example.com");
|
|
325
|
+
if (!api) return;
|
|
295
326
|
|
|
296
|
-
if (!(await policyManager.allow(
|
|
327
|
+
if (!(await policyManager.allow(api))) return;
|
|
297
328
|
|
|
298
|
-
|
|
329
|
+
api.accept((request) => policyManager.handleHttp(request));
|
|
299
330
|
});
|
|
300
331
|
```
|
|
301
332
|
|
|
302
|
-
|
|
303
|
-
connection is blocked.
|
|
304
|
-
reserved as future extension points for instance-local state, such as
|
|
305
|
-
remembering a grant for a time window.
|
|
306
|
-
|
|
307
|
-
The runtime uses this policy shape to keep the JavaScript boundary explicit.
|
|
308
|
-
Native rules can be added under the same model later without changing the
|
|
309
|
-
caller-facing API.
|
|
310
|
-
|
|
311
|
-
### Boot Options
|
|
333
|
+
Policy callbacks are default-deny. If the callback does not create a grant, the
|
|
334
|
+
connection is blocked.
|
|
312
335
|
|
|
313
|
-
|
|
314
|
-
different filesystems over the same reusable machine configuration:
|
|
336
|
+
Every policy event exposes IP-layer endpoints:
|
|
315
337
|
|
|
316
338
|
```ts
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"/mnt": fs.virtual(sharedFs),
|
|
322
|
-
},
|
|
323
|
-
cwd: "/workspace",
|
|
324
|
-
});
|
|
339
|
+
conn.src.ip;
|
|
340
|
+
conn.src.port;
|
|
341
|
+
conn.dst.ip;
|
|
342
|
+
conn.dst.port;
|
|
325
343
|
```
|
|
326
344
|
|
|
327
|
-
|
|
328
|
-
paths backed by user-supplied filesystems. The target path must already exist
|
|
329
|
-
in the selected rootfs; the built-in Alpine rootfs includes `/workspace`,
|
|
330
|
-
`/tmp`, and `/mnt`.
|
|
345
|
+
Endpoint helpers classify address ranges without relying on DNS, TLS, or HTTP:
|
|
331
346
|
|
|
332
|
-
|
|
347
|
+
```ts
|
|
348
|
+
conn.dst.isLoopback();
|
|
349
|
+
conn.dst.isPrivate();
|
|
350
|
+
conn.dst.isLinkLocal();
|
|
351
|
+
conn.dst.isMulticast();
|
|
352
|
+
conn.dst.isBroadcast();
|
|
353
|
+
conn.dst.isDocumentation();
|
|
354
|
+
conn.dst.isReserved();
|
|
355
|
+
conn.dst.isPublicInternet();
|
|
356
|
+
```
|
|
333
357
|
|
|
334
|
-
|
|
358
|
+
Transport and protocol helpers:
|
|
335
359
|
|
|
336
360
|
```ts
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
361
|
+
conn.accept(); // accept raw TCP or UDP transport
|
|
362
|
+
conn.matchDns("1.1.1.1"); // DNS over UDP or TCP, using 1.1.1.1 upstream
|
|
363
|
+
conn.matchTcp("203.0.113.10:5432");
|
|
364
|
+
conn.matchUdp("203.0.113.10:8125");
|
|
365
|
+
conn.matchHttp("api.example.com");
|
|
342
366
|
```
|
|
343
367
|
|
|
344
|
-
`
|
|
345
|
-
|
|
368
|
+
`conn.matchDns(...)` normalizes DNS over UDP and TCP. Passing a resolver spec
|
|
369
|
+
selects the upstream resolver used by `accept()`. Advanced policies can pass a
|
|
370
|
+
synchronous predicate and then provide explicit resolvers to `accept(...)`.
|
|
346
371
|
|
|
347
372
|
```ts
|
|
348
|
-
const
|
|
373
|
+
const dns = conn.matchDns((candidate) => candidate.transport === "udp");
|
|
374
|
+
if (dns) {
|
|
375
|
+
dns.accept({ resolvers: ["1.1.1.1", "8.8.8.8"] });
|
|
376
|
+
}
|
|
349
377
|
```
|
|
350
378
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
379
|
+
`conn.matchHttp(...)` acquires an HTTP capability from trusted destination
|
|
380
|
+
metadata. It does not inspect or trust the HTTP `Host` header. IP-addressed HTTP
|
|
381
|
+
requests can still be accepted through lower-level policy, but they do not
|
|
382
|
+
advertise a trusted hostname.
|
|
354
383
|
|
|
355
384
|
```ts
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
385
|
+
const http = conn.matchHttp((candidate) =>
|
|
386
|
+
candidate.hostname.endsWith(".example.com")
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
if (http) {
|
|
390
|
+
http.accept((request) => {
|
|
391
|
+
request.headers.set("x-agent-policy", "allowed");
|
|
392
|
+
});
|
|
393
|
+
}
|
|
360
394
|
```
|
|
361
395
|
|
|
362
|
-
`
|
|
363
|
-
|
|
364
|
-
`lane.spawn(...)` API.
|
|
396
|
+
`http.accept(...)` enters Sandbox's HTTP-family enforcement path. If the matched
|
|
397
|
+
flow is not actually HTTP or HTTPS, it fails closed.
|
|
365
398
|
|
|
366
|
-
##
|
|
399
|
+
## Architecture Reference
|
|
367
400
|
|
|
368
|
-
Sandbox hides the kernel, init, transport, and host helper behind a
|
|
369
|
-
|
|
401
|
+
Sandbox hides the kernel, init, transport, and host helper behind a TypeScript
|
|
402
|
+
API:
|
|
370
403
|
|
|
371
|
-
- The runtime boots a libkrun-backed
|
|
372
|
-
|
|
373
|
-
|
|
404
|
+
- The runtime boots a libkrun-backed microVM from a prebuilt rootfs artifact.
|
|
405
|
+
- The built-in rootfs is a compressed QCOW2 image containing an ext4 guest
|
|
406
|
+
filesystem.
|
|
374
407
|
- A signed `sandbox-host` helper owns the Node/Rust/libkrun boundary.
|
|
375
|
-
- Guest control traffic uses an
|
|
376
|
-
|
|
408
|
+
- Guest control traffic uses an fd-backed transport between the host and the
|
|
409
|
+
custom Sandbox init process.
|
|
377
410
|
- Host-implemented virtual filesystems are mounted into the guest.
|
|
378
|
-
-
|
|
379
|
-
|
|
380
|
-
- Network egress is default-deny
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
interception machinery and certificate plumbing.
|
|
384
|
-
- When HTTP interception is enabled, the host generates the CA material and
|
|
385
|
-
passes only the public CA certificate to Sandbox init. Init does not generate
|
|
386
|
-
or manage certificates; the host exposes the supplied CA through an internal
|
|
387
|
-
read-only virtiofs mount, then init installs it using the selected rootfs'
|
|
388
|
-
native trust-store mechanism. Built-in rootfs launches use an ephemeral
|
|
389
|
-
writable COW view for HTTP interception so init can update the guest trust
|
|
390
|
-
store deterministically. If a rootfs does not provide a supported native
|
|
391
|
-
trust-store installer, init fails closed.
|
|
392
|
-
|
|
393
|
-
The intended boundary is that Sandbox knows how to launch, isolate, mount,
|
|
394
|
-
intercept, and enforce. User-space owns artifact selection, filesystem
|
|
395
|
-
durability, network policy state, confirmation flows, and credential brokering.
|
|
396
|
-
|
|
397
|
-
## Design Targets
|
|
398
|
-
|
|
399
|
-
- no dynamic `libkrun` or `libkrunfw` dependency in the final host artifact,
|
|
400
|
-
- a signed `sandbox-host` process for the Node/Rust host boundary,
|
|
401
|
-
- custom guest init owned by this repo,
|
|
402
|
-
- implicit fd-backed host control sockets owned by Sandbox,
|
|
403
|
-
- avoid host filesystem coordination unless it is intrinsic to the artifact; prefer file descriptors, database handles, bytes, and async iterables over paths,
|
|
404
|
-
- build-time rootfs shaping, with built-in rootfs artifacts selected by typed logical names at VM instantiation,
|
|
405
|
-
- immutable rootfs by default, with copy-on-write rootfs supplied by a user-space block store when requested,
|
|
406
|
-
- generic guest-visible mounts backed by the same user-space filesystem abstraction,
|
|
407
|
-
- programmable virtual filesystems backed by TypeScript callbacks,
|
|
408
|
-
- transparent HTTP interception with TypeScript request-header hooks,
|
|
409
|
-
- default-deny outbound networking with JavaScript policy callbacks only where native rules cannot decide,
|
|
410
|
-
- Rust-native or statically linkable networking components; sidecar network daemons are references, not default runtime dependencies,
|
|
411
|
-
- macOS HVF entitlement signing verified as part of the integration test flow.
|
|
412
|
-
|
|
413
|
-
## Repository Layout
|
|
411
|
+
- Durable rootfs mutation is modeled as block-level copy-on-write storage, not
|
|
412
|
+
as a guest-visible POSIX filesystem.
|
|
413
|
+
- Network egress is default-deny and policy-controlled.
|
|
414
|
+
- HTTP request middleware is caller-provided JavaScript, while Sandbox owns
|
|
415
|
+
interception, forwarding, and certificate plumbing.
|
|
414
416
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
417
|
+
When HTTP interception is enabled, the host generates CA material and passes
|
|
418
|
+
only the public CA certificate to Sandbox init. Init installs that CA using the
|
|
419
|
+
selected rootfs' native trust-store mechanism. Built-in rootfs launches use an
|
|
420
|
+
ephemeral writable COW view for HTTP interception so trust-store installation is
|
|
421
|
+
deterministic. If a rootfs does not provide a supported trust-store installer,
|
|
422
|
+
init fails closed.
|
|
423
|
+
|
|
424
|
+
The intended boundary is:
|
|
420
425
|
|
|
421
|
-
|
|
426
|
+
- Sandbox owns launch, isolation, mounts, network interception, and enforcement.
|
|
427
|
+
- User-space owns artifact selection, filesystem durability, network policy
|
|
428
|
+
state, confirmation flows, audit logs, and credential brokering.
|
|
422
429
|
|
|
423
|
-
|
|
424
|
-
|
|
430
|
+
See [docs/architecture.md](docs/architecture.md) for the design background,
|
|
431
|
+
[docs/kernel-build.md](docs/kernel-build.md) for kernel artifact builds, and
|
|
432
|
+
[docs/testing-strategy.md](docs/testing-strategy.md) for the integration and
|
|
433
|
+
e2e testing plan.
|
|
425
434
|
|
|
426
|
-
##
|
|
435
|
+
## Platform Notes
|
|
427
436
|
|
|
428
|
-
The npm package is published as `@torkbot/sandbox`. It does not use
|
|
437
|
+
The npm package is published as `@torkbot/sandbox`. It does not use
|
|
438
|
+
post-install scripts. The root package contains the TypeScript API and declares
|
|
439
|
+
platform artifacts as optional dependencies:
|
|
429
440
|
|
|
430
441
|
- `@torkbot/sandbox-darwin-arm64`
|
|
431
442
|
- `@torkbot/sandbox-linux-x64-gnu`
|
|
432
443
|
|
|
433
|
-
Each platform package contains the `sandbox-host` helper and built-in rootfs
|
|
444
|
+
Each platform package contains the `sandbox-host` helper and built-in rootfs
|
|
445
|
+
artifacts for that target. Runtime artifact resolution only loads the installed
|
|
446
|
+
optional dependency for the current platform. Local development uses the same
|
|
447
|
+
layout by materializing the current platform package under `node_modules`.
|
|
434
448
|
|
|
435
449
|
### macOS signing setup
|
|
436
450
|
|
|
437
|
-
For now, the macOS `sandbox-host` artifact is not Developer ID signed or
|
|
438
|
-
|
|
439
|
-
|
|
451
|
+
For now, the macOS `sandbox-host` artifact is not Developer ID signed or
|
|
452
|
+
notarized. macOS users must sign the installed helper locally before launching a
|
|
453
|
+
VM:
|
|
440
454
|
|
|
441
455
|
```sh
|
|
442
456
|
npx @torkbot/sandbox setup-macos
|
|
443
457
|
```
|
|
444
458
|
|
|
445
|
-
This performs an ad-hoc local `codesign` with the
|
|
459
|
+
This performs an ad-hoc local `codesign` with the
|
|
460
|
+
`com.apple.security.hypervisor` entitlement required by Hypervisor.framework. It
|
|
461
|
+
does not contact Apple and does not require an Apple Developer account. If a
|
|
462
|
+
macOS user tries to launch a VM before running setup, Sandbox throws a runtime
|
|
463
|
+
error that points back to this command.
|
|
446
464
|
|
|
447
|
-
|
|
465
|
+
## Development
|
|
448
466
|
|
|
449
467
|
Local release packaging sanity check:
|
|
450
468
|
|
|
@@ -452,8 +470,20 @@ Local release packaging sanity check:
|
|
|
452
470
|
npm run release:pack
|
|
453
471
|
```
|
|
454
472
|
|
|
455
|
-
After rebuilding local native artifacts, refresh the local optional package
|
|
473
|
+
After rebuilding local native artifacts, refresh the local optional package
|
|
474
|
+
layout with:
|
|
456
475
|
|
|
457
476
|
```sh
|
|
458
477
|
npm run artifacts:link-current
|
|
459
478
|
```
|
|
479
|
+
|
|
480
|
+
Repository layout:
|
|
481
|
+
|
|
482
|
+
- `src/`: TypeScript API consumed by Node.js callers.
|
|
483
|
+
- `crates/sandbox`: Rust host implementation for libkrun, block storage,
|
|
484
|
+
network, HTTP, and VFS services.
|
|
485
|
+
- `crates/sandbox-host`: signed VM-host helper used for macOS HVF launch.
|
|
486
|
+
- `crates/sandbox-init`: custom guest init used to configure the guest before
|
|
487
|
+
supervising untrusted code.
|
|
488
|
+
- `tests/e2e`: TypeScript e2e scenarios run directly by Node.js 24+ type
|
|
489
|
+
stripping.
|