@erichll/pi-sandbox 0.20.1 → 0.21.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0 - 2026-09-25
4
+
5
+ - Support project-level configuration at `.pi/extensions/pi-sandbox/config.json`.
6
+ When present, project configuration is merged with global configuration:
7
+ array permissions (`additionalAllowRead`, `allowedDomains`, `deniedDomains`,
8
+ `preflightCommandPrefixes`) form deduplicated unions, and scalar settings
9
+ (`subagents.provider`, `hostIPC.mode`, `hostIPC.retryOnUnixSocketError`)
10
+ allow project-level overrides.
11
+ - Harden project-level configuration: added `.pi/extensions/pi-sandbox/config.json`
12
+ to default sandbox `filesystem.denyWrite` policy. Project configuration is
13
+ strictly read-only for sandboxed execution processes.
14
+ - Remove legacy configuration compatibility: removed fallback loading for
15
+ `~/.pi/agent/pi-sandbox.json` and dropped deprecated `externalWorkerIsolation`
16
+ migration parsing.
17
+ - Pair with `@erichll/pi-auto-review` `^0.21.0`.
18
+
3
19
  ## 0.20.1 - 2026-09-24
4
20
 
5
21
  - Move `@anthropic-ai/sandbox-runtime` from `devDependencies` to runtime
package/README.md CHANGED
@@ -71,12 +71,7 @@ macOS uses built-in Seatbelt support but still requires `ripgrep`.
71
71
 
72
72
  ## Subagent provider
73
73
 
74
- The provider is selected only from the trusted global file
75
- `~/.pi/agent/extensions/pi-sandbox/config.json`:
76
-
77
- If that file is missing, `pi-sandbox` still loads the legacy path
78
- `~/.pi/agent/pi-sandbox.json` when present. Prefer the extension-local path for
79
- new installs.
74
+ The provider is selected from the configuration (`~/.pi/agent/extensions/pi-sandbox/config.json` globally or `.pi/extensions/pi-sandbox/config.json` project-locally):
80
75
 
81
76
  ```json
82
77
  {
@@ -141,14 +136,13 @@ Before a release, run the deterministic and model-backed portions of
141
136
  verified on upgrade.
142
137
 
143
138
  The configuration parser rejects malformed JSON, unknown fields, and unknown
144
- providers instead of silently weakening isolation. All legacy
145
- `externalWorkerIsolation` configurations produce a migration error.
139
+ providers instead of silently weakening isolation.
146
140
 
147
141
  ## Network domain policy
148
142
 
149
- Persistent domain authorization is accepted only from the trusted global
150
- `~/.pi/agent/extensions/pi-sandbox/config.json` (or its legacy trusted fallback
151
- when the new file is absent). Project configuration cannot override it.
143
+ Domain authorization can be configured globally at
144
+ `~/.pi/agent/extensions/pi-sandbox/config.json` or project-locally at
145
+ `.pi/extensions/pi-sandbox/config.json` (merged with union semantics):
152
146
 
153
147
  ```json
