@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 +46 -2
- package/boundary.md +3 -2
- package/index.ts +216 -33
- package/package.json +17 -5
- package/security.md +14 -1
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:
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
if (
|
|
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)
|
|
634
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
1047
|
-
port
|
|
1204
|
+
host,
|
|
1205
|
+
port,
|
|
1048
1206
|
path: `${u.pathname}${u.search}`,
|
|
1049
1207
|
method: meth,
|
|
1050
|
-
timeout:
|
|
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
|
-
|
|
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(
|
|
1294
|
+
envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes(sentinel);
|
|
1129
1295
|
} finally {
|
|
1130
|
-
delete env
|
|
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
|
-
|
|
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",
|
|
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(
|
|
1316
|
+
probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes(probeName);
|
|
1150
1317
|
} finally {
|
|
1151
|
-
delete env
|
|
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
|
-
|
|
1234
|
-
const
|
|
1235
|
-
const
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
|
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": [
|
|
14
|
-
|
|
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": [
|
|
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.
|