@stixxert/pi-docker-sandbox 0.1.0 → 1.0.1

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 CHANGED
@@ -4,6 +4,15 @@ A [pi](https://pi.dev) extension that gives an AI coding agent a **private
4
4
  docker sandbox** to deploy into — powered by
5
5
  [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) (`sbx`).
6
6
 
7
+ **How this sandboxes pi.** There are many ways to sandbox pi: wrap the whole
8
+ agent in a container or dedicated VM, confine it in a locked-down workspace,
9
+ or — as this project does — give pi a sandbox *to deploy into*. This is a
10
+ **pi extension, not a wrapper**: pi itself is never contained. It keeps
11
+ running in its normal host environment with its usual tools, workspace, and
12
+ agent micro-VM; the extension only adds a separate private microVM — with its
13
+ own docker daemon — as the agent's deploy target. What gets sandboxed here is
14
+ the docker work, not the pi process.
15
+
7
16
  Each pi session gets its own **sandbox microVM with its own docker daemon**,
8
17
  running in parallel to the agent. The agent can pull images, build, run
9
18
  containers, and `docker compose up` — while the **host's docker is never
@@ -105,7 +114,7 @@ workspace mounted). No manual steps required.
105
114
  | `docker_init` | Scaffold a Dockerfile (+ compose) with language detection |
106
115
  | `docker_compose` | Deploy/manage compose projects (`up -d --build`) |
107
116
  | `docker_stop` / `docker_start` / `docker_rm` | Container lifecycle |
108
- | `docker_curl` | Probe a published port from the host process (GET/POST/PUT) |
117
+ | `docker_curl` | Probe a published port from the host process (GET/POST/PUT; only ports this sandbox published) |
109
118
  | `docker_sandbox_rm` | Remove this session's sandbox (microVM + everything inside) |
110
119
  | `docker_gc` | Sweep stale `pi-sbx-*` sandboxes left by crashed sessions |
111
120
 
@@ -164,7 +173,8 @@ persistent name (e.g. a shared sandbox reused across restarts), pin
164
173
  mappings; after a host reboot let the session-start GC handle it, or run
165
174
  `sbx rm --force $(sbx ls -q | grep pi-sbx)` from a host pane.
166
175
  - Agent-side verification: `docker_curl` (host-side fetch, GET/POST/PUT with
167
- optional body); human-side: `http://localhost:<hostport>/`.
176
+ optional body, confined to ports this sandbox published); human-side:
177
+ `http://localhost:<hostport>/`.
168
178
 
169
179
  See [boundary.md](boundary.md) for the full agent ↔ sbx boundary
170
180
  model and usage guide (agent + human). The extension is not dependent on
@@ -204,6 +214,11 @@ The agent's VM mounts the workspace at `/workspace`; the extension maps
204
214
  sandbox (the workspace is direct-mounted there). So `docker_build` contexts
205
215
  and `docker_run` volume binds "just work".
206
216
 
217
+ The mapping is **confined to the workspace**: `/workspace/..` traversal,
218
+ absolute host paths, and symlinks that point outside the workspace are all
219
+ rejected, so the agent cannot reach host paths outside the mounted workspace
220
+ via build contexts, volume binds, or `docker_init`.
221
+
207
222
  ## Example deploy flow
208
223
 
