@stixxert/pi-docker-sandbox 0.1.0 → 1.1.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 +82 -2
- package/boundary.md +13 -2
- package/index.ts +268 -44
- package/package.json +23 -5
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +467 -0
- package/sandbox/index.ts +229 -0
- package/sandbox/operations.ts +496 -0
- package/sandbox/package.json +11 -0
- package/sandbox/transport.ts +377 -0
- package/sandbox/try.sh +157 -0
- package/security.md +54 -2
- package/template/Dockerfile +34 -0
- package/template/README.md +92 -0
- package/template/build.sh +168 -0
- package/template/install.sh +77 -0
- package/test-loader.mjs +53 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exec transport for the sbx execution backend.
|
|
3
|
+
*
|
|
4
|
+
* A transport is "run a command in an isolated Linux environment and stream
|
|
5
|
+
* its output". Two backends implement it:
|
|
6
|
+
*
|
|
7
|
+
* - `sbx` — Docker Sandboxes (`sbx exec <sandbox> -- ...`). The product
|
|
8
|
+
* path: what makes pi run *against a sandbox* instead of
|
|
9
|
+
* inside one.
|
|
10
|
+
* - `docker` — a plain container (`docker exec <container> -- ...`). Useful
|
|
11
|
+
* on Linux hosts, and it is what lets the ops layer be tested
|
|
12
|
+
* end-to-end in environments where `sbx` cannot run at all
|
|
13
|
+
* (sbx needs a host hypervisor; it cannot nest).
|
|
14
|
+
*
|
|
15
|
+
* Both backends have the identical `exec <target> -- argv...` shape, so a
|
|
16
|
+
* single implementation is parameterised by (binary, target).
|
|
17
|
+
*
|
|
18
|
+
* Nothing here ever touches a host docker daemon through a socket: the `sbx`
|
|
19
|
+
* backend shells out to the `sbx` CLI only, and the `docker` backend is
|
|
20
|
+
* opt-in and explicit.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { ensureSandbox, findSbxCli, runSbxCli, scrubbedEnv } from "../index.ts";
|
|
29
|
+
|
|
30
|
+
/* ------------------------------------------------------------------ */
|
|
31
|
+
/* project-scoped sandbox naming */
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
/** Same shape the kernel accepts for DOCKER_SANDBOX names. */
|
|
35
|
+
function sanitizeName(value: string): string {
|
|
36
|
+
return value
|
|
37
|
+
.replace(/[^A-Za-z0-9._+-]/g, "-")
|
|
38
|
+
.replace(/-+/g, "-")
|
|
39
|
+
.replace(/^-+|-+$/g, "")
|
|
40
|
+
.slice(0, 60);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Nearest ancestor containing a VCS root — the same "what is this project?" rule sbxpi uses. */
|
|
44
|
+
function findProjectRoot(startDir: string): string {
|
|
45
|
+
let dir = startDir;
|
|
46
|
+
for (;;) {
|
|
47
|
+
for (const marker of [".git", ".hg", ".svn"]) {
|
|
48
|
+
if (fs.existsSync(path.join(dir, marker))) return dir;
|
|
49
|
+
}
|
|
50
|
+
const parent = path.dirname(dir);
|
|
51
|
+
if (parent === dir) return startDir;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Default the sandbox to ONE PER PROJECT rather than one per process.
|
|
58
|
+
*
|
|
59
|
+
* This is the difference between a ~15 s startup and a ~1 s one. The kernel
|
|
60
|
+
* derives `pi-sbx-<pid>-<rand>` and REMOVES it at teardown, so every pi run had
|
|
61
|
+
* to `sbx create` a brand-new VM — including re-preparing its image layers —
|
|
62
|
+
* while a pinned name makes teardown `none`, so the next run reuses the
|
|
63
|
+
* existing sandbox (sandboxd still idle-stops the VM when pi exits, but the VM,
|
|
64
|
+
* its pulled images and anything installed in it survive).
|
|
65
|
+
*
|
|
66
|
+
* It also matches sbxpi, which keeps one sandbox per project, and it makes runs
|
|
67
|
+
* from a subdirectory land in the same sandbox because the name is derived from
|
|
68
|
+
* the project root, not the cwd.
|
|
69
|
+
*
|
|
70
|
+
* An explicit DOCKER_SANDBOX always wins; SBX_EPHEMERAL=1 restores the old
|
|
71
|
+
* per-session behaviour.
|
|
72
|
+
*/
|
|
73
|
+
export function defaultProjectSandbox(startDir: string): string | undefined {
|
|
74
|
+
const explicit = (process.env.DOCKER_SANDBOX ?? "").trim();
|
|
75
|
+
if (explicit) return sanitizeName(explicit);
|
|
76
|
+
if (/^(1|true|yes|on)$/i.test((process.env.SBX_EPHEMERAL ?? "").trim())) return undefined;
|
|
77
|
+
|
|
78
|
+
const root = findProjectRoot(startDir);
|
|
79
|
+
const name = `pi-sbx-${sanitizeName(path.basename(root) || "project")}-${createHash("sha256").update(root).digest("hex").slice(0, 8)}`;
|
|
80
|
+
process.env.DOCKER_SANDBOX = name;
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
/* optional timing (SBX_PI_DEBUG=1, matching the launcher's convention) */
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
|
|
88
|
+
function debugEnabled(): boolean {
|
|
89
|
+
return /^(1|true|yes|on)$/i.test((process.env.SBX_PI_DEBUG ?? process.env.SBX_DEBUG ?? "").trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function note(message: string): void {
|
|
93
|
+
if (debugEnabled()) console.error(`[sbx] ${message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Time an async startup phase; a no-op passthrough unless debugging is on. */
|
|
97
|
+
async function timed<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
98
|
+
if (!debugEnabled()) return fn();
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
try {
|
|
101
|
+
return await fn();
|
|
102
|
+
} finally {
|
|
103
|
+
console.error(`[sbx] ${label}: ${Date.now() - started}ms`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ExecOptions {
|
|
108
|
+
/** Streamed stdout+stderr (interleaved in arrival order, like a terminal). */
|
|
109
|
+
onData?: (chunk: Buffer) => void;
|
|
110
|
+
/** Abort the command (SIGKILL the child). */
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Timeout in SECONDS (matches pi's BashOperations contract). */
|
|
113
|
+
timeout?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ExecOutcome {
|
|
117
|
+
/** null when the process was killed by a signal. */
|
|
118
|
+
exitCode: number | null;
|
|
119
|
+
stdout: Buffer;
|
|
120
|
+
stderr: Buffer;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ExecTransport {
|
|
124
|
+
readonly kind: "sbx" | "docker";
|
|
125
|
+
readonly target: string;
|
|
126
|
+
/** Run `argv[0] argv[1] ...` inside the sandbox. No shell is implied. */
|
|
127
|
+
exec(argv: string[], opts?: ExecOptions): Promise<ExecOutcome>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Run argv inside the sandbox; buffers stdout/stderr, optionally streams them. */
|
|
131
|
+
function spawnExec(bin: string, prefix: string[], argv: string[], opts: ExecOptions = {}): Promise<ExecOutcome> {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
if (opts.signal?.aborted) {
|
|
134
|
+
reject(new Error("aborted"));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const child = spawn(bin, [...prefix, ...argv], {
|
|
139
|
+
// Minimal safe env: HOME/PATH/USER/... minus DOCKER_*/COMPOSE_*.
|
|
140
|
+
env: scrubbedEnv(),
|
|
141
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
142
|
+
// Own process group, so an abort/timeout can kill the CLI *and* any
|
|
143
|
+
// children it spawned here, instead of orphaning them.
|
|
144
|
+
detached: true,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const stdout: Buffer[] = [];
|
|
148
|
+
const stderr: Buffer[] = [];
|
|
149
|
+
|
|
150
|
+
// Stream interleaved, and keep the raw streams for callers that buffer.
|
|
151
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
152
|
+
stdout.push(chunk);
|
|
153
|
+
opts.onData?.(chunk);
|
|
154
|
+
});
|
|
155
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
156
|
+
stderr.push(chunk);
|
|
157
|
+
opts.onData?.(chunk);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
child.on("error", (err) => {
|
|
161
|
+
cleanup();
|
|
162
|
+
reject(err);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
let timedOut = false;
|
|
166
|
+
// SIGKILL the whole process group: the direct child is the sbx/docker CLI,
|
|
167
|
+
// whose own children would otherwise survive. (A process already running
|
|
168
|
+
// INSIDE the sandbox VM is not reachable this way and may outlive the
|
|
169
|
+
// call - see sandbox/README.md.)
|
|
170
|
+
const killTree = () => {
|
|
171
|
+
try {
|
|
172
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
173
|
+
else child.kill("SIGKILL");
|
|
174
|
+
} catch {
|
|
175
|
+
child.kill("SIGKILL");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const timer =
|
|
179
|
+
opts.timeout && opts.timeout > 0
|
|
180
|
+
? setTimeout(() => {
|
|
181
|
+
timedOut = true;
|
|
182
|
+
killTree();
|
|
183
|
+
}, opts.timeout * 1000)
|
|
184
|
+
: undefined;
|
|
185
|
+
|
|
186
|
+
const onAbort = () => killTree();
|
|
187
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
188
|
+
|
|
189
|
+
function cleanup() {
|
|
190
|
+
if (timer) clearTimeout(timer);
|
|
191
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
child.on("close", (code, signal) => {
|
|
195
|
+
cleanup();
|
|
196
|
+
if (opts.signal?.aborted) {
|
|
197
|
+
reject(new Error("aborted"));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (timedOut) {
|
|
201
|
+
reject(new Error(`timeout:${opts.timeout}`));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
resolve({
|
|
205
|
+
exitCode: signal ? null : code,
|
|
206
|
+
stdout: Buffer.concat(stdout),
|
|
207
|
+
stderr: Buffer.concat(stderr),
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `sbx exec <sandbox> -- ...` — the product path. */
|
|
214
|
+
export function createSbxTransport(sandboxName: string): ExecTransport {
|
|
215
|
+
const bin = findSbxCli();
|
|
216
|
+
return {
|
|
217
|
+
kind: "sbx",
|
|
218
|
+
target: sandboxName,
|
|
219
|
+
exec: (argv, opts) => spawnExec(bin, ["exec", sandboxName, "--"], argv, opts),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `docker exec <container> -- ...` — alternate/test backend.
|
|
225
|
+
*
|
|
226
|
+
* Deliberately NOT the default and never implicit: the default path is sbx,
|
|
227
|
+
* and a container is only used when a container id is supplied explicitly.
|
|
228
|
+
*/
|
|
229
|
+
export function createDockerTransport(container: string): ExecTransport {
|
|
230
|
+
return {
|
|
231
|
+
kind: "docker",
|
|
232
|
+
target: container,
|
|
233
|
+
// docker takes `exec <container> <cmd...>` — it has no `--` separator
|
|
234
|
+
// (unlike sbx), so the prefix differs from the sbx backend.
|
|
235
|
+
exec: (argv, opts) => spawnExec("docker", ["exec", container], argv, opts),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Where template/build.sh records the ref of the lightweight template it built.
|
|
241
|
+
* The handshake is a file rather than configuration so that the user never has
|
|
242
|
+
* to set anything: build the template (or let a launcher build it) and the next
|
|
243
|
+
* session picks it up.
|
|
244
|
+
*/
|
|
245
|
+
function templateRefFile(): string {
|
|
246
|
+
const cache = (process.env.XDG_CACHE_HOME ?? "").trim() || path.join(os.homedir(), ".cache");
|
|
247
|
+
return path.join(cache, "pi-sbx-lite", "template-ref");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Does `sbx template ls` list `tag`? Handles both known output layouts (a
|
|
252
|
+
* `<repo> <version>` table and a flat `<repo>:<version>` list) by matching on
|
|
253
|
+
* the repository basename and version rather than on column positions.
|
|
254
|
+
*/
|
|
255
|
+
export function templateListHas(output: string, tag: string): boolean {
|
|
256
|
+
const separator = tag.lastIndexOf(":");
|
|
257
|
+
if (separator < 0) return false;
|
|
258
|
+
const wantedRepo = tag.slice(0, separator).split("/").pop();
|
|
259
|
+
const wantedVersion = tag.slice(separator + 1);
|
|
260
|
+
for (const raw of output.split("\n")) {
|
|
261
|
+
const line = raw.trim();
|
|
262
|
+
if (!line || /^REPOSITORY/i.test(line)) continue;
|
|
263
|
+
const parts = line.split(/\s+/);
|
|
264
|
+
const first = parts[0] ?? "";
|
|
265
|
+
const basename = first.split("/").pop();
|
|
266
|
+
// Flat layout: one token, "<repo path>/<name>:<version>" — so the tag is
|
|
267
|
+
// the LAST path segment, not the whole token.
|
|
268
|
+
if (parts.length === 1 && basename === tag) return true;
|
|
269
|
+
// Table layout: "<repo> <version>" in separate columns.
|
|
270
|
+
if (basename === wantedRepo && parts[1] === wantedVersion) return true;
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Adopt the lightweight template if one has been recorded AND it still exists.
|
|
277
|
+
*
|
|
278
|
+
* Never fatal and never required: if the file is absent, unreadable, or names a
|
|
279
|
+
* template that has since been removed, we simply leave DOCKER_SANDBOX_TEMPLATE
|
|
280
|
+
* unset and the sandbox is created from the stock base. That is what makes the
|
|
281
|
+
* "the user does nothing" guarantee safe — a missing template degrades speed,
|
|
282
|
+
* it never turns into a failure.
|
|
283
|
+
*
|
|
284
|
+
* An explicit DOCKER_SANDBOX_TEMPLATE always wins.
|
|
285
|
+
*/
|
|
286
|
+
async function useRecordedTemplate(): Promise<void> {
|
|
287
|
+
if ((process.env.DOCKER_SANDBOX_TEMPLATE ?? "").trim()) return;
|
|
288
|
+
let ref: string;
|
|
289
|
+
try {
|
|
290
|
+
ref = fs.readFileSync(templateRefFile(), "utf8").trim();
|
|
291
|
+
} catch {
|
|
292
|
+
return; // never built — stock base is fine
|
|
293
|
+
}
|
|
294
|
+
if (!ref) return;
|
|
295
|
+
try {
|
|
296
|
+
const tag = ref.split("/").slice(-1)[0]; // docker.io/library/x:y -> x:y
|
|
297
|
+
const listed = await runSbxCli(["template", "ls"], 30_000);
|
|
298
|
+
if (templateListHas(`${listed.stdout}\n${listed.stderr}`, tag)) {
|
|
299
|
+
process.env.DOCKER_SANDBOX_TEMPLATE = ref;
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
/* verification failed: fall back to the stock base rather than gamble */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve the transport for this session.
|
|
308
|
+
*
|
|
309
|
+
* `SBX_BACKEND=docker` + `SBX_DOCKER_CONTAINER=<id>` opts into the container
|
|
310
|
+
* backend (test/alternate); anything else auto-provisions and targets an sbx
|
|
311
|
+
* sandbox via the shared kernel.
|
|
312
|
+
*/
|
|
313
|
+
export async function resolveTransport(): Promise<ExecTransport> {
|
|
314
|
+
const startedAt = Date.now();
|
|
315
|
+
const backend = (process.env.SBX_BACKEND ?? "sbx").trim().toLowerCase();
|
|
316
|
+
if (backend === "docker") {
|
|
317
|
+
const container = (process.env.SBX_DOCKER_CONTAINER ?? "").trim();
|
|
318
|
+
if (!container) throw new Error("SBX_BACKEND=docker requires SBX_DOCKER_CONTAINER=<container id or name>");
|
|
319
|
+
note(`backend=docker container=${container} (${Date.now() - startedAt}ms)`);
|
|
320
|
+
return createDockerTransport(container);
|
|
321
|
+
}
|
|
322
|
+
// Both must run BEFORE ensureSandbox(): the first chooses the template it
|
|
323
|
+
// creates from, the second arms the watchdog that reads the keepalive flag.
|
|
324
|
+
await timed("template", () => useRecordedTemplate());
|
|
325
|
+
defaultKeepaliveOn();
|
|
326
|
+
const sandbox = await timed("ensure sandbox (create if missing)", () => ensureSandbox());
|
|
327
|
+
note(
|
|
328
|
+
`backend=sbx sandbox=${sandbox} template=${process.env.DOCKER_SANDBOX_TEMPLATE ?? "stock base"} ` +
|
|
329
|
+
`keepalive=${process.env.DOCKER_SANDBOX_KEEPALIVE} total=${Date.now() - startedAt}ms`,
|
|
330
|
+
);
|
|
331
|
+
return createSbxTransport(sandbox);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Keep the sandbox VM running for as long as the pi process lives.
|
|
336
|
+
*
|
|
337
|
+
* sandboxd stops an idle sandbox ~2-4 min after the last `sbx` call, so
|
|
338
|
+
* without this, the first tool call after any pause pays a multi-second VM
|
|
339
|
+
* boot — the only overhead in this backend that is actually noticeable. The
|
|
340
|
+
* backend therefore defaults keepalive ON, while an explicit
|
|
341
|
+
* `DOCKER_SANDBOX_KEEPALIVE=0` still wins.
|
|
342
|
+
*
|
|
343
|
+
* Note this keeps the VM *running*; it does not keep it *existing*. Teardown is
|
|
344
|
+
* still governed by DOCKER_SANDBOX_TEARDOWN (default `remove` for a
|
|
345
|
+
* session-scoped sandbox), so the sandbox dies with the pi process — pin
|
|
346
|
+
* DOCKER_SANDBOX and use `TEARDOWN=stop` if you want docker images to survive
|
|
347
|
+
* between runs.
|
|
348
|
+
*/
|
|
349
|
+
function defaultKeepaliveOn(): void {
|
|
350
|
+
if ((process.env.DOCKER_SANDBOX_KEEPALIVE ?? "").trim() === "") {
|
|
351
|
+
process.env.DOCKER_SANDBOX_KEEPALIVE = "1";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ------------------------------------------------------------------ */
|
|
356
|
+
/* argv-safe shell helpers */
|
|
357
|
+
/* ------------------------------------------------------------------ */
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Single-quote a value for /bin/sh. Safe for arbitrary bytes except NUL
|
|
361
|
+
* (which cannot appear in an argv anyway).
|
|
362
|
+
*/
|
|
363
|
+
export function shQuote(value: string): string {
|
|
364
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build argv for `sh -c <script> <$0> <args...>`.
|
|
369
|
+
*
|
|
370
|
+
* Passing user values as POSITIONAL params ($1, $2, ...) instead of splicing
|
|
371
|
+
* them into the script means paths and content can never be reinterpreted as
|
|
372
|
+
* shell syntax or as options — the same reason the docker_* tools validate
|
|
373
|
+
* their args.
|
|
374
|
+
*/
|
|
375
|
+
export function shArgs(script: string, ...args: string[]): string[] {
|
|
376
|
+
return ["sh", "-c", script, "sbx-ops", ...args];
|
|
377
|
+
}
|
package/sandbox/try.sh
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# try.sh — try the sbx execution backend for ONE pi run.
|
|
4
|
+
#
|
|
5
|
+
# Loads the extension with `pi -e` (the same way `pix` loads gondolin), so
|
|
6
|
+
# NOTHING is installed and no settings are touched. If it creates a container or
|
|
7
|
+
# a sandbox for you, it cleans it up again on exit.
|
|
8
|
+
#
|
|
9
|
+
# bash sandbox/try.sh # auto: sbx if usable, else docker
|
|
10
|
+
# bash sandbox/try.sh --docker # force a local container
|
|
11
|
+
# bash sandbox/try.sh --sbx # force a real Docker Sandbox
|
|
12
|
+
# bash sandbox/try.sh --discover # also load your discovered extensions
|
|
13
|
+
# # (only if none of them is a router)
|
|
14
|
+
# bash sandbox/try.sh --image sbx-lite # container image for the docker path
|
|
15
|
+
# bash sandbox/try.sh --keep # leave the container running
|
|
16
|
+
# bash sandbox/try.sh -p "run uname -a" # extra args are passed to pi
|
|
17
|
+
#
|
|
18
|
+
# Note: this extension and the gondolin extension both override the same
|
|
19
|
+
# built-in tools, so exactly one of them can be loaded. try.sh therefore runs pi
|
|
20
|
+
# with `--no-extensions` and re-adds only what is needed (this extension, plus
|
|
21
|
+
# env-keys for credentials). Use --discover to opt out of that.
|
|
22
|
+
#
|
|
23
|
+
# The docker path exists so the backend can be exercised on a machine without
|
|
24
|
+
# sbx (including inside another sandbox, where sbx cannot run). It is the same
|
|
25
|
+
# ops layer, only the `exec <target> --` verb differs.
|
|
26
|
+
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
30
|
+
EXT="$HERE/index.ts"
|
|
31
|
+
REPO="$(cd "$HERE/.." && pwd)"
|
|
32
|
+
AGENT_DIR="${PI_AGENT_DIR:-$HOME/.pi/agent}"
|
|
33
|
+
|
|
34
|
+
MODE="auto"
|
|
35
|
+
IMAGE="${SBX_TRY_IMAGE:-debian:stable-slim}"
|
|
36
|
+
CONTAINER="sbx-try-$$"
|
|
37
|
+
KEEP=0
|
|
38
|
+
DISCOVER=0
|
|
39
|
+
PI_ARGS=()
|
|
40
|
+
|
|
41
|
+
while [ $# -gt 0 ]; do
|
|
42
|
+
case "$1" in
|
|
43
|
+
--docker) MODE="docker" ;;
|
|
44
|
+
--sbx) MODE="sbx" ;;
|
|
45
|
+
--keep) KEEP=1 ;;
|
|
46
|
+
--discover) DISCOVER=1 ;;
|
|
47
|
+
--image)
|
|
48
|
+
IMAGE="$2"
|
|
49
|
+
shift
|
|
50
|
+
;;
|
|
51
|
+
-h | --help)
|
|
52
|
+
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
|
53
|
+
exit 0
|
|
54
|
+
;;
|
|
55
|
+
--)
|
|
56
|
+
shift
|
|
57
|
+
PI_ARGS+=("$@")
|
|
58
|
+
break
|
|
59
|
+
;;
|
|
60
|
+
*)
|
|
61
|
+
# Anything else is a pi argument (e.g. -p "...", --model ...).
|
|
62
|
+
PI_ARGS+=("$1")
|
|
63
|
+
;;
|
|
64
|
+
esac
|
|
65
|
+
shift
|
|
66
|
+
done
|
|
67
|
+
|
|
68
|
+
cleanup() {
|
|
69
|
+
if [ "$MODE" = "docker" ] && [ "$KEEP" != "1" ]; then
|
|
70
|
+
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
71
|
+
fi
|
|
72
|
+
}
|
|
73
|
+
trap cleanup EXIT
|
|
74
|
+
|
|
75
|
+
# ── choose a backend ──────────────────────────────────────────────────────
|
|
76
|
+
if [ "$MODE" = "auto" ]; then
|
|
77
|
+
if command -v sbx >/dev/null 2>&1 && sbx ls >/dev/null 2>&1; then
|
|
78
|
+
MODE="sbx"
|
|
79
|
+
else
|
|
80
|
+
MODE="docker"
|
|
81
|
+
fi
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
if [ "$MODE" = "docker" ]; then
|
|
85
|
+
command -v docker >/dev/null 2>&1 || {
|
|
86
|
+
echo "try: no sbx and no docker on PATH — cannot try the backend here." >&2
|
|
87
|
+
echo "try: on your host, install sbx (brew install docker/tap/sbx && sbx login) or docker." >&2
|
|
88
|
+
exit 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Mount the project at its HOST absolute path: that identity is what the
|
|
92
|
+
# whole backend relies on, so the trial must reproduce it.
|
|
93
|
+
echo "try: starting container $CONTAINER from $IMAGE (project mounted at $PWD)"
|
|
94
|
+
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
95
|
+
docker run -d --name "$CONTAINER" -v "$PWD:$PWD" -w "$PWD" "$IMAGE" sleep 86400 >/dev/null
|
|
96
|
+
|
|
97
|
+
export SBX_BACKEND=docker
|
|
98
|
+
export SBX_DOCKER_CONTAINER="$CONTAINER"
|
|
99
|
+
echo "try: routing pi's tools into the container (docker exec)"
|
|
100
|
+
else
|
|
101
|
+
# Real sandbox: the extension auto-provisions it on first use.
|
|
102
|
+
if bash "$REPO/template/build.sh" --check >/dev/null 2>&1; then
|
|
103
|
+
echo "try: lightweight template already current"
|
|
104
|
+
else
|
|
105
|
+
echo "try: no lightweight template yet — sandboxes will use the stock base."
|
|
106
|
+
echo "try: build it once (optional, ~2 min): bash $REPO/template/build.sh"
|
|
107
|
+
fi
|
|
108
|
+
echo "try: pi's tools will run in this project's sbx sandbox (auto-created)"
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
echo "try: extension $EXT"
|
|
112
|
+
|
|
113
|
+
# ── exactly one tool router may be loaded ────────────────────────────────
|
|
114
|
+
# This extension and the gondolin extension both override the SAME built-ins
|
|
115
|
+
# (read/write/edit/bash/ls/find/grep). pi rejects whichever registers second, so
|
|
116
|
+
# with gondolin sitting in the global extensions dir the trial would either
|
|
117
|
+
# error noisily or — worse, if load order ever flips — silently run with the
|
|
118
|
+
# WRONG backend while appearing to work.
|
|
119
|
+
#
|
|
120
|
+
# So discovery is turned off and only what the trial needs is re-added
|
|
121
|
+
# explicitly. `-e` paths still load under `-ne`, which is what it is for.
|
|
122
|
+
EXT_ARGS=(-e "$EXT")
|
|
123
|
+
|
|
124
|
+
if [ "$DISCOVER" = "1" ]; then
|
|
125
|
+
echo "try: --discover given: loading every discovered extension as well"
|
|
126
|
+
elif [ -d "$AGENT_DIR/extensions/gondolin" ]; then
|
|
127
|
+
echo "try: gondolin is installed and would conflict — leaving it out of this run"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# Credentials usually live in ~/.pi/env/keys.env, loaded by the env-keys
|
|
131
|
+
# extension. Turning discovery off would silently drop the model API keys, so
|
|
132
|
+
# it is re-added explicitly when present.
|
|
133
|
+
if [ "$DISCOVER" != "1" ] && [ -d "$AGENT_DIR/extensions/env-keys" ]; then
|
|
134
|
+
EXT_ARGS+=(-e "$AGENT_DIR/extensions/env-keys")
|
|
135
|
+
echo "try: keeping env-keys (provider credentials)"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
echo
|
|
139
|
+
|
|
140
|
+
# NOT `exec`: exec would replace this shell and the EXIT trap would never run,
|
|
141
|
+
# leaking the container. Run pi as a child so cleanup always happens, and pass
|
|
142
|
+
# its exit status through.
|
|
143
|
+
#
|
|
144
|
+
# `${ARR[@]+...}` rather than a bare "${ARR[@]}": under `set -u` an EMPTY array
|
|
145
|
+
# expansion is an "unbound variable" error in bash 3.2, which is what macOS
|
|
146
|
+
# ships as /bin/bash. The idiom expands to nothing when empty and to properly
|
|
147
|
+
# quoted elements otherwise.
|
|
148
|
+
set +e
|
|
149
|
+
if [ "$DISCOVER" = "1" ]; then
|
|
150
|
+
pi ${EXT_ARGS[@]+"${EXT_ARGS[@]}"} ${PI_ARGS[@]+"${PI_ARGS[@]}"}
|
|
151
|
+
else
|
|
152
|
+
pi -ne ${EXT_ARGS[@]+"${EXT_ARGS[@]}"} ${PI_ARGS[@]+"${PI_ARGS[@]}"}
|
|
153
|
+
fi
|
|
154
|
+
STATUS=$?
|
|
155
|
+
set -e
|
|
156
|
+
|
|
157
|
+
exit "$STATUS"
|
package/security.md
CHANGED
|
@@ -28,10 +28,16 @@ own sandbox microVM** and the host's docker is never exposed to it.
|
|
|
28
28
|
4. **Per-session sandboxes.** Each pi session gets a uniquely named sandbox
|
|
29
29
|
(`pi-sbx-<pid>-<random>`, or an explicit `DOCKER_SANDBOX`), auto-provisioned
|
|
30
30
|
with only the session's workspace mounted. Two concurrent sessions cannot
|
|
31
|
-
share or observe each other's docker state.
|
|
31
|
+
share or observe each other's docker state. (The execution backend below
|
|
32
|
+
pins a per-project name instead, which makes the sandbox project-scoped and
|
|
33
|
+
no longer removed at exit — see that section.)
|
|
32
34
|
5. **Filesystem.** Only the session workspace (the dir mounted at `/workspace`
|
|
33
35
|
in the agent VM) is direct-mounted into the sandbox. Host `~/.docker`,
|
|
34
|
-
`~/.ssh`, `~/.agent`, and other host paths are not mounted.
|
|
36
|
+
`~/.ssh`, `~/.agent`, and other host paths are not mounted. Path mapping is
|
|
37
|
+
confined to the workspace: `/workspace/..` traversal, absolute host paths,
|
|
38
|
+
and symlinks that point outside the workspace are all rejected
|
|
39
|
+
(`mapHostPath`), so the agent cannot reach host paths outside the mounted
|
|
40
|
+
workspace via build contexts, volume binds, or `docker_init`.
|
|
35
41
|
6. **Env confidentiality (secure by default).** `DOCKER_*`/`COMPOSE_*` are
|
|
36
42
|
always stripped from every child env. By default only a minimal safe set
|
|
37
43
|
(`HOME`, `PATH`, `USER`, `LOGNAME`, `TMPDIR`, `SHELL`, `LANG`, `TERM`) is
|
|
@@ -40,6 +46,12 @@ own sandbox microVM** and the host's docker is never exposed to it.
|
|
|
40
46
|
`DOCKER_SANDBOX_ENV_PASSTHROUGH=1` (host env minus the docker vars).
|
|
41
47
|
Anything running inside the sandbox can read whatever reaches it — this
|
|
42
48
|
knob confines that surface.
|
|
49
|
+
7. **Input validation.** Tool arguments that reach the docker CLI positionally
|
|
50
|
+
(container ids, image refs, tags, names) are validated to reject values that
|
|
51
|
+
start with `-` (which docker would parse as flags) or contain control
|
|
52
|
+
characters. `docker_run` validates port/volume/memory specs, and
|
|
53
|
+
`docker_curl` is restricted to a safe HTTP-method allowlist and a 1 MiB body
|
|
54
|
+
limit.
|
|
43
55
|
|
|
44
56
|
### Read-only workspace mode (`DOCKER_SANDBOX_WORKSPACE_RO=1`)
|
|
45
57
|
|
|
@@ -58,6 +70,43 @@ project as an **additional read-only** workspace. Consequences (all verified):
|
|
|
58
70
|
its VM) — the intended trust boundary. The sandbox becomes a pure
|
|
59
71
|
read-and-execute environment for the project.
|
|
60
72
|
|
|
73
|
+
Read-only mode is **not** usable together with the execution backend below:
|
|
74
|
+
that backend's `write`/`edit`/`mkdir` operate on the mounted project, so they
|
|
75
|
+
would simply fail.
|
|
76
|
+
|
|
77
|
+
## Execution backend (built-in tools routed into the sandbox)
|
|
78
|
+
|
|
79
|
+
The `sandbox/` entry point uses the *same* sandbox differently: pi stays on the
|
|
80
|
+
host and `bash`, `read`, `write`, `edit`, `grep`, `find`, `ls` plus the user's
|
|
81
|
+
`!` commands execute **inside** it (see [sandbox/README.md](sandbox/README.md)).
|
|
82
|
+
It protects a different thing, so it carries its own guarantees:
|
|
83
|
+
|
|
84
|
+
1. **The host environment does not leak in.** pi's `bash` tool builds its child
|
|
85
|
+
environment from the *full* host environment, so forwarding it verbatim
|
|
86
|
+
would copy host API keys and tokens into the sandbox. Only `PI_*` session
|
|
87
|
+
metadata is forwarded (plus names listed in `DOCKER_SANDBOX_ENV_ALLOWLIST`),
|
|
88
|
+
and `DOCKER_*`/`COMPOSE_*` can never be allowlisted. Covered by a test.
|
|
89
|
+
2. **The agent's file access is confined to the sandbox.** Because the file
|
|
90
|
+
tools are overridden, not just the shell, an absolute path resolves inside
|
|
91
|
+
the sandbox — no built-in tool can read or write a host path outside the
|
|
92
|
+
mounted workspace.
|
|
93
|
+
3. **No shell-injection surface.** Paths and file bodies are passed as
|
|
94
|
+
*positional* arguments to `sh -c`, never spliced into the script text, so a
|
|
95
|
+
path cannot be reinterpreted as shell syntax or as a CLI option.
|
|
96
|
+
4. **`grep` is reimplemented over the transport.** pi's grep tool spawns host
|
|
97
|
+
ripgrep regardless of custom operations; the backend walks and matches
|
|
98
|
+
in-sandbox instead, so a search cannot silently read host files.
|
|
99
|
+
|
|
100
|
+
What this mode does **not** do:
|
|
101
|
+
|
|
102
|
+
- **It does not confine pi's own process.** Extension tools other than these
|
|
103
|
+
built-in overrides still run on the host (subagents, `webshot`, ...). This is
|
|
104
|
+
a sandbox for the tools, not a wrapper around pi.
|
|
105
|
+
- The sandbox has network access per its sbx network policy, and its own `/etc`,
|
|
106
|
+
`/usr` and so on are readable by the agent.
|
|
107
|
+
- Killing the `sbx`/`docker` CLI on abort does not necessarily kill a process
|
|
108
|
+
already running inside the VM.
|
|
109
|
+
|
|
61
110
|
## Live verification: `docker_verify`
|
|
62
111
|
|
|
63
112
|
The `docker_verify` tool runs a runtime audit of the sandbox and reports
|
|
@@ -115,3 +164,6 @@ sandboxes are never touched.
|
|
|
115
164
|
level as any agent tooling writing to the workspace.
|
|
116
165
|
- `sbx` port forwarding binds `127.0.0.1` on the host; apps inside the sandbox
|
|
117
166
|
are not reachable from the LAN unless the host user forwards further.
|
|
167
|
+
- `docker_curl` is confined to ports the sandbox itself published (it reads the
|
|
168
|
+
live `sbx ports` mappings and rejects any other host-localhost port), so it
|
|
169
|
+
cannot be used to probe unrelated host services on localhost.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
#
|
|
3
|
+
# Lightweight sandbox baseline — the docker-buildable form of install.sh.
|
|
4
|
+
#
|
|
5
|
+
# The sbx snapshot itself is built by template/build.sh (which runs the same
|
|
6
|
+
# install.sh inside a sandbox and `sbx template save`s the result). This
|
|
7
|
+
# Dockerfile exists so the recipe can be BUILT AND MEASURED anywhere docker
|
|
8
|
+
# runs, including CI and machines where sbx cannot start:
|
|
9
|
+
#
|
|
10
|
+
# docker build -f template/Dockerfile -t sbx-lite .
|
|
11
|
+
# docker images sbx-lite --format '{{.Size}}'
|
|
12
|
+
#
|
|
13
|
+
# It is also a perfectly good sandbox image on its own for the docker backend of
|
|
14
|
+
# the sandbox extension (SBX_BACKEND=docker).
|
|
15
|
+
#
|
|
16
|
+
# Ubuntu, because an sbx sandbox is always Ubuntu; using anything else here
|
|
17
|
+
# would make the measured size unrepresentative of the real template.
|
|
18
|
+
|
|
19
|
+
FROM ubuntu:24.04
|
|
20
|
+
|
|
21
|
+
ARG NODE_VERSION=22.22.1
|
|
22
|
+
ARG SBX_LITE_BUILD=on
|
|
23
|
+
ARG SBX_LITE_PNPM=on
|
|
24
|
+
ENV NODE_VERSION=${NODE_VERSION} \
|
|
25
|
+
SBX_LITE_BUILD=${SBX_LITE_BUILD} \
|
|
26
|
+
SBX_LITE_PNPM=${SBX_LITE_PNPM} \
|
|
27
|
+
LANG=C.UTF-8
|
|
28
|
+
|
|
29
|
+
# Context is the repository root (see the build command above), so this path is
|
|
30
|
+
# repo-relative rather than relative to this Dockerfile.
|
|
31
|
+
COPY template/install.sh /tmp/install.sh
|
|
32
|
+
RUN bash /tmp/install.sh && rm -f /tmp/install.sh
|
|
33
|
+
|
|
34
|
+
WORKDIR /workspace
|