@erichll/pi-sandbox 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-08-09
4
+
5
+ - Retarget the development compatibility target from `pi-subagents 0.42.1` to
6
+ `pi-subagents 0.44.0` and update the coexistence assertions, capability
7
+ boundary, and compatibility gate. The 0.44.0 line lands internal
8
+ workflow/mission/schedule fixes that do not change pi-sandbox's contract
9
+ (tool-ownership detection, `PI_SUBAGENT_PI_BINARY` worker bootstrap).
10
+ - Adapt the compatibility gate's "direct child" probe to the `workflowScript`
11
+ single-run form: pi-subagents 0.43.0 removed public top-level `agent`/`task`
12
+ direct execution, so the external provider's direct single-child launch is
13
+ now `subagent({ workflowScript: "return runs.run('main', { agent, task })" })`.
14
+ This unblocks the `subagents.externalWorkerIsolation: "enforce"` gate, which
15
+ previously timed out when the isolated worker could not reconcile the removed
16
+ direct signature with the newer `workflowScript`-only contract.
17
+
18
+ ## 0.5.0 - 2026-08-08
19
+
20
+ - Pin the development compatibility target to `pi-subagents 0.42.1` and add
21
+ coexistence assertions for its package identity and tool ownership.
22
+ - Add `npm run gate:pi-subagents`, an opt-in real-Pi compatibility gate. It
23
+ uses an isolated agent directory and reports `SKIP` unless a dedicated gate
24
+ model and credentials are supplied. The gate now waits for returned child
25
+ results and uses configurable provider-latency timeouts.
26
+ - Add the opt-in `subagents.externalWorkerIsolation: "enforce"` bootstrap for
27
+ external `pi-subagents` workers. It uses a tarball-shipped launcher and
28
+ fails closed if its outer Sandbox Runtime broker cannot start, protects
29
+ first-time security configuration creation, and cleans up only unchanged
30
+ temporary bind placeholders.
31
+
3
32
  ## 0.4.3 - 2026-08-06
4
33
 
5
34
  - Move the `pi-auto-review` dependency to `0.3.3`, which allows multi-segment
package/README.md CHANGED
@@ -59,7 +59,8 @@ new installs.
59
59
  ```json
60
60
  {
61
61
  "subagents": {
62
- "provider": "builtin"
62
+ "provider": "builtin",
63
+ "externalWorkerIsolation": "off"
63
64
  }
64
65
  }
65
66
  ```
@@ -70,12 +71,59 @@ Supported modes:
70
71
  each complete worker process tree.
71
72
  - `pi-subagents`: let the external extension own orchestration. Main-agent and
72
73
  inherited Bash execution remains sandboxed, but the external worker process
73
- itself is not wrapped.
74
+ itself is not wrapped while `externalWorkerIsolation` is `off`.
74
75
  - `off`: protect Bash only.
75
76
 
77
+ ### `pi-subagents` 0.44.0 capability boundary
78
+
79
+ `pi-sandbox` verifies the following combination in its test suite and release
80
+ gate:
81
+
82
+ | Capability | `builtin` | `pi-subagents` 0.44.0 |
83
+ | --- | --- | --- |
84
+ | Outer worker sandbox | Yes | Opt-in (`externalWorkerIsolation: "enforce"`) |
85
+ | Bash sandbox | Yes | Yes |
86
+ | Persistent follow-up | Yes | Yes |
87
+ | `workflowScript`, missions, schedules | No | Yes |
88
+ | permission-system parent forwarding | Not required | Verified by compatibility gate |
89
+
90
+ For the external provider, `off` leaves the worker process outside the outer
91
+ Sandbox Runtime boundary. Do not treat inherited Bash sandboxing as complete
92
+ worker isolation unless the trusted global configuration explicitly enables
93
+ `enforce`.
94
+
95
+ Note on the external provider's public execution surface: pi-subagents 0.43.0
96
+ removed top-level `agent`/`task` direct execution. Every public `subagent`
97
+ launch — including a minimal single child — is expressed as a `workflowScript`
98
+ such as `return runs.run('main', { agent, task })`. The compatibility gate's
99
+ "direct single child" probe uses that single-run form.
100
+
76
101
  The configuration parser rejects malformed JSON, unknown fields, and unknown
77
102
  providers instead of silently weakening isolation.
78
103
 