209
224
  ```
@@ -266,6 +281,35 @@ directly by pi (copy it to `~/.pi/agent/extensions/docker-sandbox.ts`, or
266
281
  pi package manifest (`pi.extensions`) and declares typecheck-only dev deps;
267
282
  `tsconfig.json` keeps `tsc --strict` honest against the pi SDK types.
268
283
 
284
+ ## Releases (CI/CD)
285
+
286
+ Releases are fully automated with [semantic-release](https://semantic-release.gitbook.io/)
287
+ via GitHub Actions (`.github/workflows/release.yml`). The **commit messages
288
+ themselves signal the release**: push to `main` and CI analyzes commits since
289
+ the last release tag, then bumps the version, tags, creates a GitHub Release,
290
+ and publishes to npm — only when there is something releasable.
291
+
292
+ | Commit message | Version bump |
293
+ |---|---|
294
+ | `fix: ...` | patch (`1.0.0` → `1.0.1`) |
295
+ | `feat: ...` | minor (`1.0.0` → `1.1.0`) |
296
+ | `BREAKING CHANGE: ...` (in body or footer) | major (`1.0.0` → `2.0.0`) |
297
+ | anything else (`docs:`, `chore:`, `refactor:`, …) | no release |
298
+
299
+ One-time setup: configure **trusted publishing** on npm so the workflow can
300
+ publish via OIDC — no npm token stored anywhere. On npmjs.com → package
301
+ `@stixxert/pi-docker-sandbox` → Settings → **Trusted Publisher**, add:
302
+
303
+ - Organization or user: `stixxert`
304
+ - Repository: `pi-docker-sandbox`
305
+ - Workflow filename: `release.yml`
306
+ - Environment name: *(leave empty)*
307
+ - Allowed actions: `npm publish`
308
+
309
+ `GITHUB_TOKEN` needs no setup. The first push to `main` containing a
310
+ `fix:`/`feat:` commit publishes the initial version. Provenance attestations
311
+ are generated automatically with trusted publishing.
312
+
269
313
  ## License
270
314
 
271
315
  Apache-2.0 — see [LICENSE](LICENSE).
package/boundary.md CHANGED
@@ -65,7 +65,7 @@ host macOS (trusted operator)
65
65
  | Host env confidentiality | `DOCKER_*`/`COMPOSE_*` always stripped; secure by default (only a minimal safe set is forwarded); `DOCKER_SANDBOX_ENV_ALLOWLIST` opts in to specific vars, `_PASSTHROUGH` opts out entirely; `docker_verify` audits the active mode |
66
66
  | Host files untouched | Only the session workspace is mounted into the sandbox; host `~/.docker`, `~/.ssh` etc. are not visible (audited by `docker_verify`) |
67
67
  | Project writes (optional) | `DOCKER_SANDBOX_WORKSPACE_RO=1` → project mounted read-only; all project writes must go through the agent's own tools |
68
- | Ports | Published on host `127.0.0.1` only (`sbx ports`); the agent probes them with `docker_curl` (host-side fetch); the agent's VM cannot reach host loopback |
68
+ | Ports | Published on host `127.0.0.1` only (`sbx ports`); the agent probes them with `docker_curl` (host-side fetch, confined to ports this sandbox published); the agent's VM cannot reach host loopback |
69
69
 
70
70
  ## Ports & networking (verified rules)
71
71
 
@@ -98,7 +98,8 @@ host macOS (trusted operator)
98
98
  pokes the sandbox every ~60s while the pi session is alive).
99
99
  6. **Verification paths**:
100
100
  - Agent → `docker_curl http://127.0.0.1:<hostport>/` (host-side fetch;
101
- GET/POST/PUT with optional body).
101
+ GET/POST/PUT with optional body; only ports this sandbox published are
102
+ reachable).
102
103
  - Sandbox-internal → `docker_exec` against `127.0.0.1:<port>` (same docker
103
104
  network).
104
105
  - Human → open `http://localhost:<hostport>/` on the host.
package/index.ts CHANGED
@@ -228,15 +228,81 @@ function splitCommand(cmd: string): string[] {
228
228
  return out;
229
229
  }
230
230
 
231
+ /** Real path of the workspace root (resolved once; follows symlinks in the path). */
232
+ let realHostRoot: string | undefined;
233
+ function realHostRootPath(): string {
234
+ if (realHostRoot === undefined) {
235
+ try {
236
+ realHostRoot = fs.realpathSync(hostRoot);
237
+ } catch {
238
+ realHostRoot = hostRoot;
239
+ }
240
+ }
241
+ return realHostRoot;
242
+ }
243
+
244
+ /**
245
+ * True if `target` resolves (through symlinks) to a path inside the workspace
246
+ * root. Handles non-existent targets by resolving the deepest existing
247
+ * ancestor and re-appending the missing suffix.
248
+ */
249
+ function realpathWithin(target: string): boolean {
250
+ const root = realHostRootPath();
251
+ let probe = target;
252
+ const suffix: string[] = [];
253
+ while (!fs.existsSync(probe)) {
254
+ const parent = path.dirname(probe);
255
+ if (parent === probe) return false; // reached filesystem root
256
+ suffix.unshift(path.basename(probe));
257
+ probe = parent;
258
+ }
259
+ let realProbe: string;
260
+ try {
261
+ realProbe = fs.realpathSync(probe);
262
+ } catch {
263
+ return false;
264
+ }
265
+ const realTarget = suffix.length ? path.join(realProbe, ...suffix) : realProbe;
266
+ const rel = path.relative(root, realTarget);
267
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
268
+ }
269
+
270
+ /** Reject values that could be interpreted as docker CLI flags or inject output. */
271
+ function assertSafeArg(value: string, what: string): void {
272
+ if (!value || value.startsWith("-")) {
273
+ throw new Error(`docker: invalid ${what} "${value}" (must not be empty or start with "-")`);
274
+ }
275
+ if (/[\r\n\x00]/.test(value)) {
276
+ throw new Error(`docker: invalid ${what} (must not contain newlines or NUL)`);
277
+ }
278
+ }
279
+
231
280
  function mapHostPath(input: string): string {
232
281
  const trimmed = (input ?? "").trim();
233
282
  if (!trimmed) throw new Error("docker: empty path");
283
+ let resolved: string;
234
284
  if (trimmed.startsWith("/workspace")) {
235
285
  const rel = trimmed.slice("/workspace".length).replace(/^\/+/, "");
236
- return rel ? path.join(hostRoot, rel) : hostRoot;
286
+ resolved = rel ? path.join(hostRoot, rel) : hostRoot;
287
+ } else if (path.isAbsolute(trimmed)) {
288
+ resolved = trimmed;
289
+ } else {
290
+ resolved = path.resolve(hostRoot, trimmed);
291
+ }
292
+ // Confine to the workspace: reject any path that escapes hostRoot. This is
293
+ // the core isolation guarantee — the agent must not be able to reach host
294
+ // paths outside the mounted workspace (via /workspace/.. traversal or an
295
+ // absolute host path), because these paths are used in host-side fs calls
296
+ // (existence checks, docker_init writes) as well as sandbox-side mounts.
297
+ const rel = path.relative(hostRoot, resolved);
298
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
299
+ throw new Error(`docker: path escapes the workspace: ${input}`);
237
300
  }
