@erichll/pi-sandbox 0.20.1 → 0.21.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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1 - 2026-09-26
4
+
5
+ - Alias `@earendil-works/pi-tui` for the `jiti` instance that loads the
6
+ `pi-subagents` internals. `pi-subagents` imports that host peer from its
7
+ internal modules but does not ship it, and Pi's own extension loader aliases
8
+ it to the copy inside the running Pi package. The protected-mode loader
9
+ created its own `jiti` instance without that alias, so installs where the peer
10
+ is not hoisted next to the extension (for example the global
11
+ `~/.pi/agent/npm` tree) failed to load as soon as `subagents.provider` was
12
+ `pi-subagents` with `pi-subagents compatibility failure: Cannot find module
13
+ '@earendil-works/pi-tui'`. The alias is computed host-first from the running Pi
14
+ package (`process.argv[1]`, `PI_PACKAGE_DIR`) and falls back to plain
15
+ resolution from the extension tree; an unresolvable host peer still fails
16
+ closed with that explicit error.
17
+ - Add regression coverage for host alias resolution, including a `jiti` load in
18
+ a tree where `@earendil-works/pi-tui` is unreachable without the alias.
19
+
20
+ ## 0.21.0 - 2026-09-25
21
+
22
+ - Support project-level configuration at `.pi/extensions/pi-sandbox/config.json`.
23
+ When present, project configuration is merged with global configuration:
24
+ array permissions (`additionalAllowRead`, `allowedDomains`, `deniedDomains`,
25
+ `preflightCommandPrefixes`) form deduplicated unions, and scalar settings
26
+ (`subagents.provider`, `hostIPC.mode`, `hostIPC.retryOnUnixSocketError`)
27
+ allow project-level overrides.
28
+ - Harden project-level configuration: added `.pi/extensions/pi-sandbox/config.json`
29
+ to default sandbox `filesystem.denyWrite` policy. Project configuration is
30
+ strictly read-only for sandboxed execution processes.
31
+ - Remove legacy configuration compatibility: removed fallback loading for
32
+ `~/.pi/agent/pi-sandbox.json` and dropped deprecated `externalWorkerIsolation`
33
+ migration parsing.
34
+ - Pair with `@erichll/pi-auto-review` `^0.21.0`.
35
+
3
36
  ## 0.20.1 - 2026-09-24
4
37
 
