@erichll/pi-sandbox 0.15.3 → 0.16.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 +19 -0
- package/README.md +51 -79
- package/package.json +8 -6
- package/src/config.ts +44 -14
- package/src/index.ts +96 -136
- package/src/pi-subagents-native.ts +250 -0
- package/src/external-network-policy.d.mts +0 -8
- package/src/external-network-policy.mjs +0 -27
- package/src/external-runs-view.ts +0 -173
- package/src/external-supervisor.ts +0 -189
- package/src/external-worker-launcher.mjs +0 -266
|
@@ -1,266 +0,0 @@
|
|
|
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
|
-
import { decodeExternalNetworkPolicy } from "./external-network-policy.mjs";
|
|
12
|
-
|
|
13
|
-
const realPi = process.env.PI_SANDBOX_EXTERNAL_REAL_PI_BINARY;
|
|
14
|
-
const workerId = randomUUID();
|
|
15
|
-
|
|
16
|
-
function worktreeGitReadPaths(cwd) {
|
|
17
|
-
const dotGit = join(cwd, ".git");
|
|
18
|
-
try {
|
|
19
|
-
const pointer = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)\s*$/m)?.[1];
|
|
20
|
-
if (!pointer) return [];
|
|
21
|
-
const gitDir = resolve(cwd, pointer);
|
|
22
|
-
const paths = [gitDir];
|
|
23
|
-
try {
|
|
24
|
-
const common = readFileSync(join(gitDir, "commondir"), "utf8").trim();
|
|
25
|
-
if (common) paths.push(resolve(gitDir, common));
|
|
26
|
-
} catch { /* a direct worktree gitdir has no commondir */ }
|
|
27
|
-
return paths;
|
|
28
|
-
} catch {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function supervisorRequest(payload) {
|
|
34
|
-
const socketPath = process.env.PI_SANDBOX_EXTERNAL_SUPERVISOR_SOCKET;
|
|
35
|
-
const capability = process.env.PI_SANDBOX_EXTERNAL_SUPERVISOR_CAPABILITY;
|
|
36
|
-
if (!socketPath || !capability) return Promise.resolve({ action: "deny" });
|
|
37
|
-
return new Promise((resolveResponse) => {
|
|
38
|
-
const socket = createConnection(socketPath);
|
|
39
|
-
let buffer = "";
|
|
40
|
-
const finish = (response) => {
|
|
41
|
-
socket.destroy();
|
|
42
|
-
resolveResponse(response?.action === "allow" ? response : { action: "deny" });
|
|
43
|
-
};
|
|
44
|
-
socket.setEncoding("utf8");
|
|
45
|
-
socket.setTimeout(30_000, () => finish({ action: "deny" }));
|
|
46
|
-
socket.once("error", () => finish({ action: "deny" }));
|
|
47
|
-
socket.on("data", (chunk) => {
|
|
48
|
-
buffer += chunk;
|
|
49
|
-
const newline = buffer.indexOf("\n");
|
|
50
|
-
if (newline < 0 || Buffer.byteLength(buffer, "utf8") > 16 * 1024) return;
|
|
51
|
-
try { finish(JSON.parse(buffer.slice(0, newline))); } catch { finish({ action: "deny" }); }
|
|
52
|
-
});
|
|
53
|
-
socket.once("connect", () => socket.write(`${JSON.stringify({
|
|
54
|
-
version: 1, capability, id: randomUUID(), ...payload,
|
|
55
|
-
})}\n`));
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
function registerWorker() {
|
|
59
|
-
return supervisorRequest({ type: "register", workerId, cwd: process.cwd() });
|
|
60
|
-
}
|
|
61
|
-
function unregisterWorker() {
|
|
62
|
-
return supervisorRequest({ type: "unregister", workerId, cwd: process.cwd() }).then((response) => response.action);
|
|
63
|
-
}
|
|
64
|
-
function askSupervisor(hostname, port) {
|
|
65
|
-
return supervisorRequest({ type: "network", workerId, cwd: process.cwd(), hostname, port }).then((response) => response.action);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function createMandatoryDenyPlaceholders(cwd, agentDir) {
|
|
69
|
-
// Sandbox Runtime protects these names unconditionally. Under an outer
|
|
70
|
-
// bubblewrap it cannot create a missing bind target after the root becomes
|
|
71
|
-
// read-only, so create only absent regular-file placeholders before broker
|
|
72
|
-
// bootstrap and remove exactly those placeholders at wrapper exit.
|
|
73
|
-
const created = [];
|
|
74
|
-
const remember = (path, kind, content) => {
|
|
75
|
-
const stat = lstatSync(path);
|
|
76
|
-
created.push({ path, kind, content, dev: stat.dev, ino: stat.ino });
|
|
77
|
-
};
|
|
78
|
-
for (const name of [
|
|
79
|
-
".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc",
|
|
80
|
-
".zprofile", ".profile", ".ripgreprc", ".mcp.json",
|
|
81
|
-
]) {
|
|
82
|
-
const path = join(cwd, name);
|
|
83
|
-
if (existsSync(path)) continue;
|
|
84
|
-
try {
|
|
85
|
-
writeFileSync(path, "", { flag: "wx", mode: 0o600 });
|
|
86
|
-
remember(path, "file", "");
|
|
87
|
-
} catch {
|
|
88
|
-
// Sandbox bootstrap will fail closed if a mandatory target is unusable.
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
for (const directory of [".vscode", ".idea", ".claude/commands", ".claude/agents"]) {
|
|
92
|
-
let path = cwd;
|
|
93
|
-
for (const part of directory.split("/")) {
|
|
94
|
-
path = join(path, part);
|
|
95
|
-
if (existsSync(path)) continue;
|
|
96
|
-
try {
|
|
97
|
-
mkdirSync(path, { mode: 0o700 });
|
|
98
|
-
remember(path, "directory");
|
|
99
|
-
} catch {
|
|
100
|
-
// Sandbox bootstrap will fail closed if a mandatory target is unusable.
|
|
101
|
-
break;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
// Keep the project-state directory available so denyWrite can bind missing
|
|
106
|
-
// security files at their exact paths instead of masking the first missing
|
|
107
|
-
// `.pi` component and preventing ordinary project-state creation.
|
|
108
|
-
const projectState = join(cwd, ".pi");
|
|
109
|
-
if (!existsSync(projectState)) {
|
|
110
|
-
try {
|
|
111
|
-
mkdirSync(projectState, { mode: 0o700 });
|
|
112
|
-
remember(projectState, "directory");
|
|
113
|
-
} catch {
|
|
114
|
-
// Sandbox bootstrap will fail closed if the directory remains unusable.
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// The agent directory sits below denyRead(home). Bubblewrap cannot create a
|
|
118
|
-
// missing nested bind target there after the home mask becomes read-only, so
|
|
119
|
-
// seed valid empty JSON files and remove only unchanged placeholders later.
|
|
120
|
-
for (const name of ["settings.json", "permissions.json", "sandbox.json"]) {
|
|
121
|
-
const path = join(agentDir, name);
|
|
122
|
-
if (existsSync(path)) continue;
|
|
123
|
-
try {
|
|
124
|
-
writeFileSync(path, "{}\n", { flag: "wx", mode: 0o600 });
|
|
125
|
-
remember(path, "file", "{}\n");
|
|
126
|
-
} catch {
|
|
127
|
-
// Sandbox bootstrap will fail closed if a protected target is unusable.
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return () => {
|
|
131
|
-
for (const item of created.reverse()) {
|
|
132
|
-
try {
|
|
133
|
-
const current = lstatSync(item.path);
|
|
134
|
-
if (current.dev !== item.dev || current.ino !== item.ino) continue;
|
|
135
|
-
if (item.kind === "directory") rmdirSync(item.path);
|
|
136
|
-
else if (readFileSync(item.path, "utf8") === item.content) unlinkSync(item.path);
|
|
137
|
-
} catch { /* remove only unchanged empty placeholders */ }
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
if (!realPi) {
|
|
142
|
-
process.stderr.write("pi-sandbox: external worker wrapper has no verified Pi binary\n");
|
|
143
|
-
process.exitCode = 1;
|
|
144
|
-
} else {
|
|
145
|
-
const cwd = process.cwd();
|
|
146
|
-
const workerTempDir = mkdtempSync(join(tmpdir(), "pi-sandbox-external-worker-"));
|
|
147
|
-
process.once("exit", () => {
|
|
148
|
-
try { rmSync(workerTempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
149
|
-
});
|
|
150
|
-
const home = resolve(homedir());
|
|
151
|
-
const agentDir = resolve(process.env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent"));
|
|
152
|
-
const removeMandatoryDenyPlaceholders = createMandatoryDenyPlaceholders(cwd, agentDir);
|
|
153
|
-
process.once("exit", removeMandatoryDenyPlaceholders);
|
|
154
|
-
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
155
|
-
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
|
156
|
-
const nodeRoot = dirname(dirname(process.execPath));
|
|
157
|
-
const runtimeRoot = dirname(dirname(fileURLToPath(import.meta.resolve("@anthropic-ai/sandbox-runtime"))));
|
|
158
|
-
const extraRead = (process.env.PI_SANDBOX_EXTERNAL_ALLOW_READ || "")
|
|
159
|
-
.split(":")
|
|
160
|
-
.filter(Boolean);
|
|
161
|
-
const existingGitConfig = [
|
|
162
|
-
join(home, ".gitconfig"),
|
|
163
|
-
join(home, ".config", "git", "config"),
|
|
164
|
-
].filter(existsSync);
|
|
165
|
-
// On Linux Sandbox Runtime masks the first missing component of a deny path.
|
|
166
|
-
// Denying a not-yet-created `.pi/settings.json` would therefore mask `.pi`
|
|
167
|
-
// itself and prevent Pi from creating its ordinary project state directory.
|
|
168
|
-
// Existing security files remain outer-sandbox protected; creation attempts
|
|
169
|
-
// are still gated by permission-system and pi-auto-review.
|
|
170
|
-
const workspaceSecurityFiles = [
|
|
171
|
-
join(cwd, ".pi", "settings.json"),
|
|
172
|
-
join(cwd, ".pi", "sandbox.json"),
|
|
173
|
-
join(cwd, ".pi", "pi-auto-review.json"),
|
|
174
|
-
];
|
|
175
|
-
const agentSecurityPaths = [
|
|
176
|
-
join(agentDir, "settings.json"),
|
|
177
|
-
join(agentDir, "permissions.json"),
|
|
178
|
-
join(agentDir, "sandbox.json"),
|
|
179
|
-
join(agentDir, "extensions"),
|
|
180
|
-
];
|
|
181
|
-
const registration = await registerWorker();
|
|
182
|
-
if (registration.action !== "allow") {
|
|
183
|
-
process.stderr.write("pi-sandbox: external worker registration was denied\n");
|
|
184
|
-
process.exitCode = 1;
|
|
185
|
-
process.exit();
|
|
186
|
-
}
|
|
187
|
-
const registeredNetwork = decodeExternalNetworkPolicy(registration.network);
|
|
188
|
-
const runtimeConfig = {
|
|
189
|
-
filesystem: {
|
|
190
|
-
denyRead: home === parse(home).root ? [] : [home],
|
|
191
|
-
allowRead: [cwd, nodeRoot, runtimeRoot, ...existingGitConfig, "/dev/null", ...worktreeGitReadPaths(cwd), ...extraRead],
|
|
192
|
-
// Child sessions and permission-forwarding request/response files live
|
|
193
|
-
// here. Keep this narrower than the agent directory, whose security
|
|
194
|
-
// settings and extensions remain read-only.
|
|
195
|
-
allowWrite: [
|
|
196
|
-
cwd,
|
|
197
|
-
"/dev/null",
|
|
198
|
-
workerTempDir,
|
|
199
|
-
...(sessionDir?.startsWith("/") ? [sessionDir] : []),
|
|
200
|
-
// Pi creates directory-style settings locks and provider model caches
|
|
201
|
-
// beside its global settings. Security-sensitive files and extensions
|
|
202
|
-
// below remain explicit denyWrite entries.
|
|
203
|
-
agentDir,
|
|
204
|
-
],
|
|
205
|
-
denyWrite: [
|
|
206
|
-
...workspaceSecurityFiles,
|
|
207
|
-
...agentSecurityPaths,
|
|
208
|
-
packageRoot,
|
|
209
|
-
dirname(process.execPath),
|
|
210
|
-
],
|
|
211
|
-
allowGitConfig: true,
|
|
212
|
-
},
|
|
213
|
-
network: {
|
|
214
|
-
allowedDomains: registeredNetwork.allowedDomains,
|
|
215
|
-
deniedDomains: registeredNetwork.deniedDomains,
|
|
216
|
-
allowAllUnixSockets: false, allowUnixSockets: [],
|
|
217
|
-
allowLocalBinding: false,
|
|
218
|
-
},
|
|
219
|
-
};
|
|
220
|
-
const broker = fork(new URL("./srt-broker.mjs", import.meta.url), [], {
|
|
221
|
-
cwd,
|
|
222
|
-
detached: true,
|
|
223
|
-
env: {
|
|
224
|
-
...process.env,
|
|
225
|
-
PI_SANDBOX_TMPDIR: workerTempDir,
|
|
226
|
-
CLAUDE_CODE_TMPDIR: workerTempDir,
|
|
227
|
-
TMPDIR: workerTempDir,
|
|
228
|
-
TMP: workerTempDir,
|
|
229
|
-
TEMP: workerTempDir,
|
|
230
|
-
},
|
|
231
|
-
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
232
|
-
});
|
|
233
|
-
broker.stdout.pipe(process.stdout);
|
|
234
|
-
broker.stderr.pipe(process.stderr);
|
|
235
|
-
process.stdin.pipe(broker.stdin);
|
|
236
|
-
broker.on("message", (message) => {
|
|
237
|
-
if (!message || typeof message !== "object" || message.type !== "network-request") return;
|
|
238
|
-
void askSupervisor(message.hostname, message.port).then((action) => {
|
|
239
|
-
if (broker.connected) broker.send({ type: "network-response", id: message.id, action });
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
let prefix = [];
|
|
243
|
-
try { prefix = JSON.parse(process.env.PI_SANDBOX_EXTERNAL_REAL_PI_PREFIX || "[]"); } catch { prefix = []; }
|
|
244
|
-
if (!Array.isArray(prefix) || prefix.some((item) => typeof item !== "string")) {
|
|
245
|
-
process.stderr.write("pi-sandbox: external worker wrapper received invalid Pi prefix\n");
|
|
246
|
-
broker.kill("SIGKILL");
|
|
247
|
-
process.exitCode = 1;
|
|
248
|
-
} else {
|
|
249
|
-
broker.send({ type: "init", invocation: [realPi, ...prefix, ...process.argv.slice(2)], runtimeConfig });
|
|
250
|
-
}
|
|
251
|
-
const stop = (signal) => {
|
|
252
|
-
if (broker.pid) {
|
|
253
|
-
try { process.kill(-broker.pid, signal); } catch { broker.kill(signal); }
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
process.on("SIGINT", () => stop("SIGINT"));
|
|
257
|
-
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
258
|
-
broker.once("exit", async (code, signal) => {
|
|
259
|
-
await unregisterWorker();
|
|
260
|
-
process.exitCode = code ?? (signal ? 1 : 1);
|
|
261
|
-
});
|
|
262
|
-
broker.once("error", (error) => {
|
|
263
|
-
process.stderr.write(`pi-sandbox: external worker broker failed: ${error.message}\n`);
|
|
264
|
-
process.exitCode = 1;
|
|
265
|
-
});
|
|
266
|
-
}
|