238
- if (path.isAbsolute(trimmed)) return trimmed;
239
- return path.resolve(hostRoot, trimmed);
301
+ // Also reject symlinks inside the workspace that point outside it.
302
+ if (!realpathWithin(resolved)) {
303
+ throw new Error(`docker: path escapes the workspace (symlink): ${input}`);
304
+ }
305
+ return resolved;
240
306
  }
241
307
 
242
308
  /* ------------------------------------------------------------------ */
@@ -252,7 +318,13 @@ function runSbxCli(args: string[], timeoutMs?: number): Promise<ExecResult> {
252
318
  args,
253
319
  { env: scrubbedEnv(), timeout: timeoutMs, maxBuffer: 128 * 1024 * 1024, windowsHide: true },
254
320
  (err, stdout, stderr) => {
255
- const code = err ? (err as NodeJS.ErrnoException & { code?: number }).code ?? 1 : 0;
321
+ // execFile's err.code is string (e.g. "ENOENT") | number (exit code) | null (signal).
322
+ // Normalize to a number so callers can compare reliably.
323
+ let code = 0;
324
+ if (err) {
325
+ const c = (err as NodeJS.ErrnoException).code;
326
+ code = typeof c === "number" ? c : 1;
327
+ }
256
328
  resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
257
329
  },
258
330
  );
@@ -276,13 +348,13 @@ async function ensureSandbox(): Promise<string> {
276
348
  }
277
349
  const cpus = env.DOCKER_SANDBOX_CPUS ?? "2";
278
350
  let mem = env.DOCKER_SANDBOX_MEMORY ?? "2g";
279
- // sbx requires >= 1 GiB of memory
280
- const m = /^(\d+)\s*([gGmM])?$/.exec(mem.trim());
351
+ // sbx requires >= 1 GiB of memory (accept decimal values like 2.5g / 512m).
352
+ const m = /^(\d+(?:\.\d+)?)\s*([gGmM])?$/.exec(mem.trim());
281
353
  if (m) {
282
354
  const v = Number(m[1]);
283
355
  const unit = (m[2] ?? "g").toLowerCase();
284
- if (unit === "g" && v < 1) mem = "1g";
285
- if (unit === "m" && v < 1024) mem = "1g";
356
+ const giB = unit === "m" ? v / 1024 : v;
357
+ if (giB < 1) mem = "1g";
286
358
  } else {
287
359
  mem = "2g";
288
360
  }
@@ -586,6 +658,7 @@ async function toolPs(all: boolean): Promise<string> {
586
658
  }
587
659
 