104
+ To opt in to outer worker isolation for the external provider, use:
105
+
106
+ ```json
107
+ {
108
+ "subagents": {
109
+ "provider": "pi-subagents",
110
+ "externalWorkerIsolation": "enforce"
111
+ }
112
+ }
113
+ ```
114
+
115
+ `enforce` installs a session-scoped `PI_SUBAGENT_PI_BINARY` wrapper which
116
+ starts the real Pi worker under a dedicated Sandbox Runtime broker. Bootstrap
117
+ failure is terminal for that worker; it never falls back to host execution.
118
+ The wrapper is preserved for nested child launches. This is opt-in while the
119
+ permission-forwarding supervisor and managed-worktree gate continue to mature.
120
+
121
+ Managed worktrees receive their own writable checkout plus the smallest known
122
+ Git metadata paths as read-only access so `git status` works. The main
123
+ repository `.git` is never made writable by this policy; Git mutation from an
124
+ outer-sandboxed external worker is therefore intentionally unsupported for
125
+ now.
126
+
79
127
  ## Optional host IPC fallback
80
128
 
81
129
  Some host services expose only a Unix socket that the OS sandbox cannot use.
@@ -157,8 +205,21 @@ From the monorepo root:
157
205
  npm install
158
206
  npm run check
159
207
  npm test