154
148
  {
@@ -255,9 +249,23 @@ Persistent built-in sessions support `start`, `follow_up`, `wait`, `status`,
255
249
  `stop`, and nested `handoff` operations. At most four sessions are live at
256
250
  once, and nesting depth is capped at three.
257
251
 
258
- ## Installation
252
+ ## Project-level configuration and read-only protection
253
+
254
+ In addition to user-level configuration at `~/.pi/agent/extensions/pi-sandbox/config.json`,
255
+ `pi-sandbox` supports project-level configuration placed at:
256
+
257
+ ```
258
+ .pi/extensions/pi-sandbox/config.json
259
+ ```
260
+
261
+ When present, project configuration is merged with global configuration:
262
+ - Array policies (`additionalAllowRead`, `allowedDomains`, `deniedDomains`, `preflightCommandPrefixes`) form a deduplicated union.
263
+ - Scalar settings (`provider`, `mode`, `retryOnUnixSocketError`) allow project-level overrides.
259
264
 
260
- Load `pi-auto-review` first so its broker is available:
265
+ **Read-only Security Guarantee**:
266
+ Project-level configuration is explicitly protected in the sandbox policy (`denyWrite`),
267
+ meaning sandboxed tasks can read `.pi/extensions/pi-sandbox/config.json` but can never
268
+ tamper with or overwrite it from within the sandbox.
261
269
 
262
270
  ```bash
263
271
  pi install npm:@erichll/pi-auto-review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Linux (bubblewrap) and macOS (Seatbelt) process sandbox for Pi backed by Anthropic's @anthropic-ai/sandbox-runtime. Fail-closed static filesystem policy plus per-connection network approval from pi-auto-review; sandboxes main-agent Bash and built-in subagent process trees.",
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.77",
54
- "@erichll/pi-auto-review": "^0.20.1",
54
+ "@erichll/pi-auto-review": "^0.21.0",
55
55
  "jiti": "2.7.0"
56
56
  },
57
57
  "peerDependencies": {
package/src/config.ts CHANGED
@@ -40,8 +40,10 @@ export type PiSandboxConfig = {
40
40
 
41
41
  export type LoadPiSandboxConfigOptions = {
42
42
  path?: string;
43
- /** Override home directory when resolving default/legacy trusted paths. */
43
+ /** Override home directory when resolving default trusted paths. */
44
44
  home?: string;
45
+ /** Override workspace directory when resolving project-level config path. */
46
+ cwd?: string;
45
47
  };
46
48
 
47
49
  export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freeze(
@@ -75,9 +77,15 @@ export function getPiSandboxConfigPath(home = homedir()): string {
75
77
  );
76
78
  }
77
79
 
78
- /** Legacy trusted path used before the extension-local config layout. */
79
- export function getLegacyPiSandboxConfigPath(home = homedir()): string {
80
- return join(home, ".pi", "agent", "pi-sandbox.json");
80
+ export const PROJECT_PI_SANDBOX_CONFIG_PATH = join(
81
+ ".pi",
82
+ "extensions",
83
+ "pi-sandbox",
84
+ "config.json",
85
+ );
86
+
87
+ export function getProjectPiSandboxConfigPath(cwd = process.cwd()): string {
88
+ return join(cwd, PROJECT_PI_SANDBOX_CONFIG_PATH);
81
89
  }
82
90
 
83
91
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -122,7 +130,20 @@ function hasValidDomainLabels(
122
130
  );
123
131
  }
124
132
 
125
- export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
133
+ export type PartialPiSandboxConfig = {
134
+ subagents?: {
135
+ provider?: SubagentProvider;
136
+ protection?: "native-background-tools";
137
+ allowedNativeAgents?: readonly string[];
138
+ };
139
+ filesystem?: {
140
+ additionalAllowRead?: readonly string[];
141
+ };
142
+ network?: Partial<NetworkConfig>;
143
+ hostIPC?: Partial<HostIPCConfig>;
144
+ };
145
+
146
+ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig {
126
147
  if (!isRecord(value)) {
127
148
  throw new Error("invalid pi-sandbox configuration: root must be an object");
128
149
  }
@@ -132,212 +153,271 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
132
153
  "root",
133
154
  );
134
155
 
135
- if (value.subagents !== undefined && !isRecord(value.subagents)) {
136
- throw new Error(
137
- "invalid pi-sandbox configuration: subagents must be an object",
138
- );
139
- }
140
- const subagents = value.subagents ?? {};
141
- if (Object.hasOwn(subagents, "externalWorkerIsolation")) {
142
- throw new Error(
143
- "invalid pi-sandbox configuration: subagents.externalWorkerIsolation was removed in 0.16.0; migrate pi-subagents to protection 'native-background-tools' with allowedNativeAgents, or use provider 'builtin' for OS-level worker isolation",
144
- );
145
- }
146
- rejectUnknownKeys(
147
- subagents,
148
- ["provider", "protection", "allowedNativeAgents"],
149
- "subagents",
150
- );
151
-
152
- const provider =
153
- subagents.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
154
- if (
155
- typeof provider !== "string" ||
156
- !SUBAGENT_PROVIDERS.includes(provider as SubagentProvider)
157
- ) {
158
- throw new Error(
159
- `invalid pi-sandbox configuration: subagents.provider must be one of ${SUBAGENT_PROVIDERS.join(", ")}`,
160
- );
161
- }
162
- const protection = subagents.protection;
163
- const allowedNativeAgents = subagents.allowedNativeAgents;
164
- if (provider === "pi-subagents" && protection !== "native-background-tools") {
165
- throw new Error(
166
- "invalid pi-sandbox configuration: provider 'pi-subagents' requires subagents.protection 'native-background-tools'; migrate legacy configurations explicitly",
167
- );
168
- }
169
- if (provider !== "pi-subagents" && (protection !== undefined || allowedNativeAgents !== undefined)) {
170
- throw new Error(
171
- "invalid pi-sandbox configuration: subagents.protection and allowedNativeAgents are only valid with provider 'pi-subagents'",
156
+ let parsedSubagents: PartialPiSandboxConfig["subagents"] | undefined;
157
+ if (value.subagents !== undefined) {
158
+ if (!isRecord(value.subagents)) {
159
+ throw new Error(
160
+ "invalid pi-sandbox configuration: subagents must be an object",
161
+ );
162
+ }
163
+ rejectUnknownKeys(
164
+ value.subagents,
165
+ ["provider", "protection", "allowedNativeAgents"],
166
+ "subagents",
172
167
  );
173
- }
174
- if (provider === "pi-subagents") {
175
- if (!Array.isArray(allowedNativeAgents) || allowedNativeAgents.length === 0) {
168
+ const provider = value.subagents.provider;
169
+ if (
170
+ provider !== undefined &&
171
+ (typeof provider !== "string" ||
172
+ !SUBAGENT_PROVIDERS.includes(provider as SubagentProvider))
173
+ ) {
176
174
  throw new Error(
177
- "invalid pi-sandbox configuration: subagents.allowedNativeAgents must be a non-empty array of canonical agent names",
175
+ `invalid pi-sandbox configuration: subagents.provider must be one of ${SUBAGENT_PROVIDERS.join(", ")}`,
178
176
  );
179
177
  }
180
- if (allowedNativeAgents.some((name) =>
181
- typeof name !== "string" ||
182
- name !== name.trim() ||
183
- !/^[A-Za-z0-9_.:-]+$/u.test(name)
184
- )) {
178
+ const protection = value.subagents.protection;
179
+ const allowedNativeAgents = value.subagents.allowedNativeAgents;
180
+ if (provider === "pi-subagents" && protection !== "native-background-tools") {
185
181
  throw new Error(
186
- "invalid pi-sandbox configuration: subagents.allowedNativeAgents must contain only canonical agent names",
182
+ "invalid pi-sandbox configuration: provider 'pi-subagents' requires subagents.protection 'native-background-tools'; migrate legacy configurations explicitly",
187
183
  );
188
184
  }
189
- if (new Set(allowedNativeAgents).size !== allowedNativeAgents.length) {
185
+ if (provider !== undefined && provider !== "pi-subagents" && (protection !== undefined || allowedNativeAgents !== undefined)) {
190
186
  throw new Error(
191
- "invalid pi-sandbox configuration: subagents.allowedNativeAgents must not contain duplicates",
187
+ "invalid pi-sandbox configuration: subagents.protection and allowedNativeAgents are only valid with provider 'pi-subagents'",
192
188
  );
193
189
  }
190
+ if (provider === "pi-subagents") {
191
+ if (!Array.isArray(allowedNativeAgents) || allowedNativeAgents.length === 0) {
192
+ throw new Error(
193
+ "invalid pi-sandbox configuration: subagents.allowedNativeAgents must be a non-empty array of canonical agent names",
194
+ );
195
+ }
196
+ if (allowedNativeAgents.some((name) =>
197
+ typeof name !== "string" ||
198
+ name !== name.trim() ||
199
+ !/^[A-Za-z0-9_.:-]+$/u.test(name)
200
+ )) {
201
+ throw new Error(
202
+ "invalid pi-sandbox configuration: subagents.allowedNativeAgents must contain only canonical agent names",
203
+ );
204
+ }
205
+ if (new Set(allowedNativeAgents).size !== allowedNativeAgents.length) {
206
+ throw new Error(
207
+ "invalid pi-sandbox configuration: subagents.allowedNativeAgents must not contain duplicates",
208
+ );
209
+ }
210
+ }
211
+ parsedSubagents = {
212
+ ...(provider !== undefined ? { provider: provider as SubagentProvider } : {}),
213
+ ...(provider === "pi-subagents"
214
+ ? {
215
+ protection: "native-background-tools" as const,
216
+ allowedNativeAgents: [...(allowedNativeAgents as string[])],
217
+ }
218
+ : {}),
219
+ };
194
220
  }
195
221
 
196
- if (value.filesystem !== undefined && !isRecord(value.filesystem)) {
197
- throw new Error(
198
- "invalid pi-sandbox configuration: filesystem must be an object",
199
- );
200
- }
201
- const filesystem = value.filesystem ?? {};
202
- rejectUnknownKeys(
203
- filesystem,
204
- ["additionalAllowRead"],
205
- "filesystem",
206
- );
207
- const additionalAllowRead =
208
- filesystem.additionalAllowRead ??
209
- DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead;
210
- if (
211
- !Array.isArray(additionalAllowRead) ||
212
- additionalAllowRead.some(
213
- (path) =>
214
- typeof path !== "string" ||
215
- path.trim() === "" ||
216
- !isAbsolute(path),
217
- )
218
- ) {
219
- throw new Error(
220
- "invalid pi-sandbox configuration: filesystem.additionalAllowRead must be an array of absolute paths",
222
+ let parsedFilesystem: PartialPiSandboxConfig["filesystem"] | undefined;
223
+ if (value.filesystem !== undefined) {
224
+ if (!isRecord(value.filesystem)) {
225
+ throw new Error(
226
+ "invalid pi-sandbox configuration: filesystem must be an object",
227
+ );
228
+ }
229
+ rejectUnknownKeys(
230
+ value.filesystem,
231
+ ["additionalAllowRead"],
232
+ "filesystem",
221
233
  );
234
+ const additionalAllowRead = value.filesystem.additionalAllowRead;
235
+ if (additionalAllowRead !== undefined) {
236
+ if (
237
+ !Array.isArray(additionalAllowRead) ||
238
+ additionalAllowRead.some(
239
+ (path) =>
240
+ typeof path !== "string" ||
241
+ path.trim() === "" ||
242
+ !isAbsolute(path),
243
+ )
244
+ ) {
245
+ throw new Error(
246
+ "invalid pi-sandbox configuration: filesystem.additionalAllowRead must be an array of absolute paths",
247
+ );
248
+ }
249
+ parsedFilesystem = {
250
+ additionalAllowRead: [...new Set(additionalAllowRead)],
251
+ };
252
+ } else {
253
+ parsedFilesystem = {};
254
+ }
222
255
  }
223
256
 
224
- if (value.network !== undefined && !isRecord(value.network)) {
225
- throw new Error(
226
- "invalid pi-sandbox configuration: network must be an object",
257
+ let parsedNetwork: PartialPiSandboxConfig["network"] | undefined;
258
+ if (value.network !== undefined) {
259
+ if (!isRecord(value.network)) {
260
+ throw new Error(
261
+ "invalid pi-sandbox configuration: network must be an object",
262
+ );
263
+ }
264
+ rejectUnknownKeys(
265
+ value.network,
266
+ ["allowedDomains", "deniedDomains"],
267
+ "network",
227
268
  );
269
+ const normalizeDomainList = (
270
+ key: "allowedDomains" | "deniedDomains",
271
+ ): string[] | undefined => {
272
+ const configured = (value.network as Record<string, unknown>)[key];
273
+ if (configured === undefined) return undefined;
274
+ if (
275
+ !Array.isArray(configured) ||
276
+ configured.some(
277
+ (pattern) => typeof pattern !== "string" || pattern.trim() === "",
278
+ )
279
+ ) {
280
+ throw new Error(
281
+ `invalid pi-sandbox configuration: network.${key} must be an array of non-empty strings`,
282
+ );
283
+ }
284
+ const normalized = [
285
+ ...new Set(configured.map((pattern) => pattern.trim())),
286
+ ];
287
+ const invalidIndex = normalized.findIndex(
288
+ (pattern) => !hasValidDomainLabels(pattern, key === "deniedDomains"),
289
+ );
290
+ if (invalidIndex >= 0) {
291
+ throw new Error(
292
+ `invalid pi-sandbox configuration: network.${key}[${invalidIndex}] contains an invalid domain pattern`,
293
+ );
294
+ }
295
+ const candidate = {
296
+ allowedDomains: key === "allowedDomains" ? normalized : [],
297
+ deniedDomains: key === "deniedDomains" ? normalized : [],
298
+ };
299
+ const result = NetworkConfigSchema.safeParse(candidate);
300
+ if (!result.success) {
301
+ const issue = result.error.issues[0];
302
+ throw new Error(
303
+ `invalid pi-sandbox configuration: network.${key}${typeof issue?.path[1] === "number" ? `[${issue.path[1]}]` : ""} ${issue?.message ?? "contains an invalid domain pattern"}`,
304
+ );
305
+ }
306
+ return normalized;
307
+ };
308
+ const allowed = normalizeDomainList("allowedDomains");
309
+ const denied = normalizeDomainList("deniedDomains");
310
+ parsedNetwork = {
311
+ ...(allowed !== undefined ? { allowedDomains: allowed } : {}),
312
+ ...(denied !== undefined ? { deniedDomains: denied } : {}),
313
+ };
228
314
  }
229
- const network = value.network ?? {};
230
- rejectUnknownKeys(
231
- network,
232
- ["allowedDomains", "deniedDomains"],
233
- "network",
234
- );
235
- const normalizeDomainList = (
236
- key: "allowedDomains" | "deniedDomains",
237
- ): string[] => {
238
- const configured = network[key] ?? DEFAULT_PI_SANDBOX_CONFIG.network[key];
239
- if (
240
- !Array.isArray(configured) ||
241
- configured.some(
242
- (pattern) => typeof pattern !== "string" || pattern.trim() === "",
243
- )
244
- ) {
315
+
316
+ let parsedHostIPC: PartialPiSandboxConfig["hostIPC"] | undefined;
317
+ if (value.hostIPC !== undefined) {
318
+ if (!isRecord(value.hostIPC)) {
245
319
  throw new Error(
246
- `invalid pi-sandbox configuration: network.${key} must be an array of non-empty strings`,
320
+ "invalid pi-sandbox configuration: hostIPC must be an object",
247
321
  );
248
322
  }
249
- const normalized = [
250
- ...new Set(configured.map((pattern) => pattern.trim())),
251
- ];
252
- const invalidIndex = normalized.findIndex(
253
- (pattern) => !hasValidDomainLabels(pattern, key === "deniedDomains"),
323
+ rejectUnknownKeys(
324
+ value.hostIPC,
325
+ ["mode", "preflightCommandPrefixes", "retryOnUnixSocketError"],
326
+ "hostIPC",
254
327
  );
255
- if (invalidIndex >= 0) {
328
+ const mode = value.hostIPC.mode;
329
+ if (
330
+ mode !== undefined &&
331
+ (typeof mode !== "string" ||
332
+ !HOST_IPC_MODES.includes(mode as HostIPCMode))
333
+ ) {
256
334
  throw new Error(
257
- `invalid pi-sandbox configuration: network.${key}[${invalidIndex}] contains an invalid domain pattern`,
335
+ `invalid pi-sandbox configuration: hostIPC.mode must be one of ${HOST_IPC_MODES.join(", ")}`,
258
336
  );
259
337
  }
260
- const candidate = {
261
- allowedDomains: key === "allowedDomains" ? normalized : [],
262
- deniedDomains: key === "deniedDomains" ? normalized : [],
263
- };
264
- const result = NetworkConfigSchema.safeParse(candidate);
265
- if (!result.success) {
266
- const issue = result.error.issues[0];
338
+ const preflight = value.hostIPC.preflightCommandPrefixes;
339
+ if (preflight !== undefined) {
340
+ if (
341
+ !Array.isArray(preflight) ||
342
+ preflight.some(
343
+ (prefix) => typeof prefix !== "string" || prefix.trim() === "",
344
+ )
345
+ ) {
346
+ throw new Error(
347
+ "invalid pi-sandbox configuration: hostIPC.preflightCommandPrefixes must be an array of non-empty strings",
348
+ );
349
+ }
350
+ }
351
+ const retry = value.hostIPC.retryOnUnixSocketError;
352
+ if (retry !== undefined && typeof retry !== "boolean") {
267
353
  throw new Error(
268
- `invalid pi-sandbox configuration: network.${key}${typeof issue?.path[1] === "number" ? `[${issue.path[1]}]` : ""} ${issue?.message ?? "contains an invalid domain pattern"}`,
354
+ "invalid pi-sandbox configuration: hostIPC.retryOnUnixSocketError must be a boolean",
269
355
  );
270
356
  }
271
- return normalized;
357
+ parsedHostIPC = {
358
+ ...(mode !== undefined ? { mode: mode as HostIPCMode } : {}),
359
+ ...(preflight !== undefined
360
+ ? {
361
+ preflightCommandPrefixes: [
362
+ ...new Set(preflight.map((prefix) => prefix.trim())),
363
+ ],
364
+ }
365
+ : {}),
366
+ ...(retry !== undefined ? { retryOnUnixSocketError: retry } : {}),
367
+ };
368
+ }
369
+
370
+ return {
371
+ ...(parsedSubagents !== undefined ? { subagents: parsedSubagents } : {}),
372
+ ...(parsedFilesystem !== undefined ? { filesystem: parsedFilesystem } : {}),
373
+ ...(parsedNetwork !== undefined ? { network: parsedNetwork } : {}),
374
+ ...(parsedHostIPC !== undefined ? { hostIPC: parsedHostIPC } : {}),
272
375
  };
273
- const allowedDomains = normalizeDomainList("allowedDomains");
274
- const deniedDomains = normalizeDomainList("deniedDomains");
376
+ }
275
377
 
276
- if (value.hostIPC !== undefined && !isRecord(value.hostIPC)) {
277
- throw new Error(
278
- "invalid pi-sandbox configuration: hostIPC must be an object",
279
- );
280
- }
281
- const hostIPC = value.hostIPC ?? {};
282
- rejectUnknownKeys(
283
- hostIPC,
284
- ["mode", "preflightCommandPrefixes", "retryOnUnixSocketError"],
285
- "hostIPC",
286
- );
287
- const hostIPCMode = hostIPC.mode ?? DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode;
288
- if (
289
- typeof hostIPCMode !== "string" ||
290
- !HOST_IPC_MODES.includes(hostIPCMode as HostIPCMode)
291
- ) {
292
- throw new Error(
293
- `invalid pi-sandbox configuration: hostIPC.mode must be one of ${HOST_IPC_MODES.join(", ")}`,
294
- );
295
- }
296
- const preflightCommandPrefixes =
297
- hostIPC.preflightCommandPrefixes ??
298
- DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes;
299
- if (
300
- !Array.isArray(preflightCommandPrefixes) ||
301
- preflightCommandPrefixes.some(
302
- (prefix) => typeof prefix !== "string" || prefix.trim() === "",
303
- )
304
- ) {
305
- throw new Error(
306
- "invalid pi-sandbox configuration: hostIPC.preflightCommandPrefixes must be an array of non-empty strings",
307
- );
308
- }
309
- const retryOnUnixSocketError =
310
- hostIPC.retryOnUnixSocketError ??
311
- DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError;
312
- if (typeof retryOnUnixSocketError !== "boolean") {
313
- throw new Error(
314
- "invalid pi-sandbox configuration: hostIPC.retryOnUnixSocketError must be a boolean",
315
- );
316
- }
378
+ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
379
+ const raw = parsePiSandboxConfigRaw(value);
380
+ const provider =
381
+ raw.subagents?.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
382
+ const isPiSubagents = provider === "pi-subagents";
317
383
 
318
384
  return {
319
385
  subagents: {
320
- provider: provider as SubagentProvider,
321
- ...(provider === "pi-subagents"
386
+ provider,
387
+ ...(isPiSubagents
322
388
  ? {
323
389
  protection: "native-background-tools" as const,
324
- allowedNativeAgents: [...(allowedNativeAgents as string[])],
390
+ allowedNativeAgents: [
391
+ ...(raw.subagents?.allowedNativeAgents ?? []),
392
+ ],
325
393
  }
326
394
  : {}),
327
395
  },
328
396
  filesystem: {
329
- additionalAllowRead: [...new Set(additionalAllowRead)],
397
+ additionalAllowRead: [
398
+ ...(raw.filesystem?.additionalAllowRead ??
399
+ DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead),
400
+ ],
330
401
  },
331
402
  network: {
332
- allowedDomains: [...allowedDomains],
333
- deniedDomains: [...deniedDomains],
403
+ allowedDomains: [
404
+ ...(raw.network?.allowedDomains ??
405
+ DEFAULT_PI_SANDBOX_CONFIG.network.allowedDomains),
406
+ ],
407
+ deniedDomains: [
408
+ ...(raw.network?.deniedDomains ??
409
+ DEFAULT_PI_SANDBOX_CONFIG.network.deniedDomains),
410
+ ],
334
411
  },
335
412
  hostIPC: {
336
- mode: hostIPCMode as HostIPCMode,
413
+ mode: raw.hostIPC?.mode ?? DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
337
414
  preflightCommandPrefixes: [
338
- ...new Set(preflightCommandPrefixes.map((prefix) => prefix.trim())),
415
+ ...(raw.hostIPC?.preflightCommandPrefixes ??
416
+ DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes),
339
417
  ],
340
- retryOnUnixSocketError,
418
+ retryOnUnixSocketError:
419
+ raw.hostIPC?.retryOnUnixSocketError ??
420
+ DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError,
341
421
  },
342
422
  };
343
423
  }
@@ -401,32 +481,123 @@ function parsePiSandboxConfigFile(path: string, source: string): PiSandboxConfig
401
481
  return parsePiSandboxConfig(value);
402
482
  }
403
483
 
484
+ function parsePiSandboxConfigFileRaw(path: string, source: string): PartialPiSandboxConfig {
485
+ let value: unknown;
486
+ try {
487
+ value = JSON.parse(source);
488
+ } catch (error) {
489
+ throw new Error(`invalid JSON in pi-sandbox configuration at ${path}`, {
490
+ cause: error,
491
+ });
492
+ }
493
+ return parsePiSandboxConfigRaw(value);
494
+ }
495
+
496
+ export function mergePiSandboxConfigs(
497
+ trusted: PiSandboxConfig,
498
+ project: PartialPiSandboxConfig,
499
+ ): PiSandboxConfig {
500
+ const provider = project.subagents?.provider ?? trusted.subagents.provider;
501
+ const isPiSubagents = provider === "pi-subagents";
502
+
503
+ let allowedNativeAgents: string[] | undefined;
504
+ if (isPiSubagents) {
505
+ const trustedAgents = trusted.subagents.allowedNativeAgents ?? [];
506
+ const projectAgents = project.subagents?.allowedNativeAgents ?? [];
507
+ allowedNativeAgents = [...new Set([...trustedAgents, ...projectAgents])];
508
+ }
509
+
510
+ return {
511
+ subagents: {
512
+ provider,
513
+ ...(isPiSubagents
514
+ ? {
515
+ protection: "native-background-tools" as const,
516
+ allowedNativeAgents,
517
+ }
518
+ : {}),
519
+ },
520
+ filesystem: {
521
+ additionalAllowRead: [
522
+ ...new Set([
523
+ ...trusted.filesystem.additionalAllowRead,
524
+ ...(project.filesystem?.additionalAllowRead ?? []),
525
+ ]),
526
+ ],
527
+ },
528
+ network: {
529
+ allowedDomains: [
530
+ ...new Set([
531
+ ...trusted.network.allowedDomains,
532
+ ...(project.network?.allowedDomains ?? []),
533
+ ]),
534
+ ],
535
+ deniedDomains: [
536
+ ...new Set([
537
+ ...trusted.network.deniedDomains,
538
+ ...(project.network?.deniedDomains ?? []),
539
+ ]),
540
+ ],
541
+ },
542
+ hostIPC: {
543
+ mode: project.hostIPC?.mode ?? trusted.hostIPC.mode,
544
+ preflightCommandPrefixes: [
545
+ ...new Set([
546
+ ...trusted.hostIPC.preflightCommandPrefixes,
547
+ ...(project.hostIPC?.preflightCommandPrefixes ?? []),
548
+ ]),
549
+ ],
550
+ retryOnUnixSocketError:
551
+ project.hostIPC?.retryOnUnixSocketError ??
552
+ trusted.hostIPC.retryOnUnixSocketError,
553
+ },
554
+ };
555
+ }
556
+
404
557
  export function loadPiSandboxConfig(
405
558
  options: LoadPiSandboxConfigOptions = {},
406
559
  ): PiSandboxConfig {
407
- const path = options.path ?? getPiSandboxConfigPath(options.home);
560
+ if (options.path !== undefined) {
561
+ const path = options.path;
562
+ try {
563
+ return parsePiSandboxConfigFile(path, readPiSandboxConfigFile(path));
564
+ } catch (error) {
565
+ if (isNotFoundError(error)) {
566
+ return defaultPiSandboxConfig();
567
+ }
568
+ throw error;
569
+ }
570
+ }
571
+
572
+ const globalPath = getPiSandboxConfigPath(options.home);
573
+ let globalConfig: PiSandboxConfig | undefined;
408
574
  try {
409
- return parsePiSandboxConfigFile(path, readPiSandboxConfigFile(path));
575
+ globalConfig = parsePiSandboxConfigFile(
576
+ globalPath,
577
+ readPiSandboxConfigFile(globalPath),
578
+ );
410
579
  } catch (error) {
411
580
  if (!isNotFoundError(error)) {
412
581
  throw error;
413
582
  }
414
583
  }
415
584
 
416
- // Only fall back to the legacy path when loading the default trusted location.
417
- if (options.path === undefined) {
418
- const legacyPath = getLegacyPiSandboxConfigPath(options.home);
419
- try {
420
- return parsePiSandboxConfigFile(
421
- legacyPath,
422
- readPiSandboxConfigFile(legacyPath),
423
- );
424
- } catch (error) {
425
- if (!isNotFoundError(error)) {
426
- throw error;
427
- }
585
+ const projectPath = getProjectPiSandboxConfigPath(options.cwd);
586
+ let projectRaw: PartialPiSandboxConfig | undefined;
587
+ try {
588
+ projectRaw = parsePiSandboxConfigFileRaw(
589
+ projectPath,
590
+ readPiSandboxConfigFile(projectPath),
591
+ );
592
+ } catch (error) {
593
+ if (!isNotFoundError(error)) {
594
+ throw error;
428
595
  }
429
596
  }
430
597
 
431
- return defaultPiSandboxConfig();
598
+ const base = globalConfig ?? defaultPiSandboxConfig();
599
+ if (projectRaw) {
600
+ return mergePiSandboxConfigs(base, projectRaw);
601
+ }
602
+ return base;
432
603
  }
package/src/index.ts CHANGED
@@ -218,7 +218,8 @@ async function performRegistration(
218
218
  options: PiSandboxExtensionOptions,
219
219
  ): Promise<void> {
220
220
  let currentTurn = 0;
221
- const config = loadPiSandboxConfig();
221
+ const cwd = process.cwd();
222
+ const config = loadPiSandboxConfig({ cwd });
222
223
  const subagentProvider =
223
224
  options.subagentProvider ?? config.subagents.provider;
224
225
  const isNativeSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
@@ -246,7 +247,6 @@ async function performRegistration(
246
247
  maxDepth: 3,
247
248
  }))
248
249
  : undefined;
249
- const cwd = process.cwd();
250
250
  let capabilityCeiling: CapabilityCeilingHandle | undefined;
251
251
  let validatedNativeAgents: string[] = [];
252
252
  const localBash = createBashToolDefinition(cwd);
package/src/policy.ts CHANGED
@@ -10,7 +10,10 @@ import {
10
10
  } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
13
- import type { NetworkConfig } from "./config.ts";
13
+ import {
14
+ PROJECT_PI_SANDBOX_CONFIG_PATH,
15
+ type NetworkConfig,
16
+ } from "./config.ts";
14
17
 
15
18
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
19
  const NODE_INSTALL_ROOT = dirname(dirname(process.execPath));
@@ -248,6 +251,7 @@ export function createDefaultPolicy(
248
251
  allowWrite: [workspace, "/dev/null"],
249
252
  denyWrite: [
250
253
  join(workspace, ".pi", "pi-auto-review.json"),
254
+ join(workspace, PROJECT_PI_SANDBOX_CONFIG_PATH),
251
255
  join(home, ".pi", "agent", "logs"),
252
256
  // Prevent the sandbox from installing or rewriting trusted extensions
253
257
  // (includes ~/.pi/agent/extensions/pi-sandbox/config.json).