588
660
  async function toolPull(image: string): Promise<string> {
661
+ assertSafeArg(image, "image");
589
662
  const out = await docker(["pull", image], 600_000);
590
663
  const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
591
664
  const interesting = lines.filter((l) => /Status:|Digest:|Downloaded newer|up to date/i.test(l));
@@ -628,16 +701,32 @@ async function toolRun(params: RunParams): Promise<string> {
628
701
  const args = ["run"];
629
702
  args.push("--label", "com.pi.sandbox=true");
630
703
  const foreground = params.detach === false;
704
+ if (params.rm && !foreground) {
705
+ throw new Error("docker run: --rm cannot be combined with a detached run (detach defaults to true); use detach=false for a foreground run that auto-removes");
706
+ }
631
707
  if (!foreground) args.push("-d");
632
708
  if (params.rm) args.push("--rm");
633
- if (params.name) args.push("--name", params.name);
634
- for (const p of params.ports ?? []) args.push("-p", p);
709
+ if (params.name) {
710
+ assertSafeArg(params.name, "container name");
711
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(params.name)) {
712
+ throw new Error(`docker run: invalid container name "${params.name}"`);
713
+ }
714
+ args.push("--name", params.name);
715
+ }
716
+ for (const p of params.ports ?? []) {
717
+ const spec = p.trim();
718
+ if (!/^\d+(?::\d+)?(\/(udp|tcp))?$/.test(spec)) {
719
+ throw new Error(`docker run: bad port spec "${p}" (use HOST:CONTAINER[/udp], e.g. "8080:3000")`);
720
+ }
721
+ args.push("-p", spec);
722
+ }
635
723
  for (const e of Array.isArray(params.env) ? params.env : (params.env ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
636
- if (e) args.push("-e", e);
724
+ if (e.startsWith("-")) throw new Error(`docker run: bad env entry "${e}"`);
725
+ args.push("-e", e);
637
726
  }
638
727
  for (const v of params.volumes ?? []) {
639
728
  const parts = v.split(":");
640
- if (parts.length < 2) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
729
+ if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
641
730
  const hostPart = mapHostPath(parts[0]);
642
731
  const rest = parts.slice(1).join(":");
643
732
  // In RO-workspace mode, binds sourced from the project are read-only by
@@ -650,8 +739,14 @@ async function toolRun(params: RunParams): Promise<string> {
650
739
  // sandbox VM being idle-stopped by sandboxd; foreground runs stay ephemeral.
651
740
  const restart = params.restart ?? (foreground ? "no" : "unless-stopped");
652
741
  if (restart && restart !== "no") args.push("--restart", restart);
653
- if (params.memory) args.push("-m", params.memory);
742
+ if (params.memory) {
743
+ if (!/^\d+(\.\d+)?[bkmg]?$/i.test(params.memory.trim())) {
744
+ throw new Error(`docker run: bad memory limit "${params.memory}" (use e.g. 512m, 1g)`);
745
+ }
746
+ args.push("-m", params.memory.trim());
747
+ }
654
748
  if (params.workdir) args.push("-w", params.workdir);
749
+ assertSafeArg(params.image, "image");
655
750
  args.push(params.image);
656
751
  if (params.command) {
657
752
  const cmd = Array.isArray(params.command) ? params.command : splitCommand(params.command);
@@ -715,6 +810,7 @@ async function toolRun(params: RunParams): Promise<string> {
715
810
  }
716
811
 
717
812
  async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<string> {
813
+ assertSafeArg(id, "container id");
718
814
  const args = ["logs"];
719
815
  if (tail > 0) args.push("--tail", String(tail));
720
816
  if (timestamps) args.push("--timestamps");
@@ -724,6 +820,10 @@ async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<
724
820
  }
725
821
 
726
822
  async function toolExec(id: string, command: string | string[]): Promise<string> {
823
+ if (command === undefined || command === null || (typeof command === "string" && !command.trim())) {
824
+ throw new Error("docker exec: empty command");
825
+ }
826
+ assertSafeArg(id, "container id");
727
827
  const cmd = Array.isArray(command) ? command : splitCommand(command);
728
828
  if (!cmd.length) throw new Error("docker exec: empty command");
729
829
  const out = await docker(["exec", id, ...cmd]);
@@ -741,8 +841,16 @@ async function toolBuild(
741
841
  const stat = fs.statSync(hostContext);
742
842
  if (!stat.isDirectory()) throw new Error(`docker build: context must be a directory: ${context}`);
743
843
 
844
+ assertSafeArg(tag, "tag");
744
845
  const args = ["build", "-t", tag];
745
- if (dockerfile) args.push("-f", path.join(hostContext, dockerfile));
846
+ if (dockerfile) {
847
+ const df = path.resolve(hostContext, dockerfile);
848
+ const dfRel = path.relative(hostContext, df);
849
+ if (dfRel === ".." || dfRel.startsWith(`..${path.sep}`) || path.isAbsolute(dfRel)) {
850
+ throw new Error(`docker build: dockerfile path escapes the context: ${dockerfile}`);
851
+ }
852
+ args.push("-f", df);
853
+ }
746
854
  if (buildArgs) {
747
855
  try {
748
856
  const parsed = JSON.parse(buildArgs) as Record<string, unknown>;
@@ -862,6 +970,12 @@ async function toolCompose(
862
970
  }
863
971
 
864
972
  const args = ["compose", "-f", hostFile];
973
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(action)) {
974
+ throw new Error(`docker compose: invalid action "${action}"`);
975
+ }
976
+ if (service && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(service)) {
977
+ throw new Error(`docker compose: invalid service "${service}"`);
978
+ }
865
979
  switch (action) {
866
980
  case "up":
867
981
  args.push("up", "-d", "--build");
@@ -989,6 +1103,7 @@ async function unpublishMappingsFor(sandboxPorts: Set<string>): Promise<string[]
989
1103
  }
990
1104
 
991
1105
  async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<string> {
1106
+ assertSafeArg(id, "container id");
992
1107
  switch (op) {
993
1108
  case "stop":
994
1109
  await docker(["stop", "--time", "10", id]);
@@ -1024,6 +1139,27 @@ async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<s
1024
1139
  }
1025
1140
  }
1026
1141
 
1142
+ /** HTTP methods docker_curl may issue (no CONNECT/TRACE — no tunneling). */
1143
+ const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"]);
1144
+ /** Max request body size for docker_curl (1 MiB). */
1145
+ const MAX_CURL_BODY = 1024 * 1024;
1146
+
1147
+ /** Host ports currently published by this session's sandbox (from `sbx ports`). */
1148
+ async function publishedHostPorts(): Promise<Set<number>> {
1149
+ const name = sessionSandboxName();
1150
+ const out = new Set<number>();
1151
+ try {
1152
+ const r = await runSbxCli(["ports", name]);
1153
+ for (const line of r.stdout.split("\n")) {
1154
+ const m = /127\.0\.0\.1\s+(\d+)\s+\d+\s+(tcp|udp)/.exec(line);
1155
+ if (m) out.add(Number(m[1]));
1156
+ }
1157
+ } catch {
1158
+ /* no published ports */
1159
+ }
1160
+ return out;
1161
+ }
1162
+
1027
1163
  /** Host-side HTTP request to a host-local published port (sbx forwards 127.0.0.1 only). */
1028
1164
  async function toolCurl(url: string, timeoutSec: number, method: string, body: string | undefined): Promise<string> {
1029
1165
  let u: URL;
@@ -1032,22 +1168,45 @@ async function toolCurl(url: string, timeoutSec: number, method: string, body: s
1032
1168
  } catch {
1033
1169
  throw new Error(`docker_curl: invalid URL "${url}" (use e.g. http://127.0.0.1:8080/health)`);
1034
1170
  }
1035
- if (!["127.0.0.1", "localhost", "::1"].includes(u.hostname)) {
1171
+ // URL.hostname keeps brackets for IPv6 literals ("[::1]"); normalize for the
1172
+ // allowlist check and for http.request.
1173
+ const host = u.hostname.replace(/^\[|\]$/g, "");
1174
+ if (!["127.0.0.1", "localhost", "::1"].includes(host)) {
1036
1175
  throw new Error(
1037
1176
  `docker_curl: only host-local published ports are reachable from the host process ` +
1038
1177
  `(sbx binds 127.0.0.1; tried host "${u.hostname}"). For the sandbox-internal address use docker_exec.`,
1039
1178
  );
1040
1179
  }
1180
+ const port = Number(u.port || 80);
1181
+ // Confine to ports THIS sandbox actually published — docker_curl is for
1182
+ // verifying a deployed container, not a general host-localhost HTTP client
1183
+ // (which could otherwise probe unrelated host services on localhost).
1184
+ const published = await publishedHostPorts();
1185
+ if (!published.has(port)) {
1186
+ throw new Error(
1187
+ `docker_curl: port ${port} is not published by this sandbox. ` +
1188
+ `Published host ports: ${published.size ? [...published].sort((a, b) => a - b).join(", ") : "(none)"}. ` +
1189
+ `Start a container with docker_run(ports=[...]) or docker_compose up first.`,
1190
+ );
1191
+ }
1041
1192
  const meth = (method || "GET").toUpperCase();
1193
+ if (!ALLOWED_METHODS.has(meth)) {
1194
+ throw new Error(`docker_curl: unsupported method "${method}" (allowed: ${[...ALLOWED_METHODS].join(", ")})`);
1195
+ }
1042
1196
  const hasBody = body !== undefined;
1197
+ if (hasBody && Buffer.byteLength(body ?? "") > MAX_CURL_BODY) {
1198
+ throw new Error(`docker_curl: body exceeds ${MAX_CURL_BODY} bytes`);
1199
+ }
1200
+ const timeoutMs = (Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 10) * 1000;
1043
1201
  const result = await new Promise<{ status: number; headers: http.IncomingHttpHeaders; text: string }>((resolve, reject) => {
1044
1202
  const req = http.request(
1045
1203
  {
1046
- host: u.hostname,
1047
- port: Number(u.port || 80),
1204
+ host,
1205
+ port,
1048
1206
  path: `${u.pathname}${u.search}`,
1049
1207
  method: meth,
1050
- timeout: Math.max(1, timeoutSec) * 1000,
1208
+ timeout: timeoutMs,
1209
+ ...(host.includes(":") ? { family: 6 } : {}),
1051
1210
  headers: {
1052
1211
  accept: "*/*",
1053
1212
  "user-agent": "pi-docker-sandbox/1",
@@ -1085,6 +1244,10 @@ async function toolInit(
1085
1244
  return `docker_init: ${path.join(dir, "Dockerfile")} already exists (pass force=true to overwrite).`;
1086
1245
  }
1087
1246
 
1247
+ const LANGS = new Set(["node", "pnpm", "go", "python", "rust", "generic"]);
1248
+ if (opts.lang && !LANGS.has(opts.lang.toLowerCase())) {
1249
+ throw new Error(`docker_init: unknown lang "${opts.lang}" (use node|pnpm|go|python|rust|generic)`);
1250
+ }
1088
1251
  const lang: Lang = opts.lang ? (opts.lang.toLowerCase() as Lang) : detectLang(dir);
1089
1252
  const port = opts.lang === "go" || lang === "go" ? 8080 : lang === "python" ? 8000 : lang === "generic" ? 8080 : 3000;
1090
1253
 
@@ -1119,15 +1282,18 @@ async function toolVerify(): Promise<string> {
1119
1282
  // DOCKER_* sentinel is injected into the HOST process env; runSbxCli's
1120
1283
  // scrubber must strip it before the sbx exec child (and thus the sandbox)
1121
1284
  // ever sees it. If the scrubber regresses, the sentinel leaks and fails.
1122
- env.DOCKER_HOST_SENTINEL = "sbx-scrub-probe";
1285
+ // The sentinel name is unique per call so concurrent verifies cannot
1286
+ // interfere with each other's probe.
1287
+ const sentinel = `DOCKER_HOST_SENTINEL_${Math.random().toString(36).slice(2, 8)}`;
1288
+ env[sentinel] = "sbx-scrub-probe";
1123
1289
  let envOk = false;
1124
1290
  let envOut = "";
1125
1291
  try {
1126
1292
  const envCheck = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep -iE '^(DOCKER_|COMPOSE_)' || echo __CLEAN__"]);
1127
1293
  envOut = `${envCheck.stdout}\n${envCheck.stderr}`.trim();
1128
- envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes("DOCKER_HOST_SENTINEL");
1294
+ envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes(sentinel);
1129
1295
  } finally {
1130
- delete env.DOCKER_HOST_SENTINEL;
1296
+ delete env[sentinel];
1131
1297
  }
1132
1298
  results.push({
1133
1299
  check: "env: no DOCKER_*/COMPOSE_* variables leak into the sandbox",
@@ -1140,15 +1306,16 @@ async function toolVerify(): Promise<string> {
1140
1306
  // reach the sandbox (probe technique, but for a NON-docker var so it is
1141
1307
  // subject to the allowlist gate, not just the docker scrub).
1142
1308
  if (!envPassthrough()) {
1143
- env.__PI_DOCKER_SANDBOX_VERIFY_PROBE__ = "sbx-env-probe";
1309
+ const probeName = `__PI_DOCKER_SANDBOX_VERIFY_PROBE_${Math.random().toString(36).slice(2, 8)}__`;
1310
+ env[probeName] = "sbx-env-probe";
1144
1311
  let probeOk = false;
1145
1312
  let probeOut = "";
1146
1313
  try {
1147
- const probe = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep __PI_DOCKER_SANDBOX_VERIFY_PROBE__ || echo __PROBE_ABSENT__"]);
1314
+ const probe = await runSbxCli(["exec", name, "--", "sh", "-c", `env | grep -F ${probeName} || echo __PROBE_ABSENT__`]);
1148
1315
  probeOut = `${probe.stdout}\n${probe.stderr}`.trim();
1149
- probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes("__PI_DOCKER_SANDBOX_VERIFY_PROBE__");
1316
+ probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes(probeName);
1150
1317
  } finally {
1151
- delete env.__PI_DOCKER_SANDBOX_VERIFY_PROBE__;
1318
+ delete env[probeName];
1152
1319
  }
1153
1320
  results.push({
1154
1321
  check: "env: restricted forwarding (allowlist/strict) — non-allowlisted vars do not reach the sandbox",
@@ -1229,14 +1396,15 @@ async function toolVerify(): Promise<string> {
1229
1396
  }
1230
1397
  results.push(sockResult);
1231
1398
 
1232
- // 7. port bindings are host-localhost only
1233
- const ls = await runSbxCli(["ls"]);
1234
- const row = ls.stdout.split("\n").find((l) => l.includes(name)) ?? "";
1235
- const nonLocal = /0\.0\.0\.0:[0-9]/.test(row) && !/127\.0\.0\.1/.test(row);
1399
+ // 7. port bindings are host-localhost only (inspect the actual `sbx ports`
1400
+ // mappings rather than the `sbx ls` row, which may not render ports).
1401
+ const portsList = await runSbxCli(["ports", name]);
1402
+ const portLines = portsList.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
1403
+ const nonLocal = portLines.some((l) => !/^127\.0\.0\.1\s/.test(l) && /\d+\s+\d+\s+(tcp|udp)/.test(l));
1236
1404
  results.push({
1237
1405
  check: "network: published ports bind to host 127.0.0.1 only",
1238
1406
  ok: !nonLocal,
1239
- evidence: nonLocal ? row.trim() : (row.trim() || "no published ports currently"),
1407
+ evidence: nonLocal ? portLines.filter((l) => !/^127\.0\.0\.1\s/.test(l)).join("; ") : (portLines.join("; ") || "no published ports currently"),
1240
1408
  });
1241
1409
 
1242
1410
  const failed = results.filter((r) => !r.ok);
@@ -1269,6 +1437,8 @@ function keepalive(): boolean {
1269
1437
  return v === "1" || v === "true" || v === "yes" || v === "on";
1270
1438
  }
1271
1439
 
1440
+ let watchdogArmed = false;
1441
+
1272
1442
  /**
1273
1443
  * Arm a detached watchdog that tears the sandbox down when THIS pi process
1274
1444
  * exits — works even for SIGKILL/power kills that never fire session_shutdown.
@@ -1288,6 +1458,10 @@ function spawnWatchdog(): void {
1288
1458
  // applies even when teardown is "none" — a pinned/shared sandbox is exactly
1289
1459
  // the case where you want the VM kept alive but NOT removed.
1290
1460
  if (mode === "none" && !keep) return;
1461
+ // Arm once per process: session_start and ensureSandbox both call this, and
1462
+ // a second watchdog would just duplicate the same teardown/keepalive work.
1463
+ if (watchdogArmed) return;
1464
+ watchdogArmed = true;
1291
1465
  const op = mode === "stop" ? `stop ${name}` : `rm --force ${name}`;
1292
1466
  const keepLine = keep ? `if [ $((i % 12)) -eq 0 ]; then ${findSbxCli()} ls 2>/dev/null | grep -Fq ${name} && ${findSbxCli()} exec ${name} -- true 2>/dev/null; fi` : "";
1293
1467
  const lines = [
@@ -1415,10 +1589,18 @@ async function gcSweep(hours: number): Promise<string> {
1415
1589
  for (const b of boxes) {
1416
1590
  const n = b.name;
1417
1591
  if (!n || !n.startsWith("pi-sbx-") || n === current) continue;
1418
- if (b.status === "running") {
1592
+ const status = (b.status ?? "").trim().toLowerCase();
1593
+ if (status === "running") {
1419
1594
  kept.push(`${n} (running)`);
1420
1595
  continue;
1421
1596
  }
1597
+ // Conservative: only remove sandboxes we can positively identify as
1598
+ // stopped. Unknown/unexpected status strings are kept (never removed) —
1599
+ // a misread status must not cause a running sandbox to be reaped.
1600
+ if (status !== "stopped") {
1601
+ kept.push(`${n} (status "${b.status ?? "?"}" — kept)`);
1602
+ continue;
1603
+ }
1422
1604
  // Sibling guard: never remove a sandbox whose owner pi process is alive
1423
1605
  // (it may be idle with an idle-stopped VM — concurrent sessions must not
1424
1606
  // reap each other). Only stop-orphaned sandboxes are candidates.
@@ -1672,7 +1854,8 @@ export default function (pi: ExtensionAPI) {
1672
1854
  "publishes container ports on host 127.0.0.1 only, and this runs in the host pi process, so it is the way " +
1673
1855
  "to check a running service from the agent (the agent's VM cannot reach host loopback). " +
1674
1856
  "url: e.g. http://127.0.0.1:8080/health. method: GET (default), POST, PUT, etc. body: optional request body " +
1675
- "(content-type application/json). Only 127.0.0.1/localhost/::1 hosts are allowed. Returns status + body.",
1857
+ "(content-type application/json). Only 127.0.0.1/localhost/::1 hosts AND ports published by this sandbox " +
1858
+ "are reachable. Returns status + body.",
1676
1859
  parameters: Type.Object({
1677
1860
  url: Type.String({ description: "Host-local URL of the published port, e.g. http://127.0.0.1:8080/health" }),
1678
1861
  timeoutSec: Type.Optional(Type.Number({ description: "Timeout in seconds (default 10)" })),
@@ -1749,4 +1932,4 @@ export default function (pi: ExtensionAPI) {
1749
1932
  }
1750
1933
 
1751
1934
  // Named exports for tests (pi's loader only calls the default factory).
1752
- export { scrubbedEnv, envForwardMode, envAllowlist, envPassthrough, sessionSandboxName };
1935
+ export { scrubbedEnv, envForwardMode, envAllowlist, envPassthrough, sessionSandboxName, mapHostPath, assertSafeArg };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "0.1.0",
3
+ "version": "1.0.1",
4
4
  "description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -10,16 +10,27 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/stixxert/pi-docker-sandbox.git"
12
12
  },
13
- "files": ["index.ts", "README.md", "boundary.md", "security.md", "LICENSE"],
14
- "keywords": ["pi-package"],
13
+ "files": [
14
+ "index.ts",
15
+ "README.md",
16
+ "boundary.md",
17
+ "security.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "pi-package"
22
+ ],
15
23
  "type": "module",
16
24
  "scripts": {
17
25
  "typecheck": "tsc --noEmit",
18
26
  "test": "node smoke-test.mjs",
19
- "prepublishOnly": "npm run typecheck && npm test"
27
+ "prepublishOnly": "npm run typecheck && npm test",
28
+ "release": "semantic-release"
20
29
  },
21
30
  "pi": {
22
- "extensions": ["./index.ts"]
31
+ "extensions": [
32
+ "./index.ts"
33
+ ]
23
34
  },
24
35
  "peerDependencies": {
25
36
  "@earendil-works/pi-ai": "*",
@@ -29,6 +40,7 @@
29
40
  "@earendil-works/pi-ai": "^0.84.1",
30
41
  "@earendil-works/pi-coding-agent": "^0.84.1",
31
42
  "@types/node": "^24.0.0",
43
+ "semantic-release": "^25.0.9",
32
44
  "typescript": "^5.9.0"
33
45
  }
34
46
  }
package/security.md CHANGED
@@ -31,7 +31,11 @@ own sandbox microVM** and the host's docker is never exposed to it.
31
31
  share or observe each other's docker state.
32
32
  5. **Filesystem.** Only the session workspace (the dir mounted at `/workspace`
33
33
  in the agent VM) is direct-mounted into the sandbox. Host `~/.docker`,
34
- `~/.ssh`, `~/.agent`, and other host paths are not mounted.
34
+ `~/.ssh`, `~/.agent`, and other host paths are not mounted. Path mapping is
35
+ confined to the workspace: `/workspace/..` traversal, absolute host paths,
36
+ and symlinks that point outside the workspace are all rejected
37
+ (`mapHostPath`), so the agent cannot reach host paths outside the mounted
38
+ workspace via build contexts, volume binds, or `docker_init`.
35
39
  6. **Env confidentiality (secure by default).** `DOCKER_*`/`COMPOSE_*` are
36
40
  always stripped from every child env. By default only a minimal safe set
37
41
  (`HOME`, `PATH`, `USER`, `LOGNAME`, `TMPDIR`, `SHELL`, `LANG`, `TERM`) is
@@ -40,6 +44,12 @@ own sandbox microVM** and the host's docker is never exposed to it.
40
44
  `DOCKER_SANDBOX_ENV_PASSTHROUGH=1` (host env minus the docker vars).
41
45
  Anything running inside the sandbox can read whatever reaches it — this
42
46
  knob confines that surface.
47
+ 7. **Input validation.** Tool arguments that reach the docker CLI positionally
48
+ (container ids, image refs, tags, names) are validated to reject values that
49
+ start with `-` (which docker would parse as flags) or contain control
50
+ characters. `docker_run` validates port/volume/memory specs, and
51
+ `docker_curl` is restricted to a safe HTTP-method allowlist and a 1 MiB body
52
+ limit.
43
53
 
44
54
  ### Read-only workspace mode (`DOCKER_SANDBOX_WORKSPACE_RO=1`)
45
55
 
@@ -115,3 +125,6 @@ sandboxes are never touched.
115
125
  level as any agent tooling writing to the workspace.
116
126
  - `sbx` port forwarding binds `127.0.0.1` on the host; apps inside the sandbox
117
127
  are not reachable from the LAN unless the host user forwards further.
128
+ - `docker_curl` is confined to ports the sandbox itself published (it reads the
129
+ live `sbx ports` mappings and rejects any other host-localhost port), so it
130
+ cannot be used to probe unrelated host services on localhost.