208
+ npm run gate:pi-subagents
160
209
  ```
161
210
 
211
+ `gate:pi-subagents` is a release/manual integration check, not a default CI
212
+ test. Set `PI_SUBAGENTS_GATE_MODEL` to a configured test model and provide its
213
+ normal Pi credential environment. The command creates a dedicated temporary
214
+ agent directory, never reads or updates your production Pi config, and prints
215
+ `SKIP` when those prerequisites are absent.
216
+
217
+ It allows 10 minutes for the direct-child phase and 15 minutes for the
218
+ multi-stage workflow, and exits only after the parent reports returned child
219
+ results. Override those bounds with positive millisecond values in
220
+ `PI_SUBAGENTS_GATE_DIRECT_TIMEOUT_MS` and
221
+ `PI_SUBAGENTS_GATE_WORKFLOW_TIMEOUT_MS` when needed for a dedicated provider.
222
+
162
223
  The test suite covers real Linux Sandbox Runtime enforcement when its native
163
224
  dependencies are installed, plus deterministic broker, network approval,
164
225
  process cleanup, concurrency, and cross-platform orchestration tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "Sandbox Runtime-backed Linux and macOS sandbox with pi-auto-review approval",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sandbox-runtime": "0.0.67",
54
- "@erichll/pi-auto-review": "0.3.3"
54
+ "@erichll/pi-auto-review": "0.3.4"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@earendil-works/pi-coding-agent": ">=0.82.1",
@@ -60,7 +60,7 @@
60
60
  "devDependencies": {
61
61
  "@earendil-works/pi-coding-agent": "0.83.0",
62
62
  "@types/node": "^22.15.3",
63
- "pi-subagents": "0.41.0",
63
+ "pi-subagents": "0.44.0",
64
64
  "typescript": "^6.0.3"
65
65
  }
66
66
  }
package/src/config.ts CHANGED
@@ -22,6 +22,7 @@ export type HostIPCConfig = {
22
22
  export type PiSandboxConfig = {
23
23
  subagents: {
24
24
  provider: SubagentProvider;
25
+ externalWorkerIsolation: "off" | "enforce";
25
26
  };
26
27
  filesystem: {
27
28
  additionalAllowRead: readonly string[];
@@ -39,6 +40,7 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
39
40
  {
40
41
  subagents: Object.freeze({
41
42
  provider: "builtin",
43
+ externalWorkerIsolation: "off",
42
44
  }),
43
45
  filesystem: Object.freeze({
44
46
  additionalAllowRead: Object.freeze([]),
@@ -96,7 +98,11 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
96
98
  );
97
99
  }
98
100
  const subagents = value.subagents ?? {};
99
- rejectUnknownKeys(subagents, ["provider"], "subagents");
101
+ rejectUnknownKeys(
102
+ subagents,
103
+ ["provider", "externalWorkerIsolation"],
104
+ "subagents",
105
+ );
100
106
 
101
107
  const provider =
102
108
  subagents.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
@@ -108,6 +114,17 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
108
114
  `invalid pi-sandbox configuration: subagents.provider must be one of ${SUBAGENT_PROVIDERS.join(", ")}`,
109
115
  );
110
116
  }
117
+ const externalWorkerIsolation =
118
+ subagents.externalWorkerIsolation ??
119
+ DEFAULT_PI_SANDBOX_CONFIG.subagents.externalWorkerIsolation;
120
+ if (
121
+ externalWorkerIsolation !== "off" &&
122
+ externalWorkerIsolation !== "enforce"
123
+ ) {
124
+ throw new Error(
125
+ "invalid pi-sandbox configuration: subagents.externalWorkerIsolation must be off or enforce",
126
+ );
127
+ }
111
128
 
112
129
  if (value.filesystem !== undefined && !isRecord(value.filesystem)) {
113
130
  throw new Error(
@@ -182,6 +199,7 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
182
199
  return {
183
200
  subagents: {
184
201
  provider: provider as SubagentProvider,
202
+ externalWorkerIsolation,
185
203
  },
186
204
  filesystem: {
187
205
  additionalAllowRead: [...new Set(additionalAllowRead)],
@@ -198,7 +216,11 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
198
216
 
199
217
  function defaultPiSandboxConfig(): PiSandboxConfig {
200
218
  return {
201
- subagents: { provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider },
219
+ subagents: {
220
+ provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider,
221
+ externalWorkerIsolation:
222
+ DEFAULT_PI_SANDBOX_CONFIG.subagents.externalWorkerIsolation,
223
+ },
202
224
  filesystem: {
203
225
  additionalAllowRead: [
204
226
  ...DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead,
@@ -0,0 +1,159 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer, type Server } from "node:net";
3
+ import { chmod, mkdtemp, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import type { NetworkEndpoint, TrapApprovalContext } from "./approval.ts";
7
+ import { approveDomainEndpoint } from "./approval.ts";
8
+
9
+ const MAX_FRAME_BYTES = 16 * 1024;
10
+ const PROTOCOL_VERSION = 1;
11
+
12
+ type Request = {
13
+ version?: unknown;
14
+ capability?: unknown;
15
+ id?: unknown;
16
+ type?: unknown;
17
+ workerId?: unknown;
18
+ cwd?: unknown;
19
+ hostname?: unknown;
20
+ port?: unknown;
21
+ };
22
+
23
+ export type ExternalWorkerSupervisor = {
24
+ socketPath: string;
25
+ capability: string;
26
+ workers(): ReadonlyArray<{
27
+ id: string;
28
+ cwd: string;
29
+ state: "active" | "exited";
30
+ startedAt: number;
31
+ requests: number;
32
+ }>;
33
+ close(): Promise<void>;
34
+ };
35
+
36
+ export async function createExternalWorkerSupervisor(
37
+ approvalContext: (worker: { id: string; cwd: string }) => TrapApprovalContext,
38
+ ): Promise<ExternalWorkerSupervisor> {
39
+ const directory = await mkdtemp(join(tmpdir(), "pi-sandbox-external-"));
40
+ await chmod(directory, 0o700);
41
+ const socketPath = join(directory, "supervisor.sock");
42
+ const capability = randomBytes(32).toString("hex");
43
+ const seen = new Set<string>();
44
+ const workers = new Map<string, {
45
+ cwd: string;
46
+ state: "active" | "exited";
47
+ startedAt: number;
48
+ requests: number;
49
+ }>();
50
+ const server: Server = createServer((socket) => {
51
+ let buffer = "";
52
+ socket.setEncoding("utf8");
53
+ socket.on("data", (chunk: string) => {
54
+ buffer += chunk;
55
+ if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
56
+ socket.end('{"action":"deny"}\n');
57
+ return;
58
+ }
59
+ let newline = buffer.indexOf("\n");
60
+ while (newline >= 0) {
61
+ const line = buffer.slice(0, newline);
62
+ buffer = buffer.slice(newline + 1);
63
+ void respond(line);
64
+ newline = buffer.indexOf("\n");
65
+ }
66
+ });
67
+ const respond = async (line: string): Promise<void> => {
68
+ let request: Request | undefined;
69
+ try { request = JSON.parse(line) as Request; } catch { /* denied below */ }
70
+ if (!request) {
71
+ socket.write('{"action":"deny"}\n');
72
+ return;
73
+ }
74
+ const baseValid =
75
+ request.version === PROTOCOL_VERSION &&
76
+ request.capability === capability &&
77
+ typeof request.id === "string" && request.id.length > 0 && request.id.length <= 128 &&
78
+ !seen.has(request.id);
79
+ if (!baseValid) {
80
+ socket.write('{"action":"deny"}\n');
81
+ return;
82
+ }
83
+ const registered =
84
+ request.type === "register" &&
85
+ typeof request.workerId === "string" && request.workerId.length > 0 && request.workerId.length <= 128 &&
86
+ typeof request.cwd === "string" && request.cwd.startsWith("/") && request.cwd.length <= 4_096 &&
87
+ !workers.has(request.workerId);
88
+ if (registered) {
89
+ seen.add(request.id as string);
90
+ workers.set(request.workerId as string, {
91
+ cwd: request.cwd as string,
92
+ state: "active",
93
+ startedAt: Date.now(),
94
+ requests: 0,
95
+ });
96
+ socket.write(`${JSON.stringify({ id: request.id, action: "allow" })}\n`);
97
+ return;
98
+ }
99
+ const knownWorker =
100
+ typeof request.workerId === "string" && typeof request.cwd === "string"
101
+ ? workers.get(request.workerId)
102
+ : undefined;
103
+ const unregister = request.type === "unregister" &&
104
+ knownWorker?.state === "active" && knownWorker.cwd === request.cwd;
105
+ if (unregister) {
106
+ seen.add(request.id as string);
107
+ knownWorker.state = "exited";
108
+ socket.write(`${JSON.stringify({ id: request.id, action: "allow" })}\n`);
109
+ return;
110
+ }
111
+ const workerCwd =
112
+ knownWorker?.state === "active" ? knownWorker.cwd : undefined;
113
+ const valid = request.type === "network" &&
114
+ workerCwd === request.cwd &&
115
+ typeof request.hostname === "string" && request.hostname.length <= 253 &&
116
+ Number.isInteger(request.port) && Number(request.port) > 0 && Number(request.port) <= 65535 &&
117
+ Boolean(workerCwd);
118
+ if (!valid) {
119
+ socket.write('{"action":"deny"}\n');
120
+ return;
121
+ }
122
+ const approvedRequest = request as Required<Request>;
123
+ seen.add(approvedRequest.id as string);
124
+ knownWorker!.requests++;
125
+ let action: "allow" | "deny" = "deny";
126
+ try {
127
+ const endpoint: NetworkEndpoint = {
128
+ hostname: approvedRequest.hostname as string,
129
+ port: approvedRequest.port as number,
130
+ protocol: "tcp",
131
+ };
132
+ action = (await approveDomainEndpoint(endpoint, approvalContext({
133
+ id: approvedRequest.workerId as string,
134
+ cwd: workerCwd as string,
135
+ }))).action;
136
+ } catch {
137
+ action = "deny";
138
+ }
139
+ socket.write(`${JSON.stringify({ id: approvedRequest.id, action })}\n`);
140
+ };
141
+ });
142
+ await new Promise<void>((resolve, reject) => {
143
+ server.once("error", reject);
144
+ server.listen(socketPath, () => resolve());
145
+ });
146
+ return {
147
+ socketPath,
148
+ capability,
149
+ workers() {
150
+ return [...workers.entries()].map(([id, worker]) => ({ id, ...worker }));
151
+ },
152
+ async close() {
153
+ await new Promise<void>((resolve) => server.close(() => resolve()));
154
+ await rm(directory, { recursive: true, force: true });
155
+ },
156
+ };
157
+ }
158
+
159
+ export const EXTERNAL_SUPERVISOR_PROTOCOL_VERSION = PROTOCOL_VERSION;
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // Public PI_SUBAGENT_PI_BINARY wrapper. It deliberately has no host fallback:
3
+ // failure to initialize the broker prevents the worker from starting.
4
+ import { fork } from "node:child_process";
5
+ import { createConnection } from "node:net";
6
+ import { randomUUID } from "node:crypto";
7
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { dirname, join, parse, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const realPi = process.env.PI_SANDBOX_EXTERNAL_REAL_PI_BINARY;
13
+ const workerId = randomUUID();
14
+
15
+ function worktreeGitReadPaths(cwd) {
16
+ const dotGit = join(cwd, ".git");
17
+ try {
18
+ const pointer = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)\s*$/m)?.[1];
19
+ if (!pointer) return [];
20
+ const gitDir = resolve(cwd, pointer);
21
+ const paths = [gitDir];
22
+ try {
23
+ const common = readFileSync(join(gitDir, "commondir"), "utf8").trim();
24
+ if (common) paths.push(resolve(gitDir, common));
25
+ } catch { /* a direct worktree gitdir has no commondir */ }
26
+ return paths;
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ function supervisorRequest(payload) {
33
+ const socketPath = process.env.PI_SANDBOX_EXTERNAL_SUPERVISOR_SOCKET;
34
+ const capability = process.env.PI_SANDBOX_EXTERNAL_SUPERVISOR_CAPABILITY;
35
+ if (!socketPath || !capability) return Promise.resolve("deny");
36
+ return new Promise((resolveAction) => {
37
+ const socket = createConnection(socketPath);
38
+ let buffer = "";
39
+ const finish = (action) => { socket.destroy(); resolveAction(action === "allow" ? "allow" : "deny"); };
40
+ socket.setEncoding("utf8");
41
+ socket.setTimeout(30_000, () => finish("deny"));
42
+ socket.once("error", () => finish("deny"));
43
+ socket.on("data", (chunk) => {
44
+ buffer += chunk;
45
+ const newline = buffer.indexOf("\n");
46
+ if (newline < 0 || Buffer.byteLength(buffer, "utf8") > 16 * 1024) return;
47
+ try { finish(JSON.parse(buffer.slice(0, newline)).action); } catch { finish("deny"); }
48
+ });
49
+ socket.once("connect", () => socket.write(`${JSON.stringify({
50
+ version: 1, capability, id: randomUUID(), ...payload,
51
+ })}\n`));
52
+ });
53
+ }
54
+ function registerWorker() {
55
+ return supervisorRequest({ type: "register", workerId, cwd: process.cwd() });
56
+ }
57
+ function unregisterWorker() {
58
+ return supervisorRequest({ type: "unregister", workerId, cwd: process.cwd() });
59
+ }
60
+ function askSupervisor(hostname, port) {
61
+ return supervisorRequest({ type: "network", workerId, cwd: process.cwd(), hostname, port });
62
+ }
63
+
64
+ function createMandatoryDenyPlaceholders(cwd, agentDir) {
65
+ // Sandbox Runtime protects these names unconditionally. Under an outer
66
+ // bubblewrap it cannot create a missing bind target after the root becomes
67
+ // read-only, so create only absent regular-file placeholders before broker
68
+ // bootstrap and remove exactly those placeholders at wrapper exit.
69
+ const created = [];
70
+ const remember = (path, kind, content) => {
71
+ const stat = lstatSync(path);
72
+ created.push({ path, kind, content, dev: stat.dev, ino: stat.ino });
73
+ };
74
+ for (const name of [
75
+ ".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc",
76
+ ".zprofile", ".profile", ".ripgreprc", ".mcp.json",
77
+ ]) {
78
+ const path = join(cwd, name);
79
+ if (existsSync(path)) continue;
80
+ try {
81
+ writeFileSync(path, "", { flag: "wx", mode: 0o600 });
82
+ remember(path, "file", "");
83
+ } catch {
84
+ // Sandbox bootstrap will fail closed if a mandatory target is unusable.
85
+ }
86
+ }
87
+ for (const directory of [".vscode", ".idea", ".claude/commands", ".claude/agents"]) {
88
+ let path = cwd;
89
+ for (const part of directory.split("/")) {
90
+ path = join(path, part);
91
+ if (existsSync(path)) continue;
92
+ try {
93
+ mkdirSync(path, { mode: 0o700 });
94
+ remember(path, "directory");
95
+ } catch {
96
+ // Sandbox bootstrap will fail closed if a mandatory target is unusable.
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ // Keep the project-state directory available so denyWrite can bind missing
102
+ // security files at their exact paths instead of masking the first missing
103
+ // `.pi` component and preventing ordinary project-state creation.
104
+ const projectState = join(cwd, ".pi");
105
+ if (!existsSync(projectState)) {
106
+ try {
107
+ mkdirSync(projectState, { mode: 0o700 });
108
+ remember(projectState, "directory");
109
+ } catch {
110
+ // Sandbox bootstrap will fail closed if the directory remains unusable.
111
+ }
112
+ }
113
+ // The agent directory sits below denyRead(home). Bubblewrap cannot create a
114
+ // missing nested bind target there after the home mask becomes read-only, so
115
+ // seed valid empty JSON files and remove only unchanged placeholders later.
116
+ for (const name of ["settings.json", "permissions.json", "sandbox.json"]) {
117
+ const path = join(agentDir, name);
118
+ if (existsSync(path)) continue;
119
+ try {
120
+ writeFileSync(path, "{}\n", { flag: "wx", mode: 0o600 });
121
+ remember(path, "file", "{}\n");
122
+ } catch {
123
+ // Sandbox bootstrap will fail closed if a protected target is unusable.
124
+ }
125
+ }
126
+ return () => {
127
+ for (const item of created.reverse()) {
128
+ try {
129
+ const current = lstatSync(item.path);
130
+ if (current.dev !== item.dev || current.ino !== item.ino) continue;
131
+ if (item.kind === "directory") rmdirSync(item.path);
132
+ else if (readFileSync(item.path, "utf8") === item.content) unlinkSync(item.path);
133
+ } catch { /* remove only unchanged empty placeholders */ }
134
+ }
135
+ };
136
+ }
137
+ if (!realPi) {
138
+ process.stderr.write("pi-sandbox: external worker wrapper has no verified Pi binary\n");
139
+ process.exitCode = 1;
140
+ } else {
141
+ const cwd = process.cwd();
142
+ const workerTempDir = mkdtempSync(join(tmpdir(), "pi-sandbox-external-worker-"));
143
+ process.once("exit", () => {
144
+ try { rmSync(workerTempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
145
+ });
146
+ const home = resolve(homedir());
147
+ const agentDir = resolve(process.env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent"));
148
+ const removeMandatoryDenyPlaceholders = createMandatoryDenyPlaceholders(cwd, agentDir);
149
+ process.once("exit", removeMandatoryDenyPlaceholders);
150
+ const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
151
+ const packageRoot = dirname(fileURLToPath(import.meta.url));
152
+ const nodeRoot = dirname(dirname(process.execPath));
153
+ const runtimeRoot = dirname(dirname(fileURLToPath(import.meta.resolve("@anthropic-ai/sandbox-runtime"))));
154
+ const extraRead = (process.env.PI_SANDBOX_EXTERNAL_ALLOW_READ || "")
155
+ .split(":")
156
+ .filter(Boolean);
157
+ const existingGitConfig = [
158
+ join(home, ".gitconfig"),
159
+ join(home, ".config", "git", "config"),
160
+ ].filter(existsSync);
161
+ // On Linux Sandbox Runtime masks the first missing component of a deny path.
162
+ // Denying a not-yet-created `.pi/settings.json` would therefore mask `.pi`
163
+ // itself and prevent Pi from creating its ordinary project state directory.
164
+ // Existing security files remain outer-sandbox protected; creation attempts
165
+ // are still gated by permission-system and pi-auto-review.
166
+ const workspaceSecurityFiles = [
167
+ join(cwd, ".pi", "settings.json"),
168
+ join(cwd, ".pi", "sandbox.json"),
169
+ join(cwd, ".pi", "pi-auto-review.json"),
170
+ ];
171
+ const agentSecurityPaths = [
172
+ join(agentDir, "settings.json"),
173
+ join(agentDir, "permissions.json"),
174
+ join(agentDir, "sandbox.json"),
175
+ join(agentDir, "extensions"),
176
+ ];
177
+ const runtimeConfig = {
178
+ filesystem: {
179
+ denyRead: home === parse(home).root ? [] : [home],
180
+ allowRead: [cwd, nodeRoot, runtimeRoot, ...existingGitConfig, "/dev/null", ...worktreeGitReadPaths(cwd), ...extraRead],
181
+ // Child sessions and permission-forwarding request/response files live
182
+ // here. Keep this narrower than the agent directory, whose security
183
+ // settings and extensions remain read-only.
184
+ allowWrite: [
185
+ cwd,
186
+ "/dev/null",
187
+ workerTempDir,
188
+ ...(sessionDir?.startsWith("/") ? [sessionDir] : []),
189
+ // Pi creates directory-style settings locks and provider model caches
190
+ // beside its global settings. Security-sensitive files and extensions
191
+ // below remain explicit denyWrite entries.
192
+ agentDir,
193
+ ],
194
+ denyWrite: [
195
+ ...workspaceSecurityFiles,
196
+ ...agentSecurityPaths,
197
+ packageRoot,
198
+ dirname(process.execPath),
199
+ ],
200
+ allowGitConfig: true,
201
+ },
202
+ network: {
203
+ allowedDomains: [], deniedDomains: [], allowLocalBinding: false,
204
+ allowAllUnixSockets: false, allowUnixSockets: [],
205
+ },
206
+ };
207
+ const registration = await registerWorker();
208
+ if (registration !== "allow") {
209
+ process.stderr.write("pi-sandbox: external worker registration was denied\n");
210
+ process.exitCode = 1;
211
+ process.exit();
212
+ }
213
+ const broker = fork(new URL("./srt-broker.mjs", import.meta.url), [], {
214
+ cwd,
215
+ detached: true,
216
+ env: {
217
+ ...process.env,
218
+ PI_SANDBOX_EXTERNAL_WORKER_TMPDIR: workerTempDir,
219
+ CLAUDE_CODE_TMPDIR: workerTempDir,
220
+ TMPDIR: workerTempDir,
221
+ TMP: workerTempDir,
222
+ TEMP: workerTempDir,
223
+ },
224
+ stdio: ["pipe", "pipe", "pipe", "ipc"],
225
+ });
226
+ broker.stdout.pipe(process.stdout);
227
+ broker.stderr.pipe(process.stderr);
228
+ process.stdin.pipe(broker.stdin);
229
+ broker.on("message", (message) => {
230
+ if (!message || typeof message !== "object" || message.type !== "network-request") return;
231
+ void askSupervisor(message.hostname, message.port).then((action) => {
232
+ if (broker.connected) broker.send({ type: "network-response", id: message.id, action });
233
+ });
234
+ });
235
+ let prefix = [];
236
+ try { prefix = JSON.parse(process.env.PI_SANDBOX_EXTERNAL_REAL_PI_PREFIX || "[]"); } catch { prefix = []; }
237
+ if (!Array.isArray(prefix) || prefix.some((item) => typeof item !== "string")) {
238
+ process.stderr.write("pi-sandbox: external worker wrapper received invalid Pi prefix\n");
239
+ broker.kill("SIGKILL");
240
+ process.exitCode = 1;
241
+ } else {
242
+ broker.send({ type: "init", invocation: [realPi, ...prefix, ...process.argv.slice(2)], runtimeConfig });
243
+ }
244
+ const stop = (signal) => {
245
+ if (broker.pid) {
246
+ try { process.kill(-broker.pid, signal); } catch { broker.kill(signal); }
247
+ }
248
+ };
249
+ process.on("SIGINT", () => stop("SIGINT"));
250
+ process.on("SIGTERM", () => stop("SIGTERM"));
251
+ broker.once("exit", async (code, signal) => {
252
+ await unregisterWorker();
253
+ process.exitCode = code ?? (signal ? 1 : 1);
254
+ });
255
+ broker.once("error", (error) => {
256
+ process.stderr.write(`pi-sandbox: external worker broker failed: ${error.message}\n`);
257
+ process.exitCode = 1;
258
+ });
259
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ExtensionAPI,
4
4
  ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { fileURLToPath } from "node:url";
6
7
  import {
7
8
  createBashToolDefinition,
8
9
  createLocalBashOperations,
@@ -31,6 +32,10 @@ import type {
31
32
  ProcessBackedSubagentSession,
32
33
  } from "./subagent.ts";
33
34
  import { Type } from "typebox";
35
+ import {
36
+ createExternalWorkerSupervisor,
37
+ type ExternalWorkerSupervisor,
38
+ } from "./external-supervisor.ts";
34
39
 
35
40
  const EXTENSION_NAME = "pi-sandbox";
36
41
  const registrations = new WeakMap<ExtensionAPI, Promise<void>>();
@@ -84,6 +89,50 @@ function externalSubagentDiagnostic(pi: ExtensionAPI): {
84
89
  };
85
90
  }
86
91
 
92
+ const EXTERNAL_WORKER_LAUNCHER = fileURLToPath(
93
+ new URL("./external-worker-launcher.mjs", import.meta.url),
94
+ );
95
+
96
+ type ExternalWorkerIsolation = {
97
+ restore(): void;
98
+ };
99
+
100
+ function enableExternalWorkerIsolation(
101
+ supervisor: ExternalWorkerSupervisor,
102
+ ): ExternalWorkerIsolation {
103
+ if (process.platform !== "linux" && process.platform !== "darwin") {
104
+ throw new Error(`external worker isolation is unavailable on ${process.platform}`);
105
+ }
106
+ if (process.env.PI_SUBAGENT_PI_BINARY) {
107
+ throw new Error(
108
+ "external worker isolation refuses to replace an existing PI_SUBAGENT_PI_BINARY wrapper",
109
+ );
110
+ }
111
+ const entry = process.argv[1];
112
+ if (!entry) {
113
+ throw new Error("external worker isolation cannot determine the real Pi CLI entrypoint");
114
+ }
115
+ const injected = {
116
+ PI_SUBAGENT_PI_BINARY: EXTERNAL_WORKER_LAUNCHER,
117
+ PI_SANDBOX_EXTERNAL_REAL_PI_BINARY: process.execPath,
118
+ PI_SANDBOX_EXTERNAL_REAL_PI_PREFIX: JSON.stringify([entry]),
119
+ PI_SANDBOX_EXTERNAL_ALLOW_READ: [
120
+ process.cwd(),
121
+ process.env.PI_CODING_AGENT_DIR,
122
+ ].filter((value): value is string => Boolean(value)).join(":"),
123
+ PI_SANDBOX_EXTERNAL_SUPERVISOR_SOCKET: supervisor.socketPath,
124
+ PI_SANDBOX_EXTERNAL_SUPERVISOR_CAPABILITY: supervisor.capability,
125
+ };
126
+ Object.assign(process.env, injected);
127
+ return {
128
+ restore() {
129
+ for (const [key, value] of Object.entries(injected)) {
130
+ if (process.env[key] === value) delete process.env[key];
131
+ }
132
+ },
133
+ };
134
+ }
135
+
87
136
  function humanApproval(ctx: ExtensionContext): HumanApproval {
88
137
  return async (request, reason, signal) => {
89
138
  if (!ctx.hasUI) return "deny";
@@ -204,6 +253,7 @@ async function performRegistration(
204
253
  const config = loadPiSandboxConfig();
205
254
  const subagentProvider =
206
255
  options.subagentProvider ?? config.subagents.provider;
256
+ const externalWorkerIsolation = config.subagents.externalWorkerIsolation;
207
257
  const additionalAllowRead = config.filesystem.additionalAllowRead;
208
258
  const hostIPC = options.hostIPC ?? config.hostIPC;
209
259
  const subagents =
@@ -216,6 +266,9 @@ async function performRegistration(
216
266
  }))
217
267
  : undefined;
218
268
  const cwd = process.cwd();
269
+ let isolation: ExternalWorkerIsolation | undefined;
270
+ let supervisor: ExternalWorkerSupervisor | undefined;
271
+ let externalIsolationFailure: string | undefined;
219
272
  const localBash = createBashToolDefinition(cwd);
220
273
 
221
274
  pi.registerTool({
@@ -476,11 +529,65 @@ async function performRegistration(
476
529
  currentTurn = event.turnIndex;
477
530
  });
478
531
 
479
- pi.on("session_start", (_event, ctx) => {
532
+ // pi-subagents owns the public tool, so a failed wrapper bootstrap cannot
533
+ // be fixed by replacing that tool. Block at Pi's pre-execution boundary:
534
+ // enforce must never degrade into a host worker launch.
535
+ pi.on("tool_call", (event) => {
536
+ if (
537
+ event.toolName === "subagent" &&
538
+ subagentProvider === "pi-subagents" &&
539
+ externalWorkerIsolation === "enforce" &&
540
+ !isolation
541
+ ) {
542
+ return {
543
+ block: true,
544
+ reason: `${EXTENSION_NAME}: external worker isolation is unavailable; refusing host worker launch${externalIsolationFailure ? `: ${externalIsolationFailure}` : ""}`,
545
+ };
546
+ }
547
+ return undefined;
548
+ });
549
+
550
+ pi.on("session_start", async (_event, ctx) => {
551
+ if (
552
+ subagentProvider === "pi-subagents" &&
553
+ externalWorkerIsolation === "enforce"
554
+ ) {
555
+ try {
556
+ isolation?.restore();
557
+ await supervisor?.close();
558
+ supervisor = await createExternalWorkerSupervisor((worker) => ({
559
+ broker: getBoundaryBroker(),
560
+ command: "external pi-subagents worker network request",
561
+ cwd: worker.cwd,
562
+ sessionId: sessionId(ctx),
563
+ scopeKey: `${sessionId(ctx)}:turn:${currentTurn}`,
564
+ agentName: `pi-subagents-worker:${worker.id}`,
565
+ humanApproval: humanApproval(ctx),
566
+ }));
567
+ isolation = enableExternalWorkerIsolation(supervisor);
568
+ externalIsolationFailure = undefined;
569
+ } catch (error) {
570
+ const message = `${EXTENSION_NAME}: external worker isolation unavailable; child launches will fail closed: ${error instanceof Error ? error.message : String(error)}`;
571
+ externalIsolationFailure = message;
572
+ isolation?.restore();
573
+ isolation = undefined;
574
+ await supervisor?.close();
575
+ supervisor = undefined;
576
+ console.error(message);
577
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
578
+ }
579
+ }
480
580
  if (ctx.hasUI) {
481
581
  if (subagentProvider === "pi-subagents") {
482
582
  const diagnostic = externalSubagentDiagnostic(pi);
483
- ctx.ui.notify(diagnostic.message, diagnostic.level);
583
+ ctx.ui.notify(
584
+ externalWorkerIsolation === "enforce" && isolation
585
+ ? "pi-subagents orchestration active; external worker process trees require outer Sandbox Runtime isolation."
586
+ : diagnostic.message,
587
+ externalWorkerIsolation === "enforce" && isolation
588
+ ? "info"
589
+ : diagnostic.level,
590
+ );
484
591
  } else if (subagentProvider === "builtin") {
485
592
  ctx.ui.notify(
486
593
  "pi-sandbox subagent provider: builtin; worker process trees use the outer Sandbox Runtime sandbox",
@@ -513,6 +620,10 @@ async function performRegistration(
513
620
  });
514
621
 
515
622
  pi.on("session_shutdown", async () => {
623
+ isolation?.restore();
624
+ isolation = undefined;
625
+ await supervisor?.close();
626
+ supervisor = undefined;
516
627
  await subagents?.shutdown();
517
628
  });
518
629
  }
@@ -88,9 +88,12 @@ async function main() {
88
88
  undefined,
89
89
  process.cwd(),
90
90
  );
91
+ const workerTempDir = process.env.PI_SANDBOX_EXTERNAL_WORKER_TMPDIR;
91
92
  target = spawn(wrapped.argv[0], wrapped.argv.slice(1), {
92
93
  cwd: process.cwd(),
93
- env: wrapped.env,
94
+ env: workerTempDir
95
+ ? { ...wrapped.env, TMPDIR: workerTempDir, TMP: workerTempDir, TEMP: workerTempDir }
96
+ : wrapped.env,
94
97
  stdio: ["pipe", "pipe", "pipe"],
95
98
  });
96
99
  process.stdin.pipe(target.stdin);