@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.
@@ -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