5
38
  - 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
  {
@@ -99,7 +94,10 @@ Supported modes:
99
94
  This provider supports the `pi-subagents >=0.66.0` line (validated through
100
95
  0.71.0) and fails closed if its public ceiling API, module layout, or internal
101
96
  discovery layout drifts. The peer dependency is a floor with no upper pin; the
102
- loader's explicit export and layout checks are the real gate.
97
+ loader's explicit export and layout checks are the real gate. The loader also
98
+ aliases `@earendil-works/pi-tui`, a host peer that the `pi-subagents` internals
99
+ import but do not ship, to the copy inside the running Pi package, so installs
100
+ where it is not hoisted next to the extension still load.
103
101
  Configure both the protection mode and a non-empty canonical whitelist:
104
102
 
105
103
  ```json
@@ -141,14 +139,13 @@ Before a release, run the deterministic and model-backed portions of
141
139
  verified on upgrade.
142
140
 
143
141
  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.
142
+ providers instead of silently weakening isolation.
146
143
 
147
144
  ## Network domain policy
148
145
 
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.
146
+ Domain authorization can be configured globally at
147
+ `~/.pi/agent/extensions/pi-sandbox/config.json` or project-locally at
148
+ `.pi/extensions/pi-sandbox/config.json` (merged with union semantics):
152
149
 
153
150
  ```json
154
151
  {
@@ -255,9 +252,23 @@ Persistent built-in sessions support `start`, `follow_up`, `wait`, `status`,
255
252
  `stop`, and nested `handoff` operations. At most four sessions are live at
256
253
  once, and nesting depth is capped at three.
257
254
 
258
- ## Installation
255
+ ## Project-level configuration and read-only protection
256
+
257
+ In addition to user-level configuration at `~/.pi/agent/extensions/pi-sandbox/config.json`,
258
+ `pi-sandbox` supports project-level configuration placed at:
259
+
260
+ ```
261
+ .pi/extensions/pi-sandbox/config.json
262
+ ```
263
+
264
+ When present, project configuration is merged with global configuration:
265
+ - Array policies (`additionalAllowRead`, `allowedDomains`, `deniedDomains`, `preflightCommandPrefixes`) form a deduplicated union.
266
+ - Scalar settings (`provider`, `mode`, `retryOnUnixSocketError`) allow project-level overrides.
259
267
 
260
- Load `pi-auto-review` first so its broker is available:
268
+ **Read-only Security Guarantee**:
269
+ Project-level configuration is explicitly protected in the sandbox policy (`denyWrite`),
270
+ meaning sandboxed tasks can read `.pi/extensions/pi-sandbox/config.json` but can never
271
+ tamper with or overwrite it from within the sandbox.
261
272
 
262
273
  ```bash
263
274
  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.1",
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);
@@ -1,4 +1,5 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath, pathToFileURL } from "node:url";
4
5
  import { createJiti } from "jiti";
@@ -24,6 +25,10 @@ export const PI_SUBAGENTS_COMPAT_RANGE = ">=0.66.0";
24
25
  export const NATIVE_CHILD_TOOLS = ["bash", "read", "grep", "find", "ls"] as const;
25
26
  export const PI_SANDBOX_ACKNOWLEDGEMENT = "@erichll:pi-sandbox";
26
27
 
28
+ const HOST_PI_PACKAGE = "@earendil-works/pi-coding-agent";
29
+ /** Host peer that pi-subagents' internal modules import but that is not installed next to it. */
30
+ const HOST_PI_TUI_PACKAGE = "@earendil-works/pi-tui";
31
+
27
32
  /**
28
33
  * Module extension of the installed pi-subagents layout. Source checkouts (and
29
34
  * the 0.66.0-0.69.0 npm line) ship `.ts` modules that need a TypeScript loader;
@@ -142,6 +147,89 @@ export function piSubagentsInternalModulePath(
142
147
  return `${relativePath.replace(/\.(?:ts|js)$/u, "")}${extension}`;
143
148
  }
144
149
 
150
+ async function readPackageName(dir: string): Promise<string | undefined> {
151
+ try {
152
+ const manifest = JSON.parse(
153
+ await readFile(join(dir, "package.json"), "utf8"),
154
+ ) as { name?: unknown };
155
+ return typeof manifest?.name === "string" ? manifest.name : undefined;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Walk up from a Pi entry script (or a Pi package directory) to the running
163
+ * `@earendil-works/pi-coding-agent` root. Returns undefined for launchers whose
164
+ * entry is not a real file (compiled binaries, embedded SDK hosts).
165
+ */
166
+ export async function findPiPackageRootFromEntry(
167
+ entryPath: string,
168
+ ): Promise<string | undefined> {
169
+ let dir = entryPath;
170
+ for (;;) {
171
+ if ((await readPackageName(dir)) === HOST_PI_PACKAGE) return dir;
172
+ const parent = dirname(dir);
173
+ if (parent === dir) return undefined;
174
+ dir = parent;
175
+ }
176
+ }
177
+
178
+ function resolveFrom(base: string, specifier: string): string | undefined {
179
+ try {
180
+ return createRequire(base).resolve(specifier);
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Alias map for the jiti instance that loads pi-subagents internals.
188
+ *
189
+ * pi-subagents imports `@earendil-works/pi-tui` from its internal modules but,
190
+ * as a peer dependency, does not ship it. Pi's own extension loader aliases that
191
+ * specifier to the copy inside the running Pi package (`getAliases()` in the
192
+ * host loader), so extensions loaded by Pi resolve it; our jiti instance is a
193
+ * separate resolver and would otherwise fall back to plain Node resolution,
194
+ * which fails for installs where pi-tui is neither hoisted next to the
195
+ * extension nor installed beside `@earendil-works/pi-subagents`.
196
+ *
197
+ * The alias is computed host-first so the loaded modules share the host's
198
+ * pi-tui instance, and degrades to an empty map when no copy is reachable: the
199
+ * hard failure then stays the explicit compatibility error raised on load.
200
+ */
201
+ export async function resolvePiSubagentsHostAliases(
202
+ options: { entry?: string; moduleUrl?: string; env?: Record<string, string | undefined> } = {},
203
+ ): Promise<Record<string, string>> {
204
+ const bases: string[] = [];
205
+ const entry = options.entry ?? process.argv[1];
206
+ if (entry) {
207
+ const realEntry = await realpath(entry).catch(() => undefined);
208
+ const root = realEntry
209
+ ? await findPiPackageRootFromEntry(realEntry)
210
+ : undefined;
211
+ if (root) bases.push(join(root, "package.json"));
212
+ }
213
+ const packageDir = (options.env ?? process.env).PI_PACKAGE_DIR?.trim();
214
+ if (packageDir && (await readPackageName(packageDir)) === HOST_PI_PACKAGE) {
215
+ bases.push(join(packageDir, "package.json"));
216
+ }
217
+ // Extension-local resolution: the development checkout and installs that
218
+ // provide pi-tui beside the extension or beside pi-subagents.
219
+ bases.push(options.moduleUrl ?? import.meta.url);
220
+
221
+ const seen = new Set<string>();
222
+ for (const base of bases) {
223
+ if (seen.has(base)) continue;
224
+ seen.add(base);
225
+ const resolved = resolveFrom(base, HOST_PI_TUI_PACKAGE);
226
+ if (!resolved) continue;
227
+ const target = await realpath(resolved).catch(() => resolved);
228
+ return { [HOST_PI_TUI_PACKAGE]: target };
229
+ }
230
+ return {};
231
+ }
232
+
145
233
  /** Load the public ceiling API and the discovery/config internals.
146
234
  * This intentionally fails closed on package version, export, or layout drift.
147
235
  */
@@ -163,6 +251,7 @@ export async function loadPiSubagentsNativeRuntime(
163
251
  const jiti = createJiti(import.meta.url, {
164
252
  interopDefault: false,
165
253
  fsCache: false,
254
+ alias: await resolvePiSubagentsHostAliases(),
166
255
  });
167
256
  const internal = (relative: string): string =>
168
257
  pathToFileURL(join(root, piSubagentsInternalModulePath(relative, extension))).href;
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).