@erichll/pi-sandbox 0.22.0 → 0.23.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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0 - 2026-10-02
4
+
5
+ - Add opt-in `network.strictAllowlist` (default `false`) to deny unmatched
6
+ destinations without model or human approval. Forward the setting to Sandbox
7
+ Runtime for Bash and built-in workers, including persistent follow-up and
8
+ nested handoff.
9
+ - Merge global/project strict mode with logical OR while retaining domain-list
10
+ unions. Both files remain trusted policy sources: projects can add endpoints
11
+ but cannot disable globally required strict enforcement.
12
+ - Reject strict mode combined with Host-IPC `ask` after configuration merging
13
+ and embedding overrides. Keep non-strict approval and host forwarding behavior.
14
+ - Require `@erichll/pi-auto-review` `^0.23.0` for current-session review models
15
+ and full-command execution-context validation.
16
+ - Cover real Linux broker network enforcement, persistent worker lifecycle and
17
+ startup failures with synthetic endpoints; isolate configuration tests from
18
+ the developer's working directory.
19
+
20
+ ## 0.22.1 - 2026-10-01
21
+
22
+ - Fix sandboxed Bash on Bun-compiled Pi: launching the Sandbox Runtime broker
23
+ could instead start another unsandboxed Pi session with the broker path as
24
+ its prompt, leaving the requested command unexecuted
25
+ ([#7](https://github.com/erichll/pi-packages/issues/7)). The broker now runs
26
+ through a separate Node executable and communicates over JSON IPC.
27
+ - **Bun-compiled Pi requires Node.js >=22.19.0 on PATH**, with its directory
28
+ listed as an absolute path. If Node cannot be found, commands fail explicitly
29
+ before execution. Aliases back to the Pi executable are rejected.
30
+ - Fix built-in subagent startup for standalone Pi and resolve the host CLI
31
+ symlink for Node-based Pi. Derive filesystem permissions from the actual
32
+ runtime so a binary such as `/tmp/pi-bun` does not grant root reads or deny
33
+ writes to the entire host temp directory.
34
+ - Fix protected `pi-subagents` loading on Bun when `pi-tui` and `typebox` are
35
+ provided by Pi. Reuse the host modules in the internal loader so compiled
36
+ Pi does not need separate copies beside the extension.
37
+ - Clean up command temp directories when broker startup fails. Add isolated
38
+ Node, plain Bun, and compiled Bun regression tests for broker startup,
39
+ capability-ceiling registration, and rejection of enabled schedules.
40
+ - Require `@erichll/pi-auto-review` `^0.22.1` to include the Bun SQLite audit fix.
41
+
3
42
  ## 0.22.0 - 2026-09-30
4
43
 
5
44
  - Raise the `@earendil-works/pi-coding-agent` peer floor to `^0.99.1` and the
package/README.md CHANGED
@@ -69,6 +69,12 @@ configure an AppArmor profile for bubblewrap or disable
69
69
 
70
70
  macOS uses built-in Seatbelt support but still requires `ripgrep`.
71
71
 
72
+ For Bun-compiled standalone Pi, install **Node.js >=22.19.0** and include its
73
+ `bin` directory as an absolute entry on `PATH`. The sandbox broker runs in that
74
+ separate Node process; built-in subagents launch the Pi executable directly.
75
+ If no usable `node` is found, the command fails before execution. Node-based Pi
76
+ and ordinary `bun cli.js` launches use their existing JavaScript runtime.
77
+
72
78
  ## Subagent provider
73
79
 
74
80
  The provider is selected from the configuration (`~/.pi/agent/extensions/pi-sandbox/config.json` globally or `.pi/extensions/pi-sandbox/config.json` project-locally):
@@ -94,10 +100,11 @@ Supported modes:
94
100
  This provider supports the `pi-subagents >=0.66.0` line (validated through
95
101
  0.72.1) and fails closed if its public ceiling API, module layout, or internal
96
102
  discovery layout drifts. The peer dependency is a floor with no upper pin; the
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
+ loader's explicit export and layout checks are the real gate. On Node, the
104
+ loader aliases the `@earendil-works/pi-tui` host peer to the running Pi package.
105
+ On Bun, including compiled Pi, it passes Pi's `pi-tui` and `typebox` module
106
+ objects into its internal loader, so those peers can be embedded in Pi instead
107
+ of installed beside `pi-subagents`.
101
108
  Configure both the protection mode and a non-empty canonical whitelist:
102
109
 
103
110
  ```json
@@ -150,6 +157,7 @@ Domain authorization can be configured globally at
150
157
  ```json
151
158
  {
152
159
  "network": {
160
+ "strictAllowlist": false,
153
161
  "allowedDomains": [
154
162
  "github.com",
155
163
  "*.github.com",
@@ -172,14 +180,50 @@ The precedence is deterministic:
172
180
 
173
181
  1. A matching `deniedDomains` entry rejects the connection.
174
182
  2. Otherwise, a matching `allowedDomains` entry permits it without review.
175
- 3. Otherwise, the canonical public hostname and port enter the existing
183
+ 3. Otherwise, if `strictAllowlist` is `true`, the connection is denied without
184
+ invoking the Runtime ask callback, model review, or human approval.
185
+ 4. Otherwise, the canonical public hostname and port enter the existing
176
186
  one-shot auto-review or human approval flow.
177
187
 
178
- Both arrays default to empty. That preserves the prior behavior: no persistent
179
- network authorization, with every eligible public connection reviewed once.
180
- The same trusted arrays govern main-agent Bash, built-in subagents, and the
188
+ Both arrays default to empty and `strictAllowlist` defaults to `false`, preserving
189
+ the existing per-connection approval behavior. The setting accepts only a boolean.
190
+ With strict mode and an empty allowlist, all proxied destinations are denied.
191
+ `deniedDomains: ["*"]` also blocks allowed entries because deny rules take priority.
192
+ The same network policy governs main-agent Bash, user Bash, built-in subagents, and the
181
193
  sandboxed Bash tool loaded by protected native `pi-subagents` children.
182
194
 
195
+ For a worker restricted to explicitly approved inference endpoints:
196
+
197
+ ```json
198
+ {
199
+ "subagents": { "provider": "builtin" },
200
+ "network": {
201
+ "strictAllowlist": true,
202
+ "allowedDomains": ["inference.example.com:443"],
203
+ "deniedDomains": []
204
+ },
205
+ "hostIPC": { "mode": "off" }
206
+ }
207
+ ```
208
+
209
+ Replace the example hostname with your approved endpoint. Global and project
210
+ `strictAllowlist` values merge with logical OR: a project cannot disable strict
211
+ mode required globally. Domain lists still form unions, so a project **can add
212
+ allowed destinations**. Both files are trusted policy inputs; the global allowlist
213
+ is not an administrative upper bound on project permissions.
214
+
215
+ Strict mode cannot be combined with `hostIPC.mode: "ask"`, since host execution
216
+ would escape the network sandbox. The effective configuration is rejected before
217
+ tools or workers are created; set Host-IPC mode to `off`. This is checked after
218
+ global/project merging, and again after an embedding's Host-IPC override.
219
+
220
+ Configuration is loaded when the extension is registered. Existing persistent
221
+ workers keep their policy through follow-up, and handoff workers receive the same
222
+ configured policy. Reload the extension and create new workers to apply file
223
+ changes; there is no configuration hot reload. Broker startup failure never retries
224
+ the worker outside the sandbox, and unavailable approval services cannot relax a
225
+ strict network denial.
226
+
183
227
  Domain allowlists are not a complete data-loss-prevention boundary. A
184
228
  multi-tenant or user-uploadable destination such as `github.com` can itself be
185
229
  an exfiltration channel; grant only the narrow domains and ports you intend.
@@ -222,7 +266,7 @@ approval warns that the first attempt may already have had partial side
222
266
  effects. Successful, timed-out, or aborted commands are never retried.
223
267
 
224
268
  Host forwarding is intentionally unavailable inside built-in subagents in
225
- this version.
269
+ this version. It is also incompatible with `network.strictAllowlist: true`.
226
270
 
227
271
  ## Additional trusted read paths
228
272
 
@@ -262,8 +306,12 @@ In addition to user-level configuration at `~/.pi/agent/extensions/pi-sandbox/co
262
306
  ```
263
307
 
264
308
  When present, project configuration is merged with global configuration:
309
+
265
310
  - Array policies (`additionalAllowRead`, `allowedDomains`, `deniedDomains`, `preflightCommandPrefixes`) form a deduplicated union.
266
311
  - Scalar settings (`provider`, `mode`, `retryOnUnixSocketError`) allow project-level overrides.
312
+ - `network.strictAllowlist` uses logical OR: `true` in either file enables it.
313
+ Both configuration files are trusted; project allowlist additions remain effective.
314
+ - The merged configuration must not enable both strict allowlist and Host-IPC `ask`.
267
315
 
268
316
  **Read-only Security Guarantee**:
269
317
  Project-level configuration is explicitly protected in the sandbox policy (`denyWrite`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.22.0",
3
+ "version": "0.23.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.22.0",
54
+ "@erichll/pi-auto-review": "^0.23.0",
55
55
  "jiti": "2.7.0"
56
56
  },
57
57
  "peerDependencies": {
package/src/config.ts CHANGED
@@ -23,6 +23,8 @@ export type HostIPCConfig = {
23
23
  export type NetworkConfig = {
24
24
  readonly allowedDomains: readonly string[];
25
25
  readonly deniedDomains: readonly string[];
26
+ /** Deny unmatched destinations without review. Defaults to false. */
27
+ readonly strictAllowlist?: boolean;
26
28
  };
27
29
 
28
30
  export type PiSandboxConfig = {
@@ -34,7 +36,7 @@ export type PiSandboxConfig = {
34
36
  filesystem: {
35
37
  additionalAllowRead: readonly string[];
36
38
  };
37
- network: NetworkConfig;
39
+ network: NetworkConfig & { readonly strictAllowlist: boolean };
38
40
  hostIPC: HostIPCConfig;
39
41
  };
40
42
 
@@ -57,6 +59,7 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
57
59
  network: Object.freeze({
58
60
  allowedDomains: Object.freeze([]),
59
61
  deniedDomains: Object.freeze([]),
62
+ strictAllowlist: false,
60
63
  }),
61
64
  hostIPC: Object.freeze({
62
65
  mode: "off",
@@ -263,9 +266,15 @@ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig
263
266
  }
264
267
  rejectUnknownKeys(
265
268
  value.network,
266
- ["allowedDomains", "deniedDomains"],
269
+ ["allowedDomains", "deniedDomains", "strictAllowlist"],
267
270
  "network",
268
271
  );
272
+ const strictAllowlist = value.network.strictAllowlist;
273
+ if (strictAllowlist !== undefined && typeof strictAllowlist !== "boolean") {
274
+ throw new Error(
275
+ "invalid pi-sandbox configuration: network.strictAllowlist must be a boolean",
276
+ );
277
+ }
269
278
  const normalizeDomainList = (
270
279
  key: "allowedDomains" | "deniedDomains",
271
280
  ): string[] | undefined => {
@@ -310,6 +319,7 @@ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig
310
319
  parsedNetwork = {
311
320
  ...(allowed !== undefined ? { allowedDomains: allowed } : {}),
312
321
  ...(denied !== undefined ? { deniedDomains: denied } : {}),
322
+ ...(strictAllowlist !== undefined ? { strictAllowlist } : {}),
313
323
  };
314
324
  }
315
325
 
@@ -376,7 +386,24 @@ export function parsePiSandboxConfigRaw(value: unknown): PartialPiSandboxConfig
376
386
  }
377
387
 
378
388
  export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
379
- const raw = parsePiSandboxConfigRaw(value);
389
+ const config = resolvePiSandboxConfig(parsePiSandboxConfigRaw(value));
390
+ validatePiSandboxConfig(config);
391
+ return config;
392
+ }
393
+
394
+ /** Validate the effective policy, after merging configuration layers. */
395
+ export function validatePiSandboxConfig(
396
+ config: { network: NetworkConfig; hostIPC: HostIPCConfig },
397
+ ): void {
398
+ if (config.network.strictAllowlist && config.hostIPC.mode === "ask") {
399
+ throw new Error(
400
+ 'invalid pi-sandbox configuration: network.strictAllowlist=true cannot be combined with hostIPC.mode="ask"; set hostIPC.mode="off" to keep execution sandboxed',
401
+ );
402
+ }
403
+ }
404
+
405
+ /** Apply defaults without validating conflicts in an intermediate layer. */
406
+ function resolvePiSandboxConfig(raw: PartialPiSandboxConfig): PiSandboxConfig {
380
407
  const provider =
381
408
  raw.subagents?.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
382
409
  const isPiSubagents = provider === "pi-subagents";
@@ -400,6 +427,7 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
400
427
  ],
401
428
  },
402
429
  network: {
430
+ strictAllowlist: raw.network?.strictAllowlist ?? false,
403
431
  allowedDomains: [
404
432
  ...(raw.network?.allowedDomains ??
405
433
  DEFAULT_PI_SANDBOX_CONFIG.network.allowedDomains),
@@ -435,6 +463,7 @@ function defaultPiSandboxConfig(): PiSandboxConfig {
435
463
  network: {
436
464
  allowedDomains: [...DEFAULT_PI_SANDBOX_CONFIG.network.allowedDomains],
437
465
  deniedDomains: [...DEFAULT_PI_SANDBOX_CONFIG.network.deniedDomains],
466
+ strictAllowlist: DEFAULT_PI_SANDBOX_CONFIG.network.strictAllowlist,
438
467
  },
439
468
  hostIPC: {
440
469
  mode: DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
@@ -507,7 +536,7 @@ export function mergePiSandboxConfigs(
507
536
  allowedNativeAgents = [...new Set([...trustedAgents, ...projectAgents])];
508
537
  }
509
538
 
510
- return {
539
+ const merged: PiSandboxConfig = {
511
540
  subagents: {
512
541
  provider,
513
542
  ...(isPiSubagents
@@ -526,6 +555,10 @@ export function mergePiSandboxConfigs(
526
555
  ],
527
556
  },
528
557
  network: {
558
+ // Both configuration files are trusted policy inputs. Projects may add
559
+ // destinations, but cannot turn off strict enforcement required globally.
560
+ strictAllowlist:
561
+ trusted.network.strictAllowlist || (project.network?.strictAllowlist ?? false),
529
562
  allowedDomains: [
530
563
  ...new Set([
531
564
  ...trusted.network.allowedDomains,
@@ -552,6 +585,8 @@ export function mergePiSandboxConfigs(
552
585
  trusted.hostIPC.retryOnUnixSocketError,
553
586
  },
554
587
  };
588
+ validatePiSandboxConfig(merged);
589
+ return merged;
555
590
  }
556
591
 
557
592
  export function loadPiSandboxConfig(
@@ -572,9 +607,8 @@ export function loadPiSandboxConfig(
572
607
  const globalPath = getPiSandboxConfigPath(options.home);
573
608
  let globalConfig: PiSandboxConfig | undefined;
574
609
  try {
575
- globalConfig = parsePiSandboxConfigFile(
576
- globalPath,
577
- readPiSandboxConfigFile(globalPath),
610
+ globalConfig = resolvePiSandboxConfig(
611
+ parsePiSandboxConfigFileRaw(globalPath, readPiSandboxConfigFile(globalPath)),
578
612
  );
579
613
  } catch (error) {
580
614
  if (!isNotFoundError(error)) {
@@ -599,5 +633,6 @@ export function loadPiSandboxConfig(
599
633
  if (projectRaw) {
600
634
  return mergePiSandboxConfigs(base, projectRaw);
601
635
  }
636
+ validatePiSandboxConfig(base);
602
637
  return base;
603
638
  }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "./approval.ts";
18
18
  import {
19
19
  loadPiSandboxConfig,
20
+ validatePiSandboxConfig,
20
21
  type HostIPCConfig,
21
22
  type NetworkConfig,
22
23
  type SubagentProvider,
@@ -220,6 +221,8 @@ async function performRegistration(
220
221
  let currentTurn = 0;
221
222
  const cwd = process.cwd();
222
223
  const config = loadPiSandboxConfig({ cwd });
224
+ const hostIPC = options.hostIPC ?? config.hostIPC;
225
+ validatePiSandboxConfig({ network: config.network, hostIPC });
223
226
  const subagentProvider =
224
227
  options.subagentProvider ?? config.subagents.provider;
225
228
  const isNativeSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
@@ -237,7 +240,6 @@ async function performRegistration(
237
240
  : undefined;
238
241
  const additionalAllowRead = config.filesystem.additionalAllowRead;
239
242
  const network = config.network;
240
- const hostIPC = options.hostIPC ?? config.hostIPC;
241
243
  const subagents =
242
244
  subagentProvider === "builtin"
243
245
  ? (options.subagentManager ??
@@ -625,10 +627,13 @@ async function performRegistration(
625
627
  return;
626
628
  }
627
629
  if (ctx.hasUI) {
630
+ const networkMode = network.strictAllowlist
631
+ ? "strict network allowlist (unmatched destinations denied without review)"
632
+ : "per-connection network review";
628
633
  ctx.ui.notify(
629
634
  process.platform === "darwin"
630
- ? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem/domain policy and per-connection network review`
631
- : `${EXTENSION_NAME} enabled: Linux Sandbox Runtime with static filesystem/domain policy and per-connection network review`,
635
+ ? `${EXTENSION_NAME} enabled: macOS Sandbox Runtime with static filesystem/domain policy and ${networkMode}`
636
+ : `${EXTENSION_NAME} enabled: Linux Sandbox Runtime with static filesystem/domain policy and ${networkMode}`,
632
637
  "info",
633
638
  );
634
639
  }
@@ -3,6 +3,8 @@ import { createRequire } from "node:module";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createJiti } from "jiti";
6
+ import * as hostPiTui from "@earendil-works/pi-tui";
7
+ import * as hostTypebox from "typebox";
6
8
 
7
9
  /**
8
10
  * Compatible pi-subagents line: 0.66.0 and above (no upper pin). Any new minor
@@ -254,10 +256,23 @@ export async function loadPiSubagentsNativeRuntime(
254
256
  }
255
257
  const extension = resolvePiSubagentsModuleExtension(packageJson.exports);
256
258
 
259
+ // Pi's outer extension loader supplies these host modules, including embedded
260
+ // copies in compiled Bun builds. Static imports above keep host resolution in
261
+ // that outer loader even under plain `bun cli.js`. Filesystem aliases cannot
262
+ // locate modules that only exist inside Pi.
263
+ const resolutionOptions = process.versions.bun
264
+ ? {
265
+ tryNative: false,
266
+ virtualModules: {
267
+ [HOST_PI_TUI_PACKAGE]: hostPiTui,
268
+ typebox: hostTypebox,
269
+ },
270
+ }
271
+ : { alias: await resolvePiSubagentsHostAliases() };
257
272
  const jiti = createJiti(import.meta.url, {
258
273
  interopDefault: false,
259
274
  fsCache: false,
260
- alias: await resolvePiSubagentsHostAliases(),
275
+ ...resolutionOptions,
261
276
  });
262
277
  const internal = (relative: string): string =>
263
278
  pathToFileURL(join(root, piSubagentsInternalModulePath(relative, extension))).href;
package/src/policy.ts CHANGED
@@ -10,13 +10,13 @@ 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 { runtimeFilesystemPaths } from "./runtime.ts";
13
14
  import {
14
15
  PROJECT_PI_SANDBOX_CONFIG_PATH,
15
16
  type NetworkConfig,
16
17
  } from "./config.ts";
17
18
 
18
19
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
19
- const NODE_INSTALL_ROOT = dirname(dirname(process.execPath));
20
20
  const SANDBOX_RUNTIME_ROOT = dirname(
21
21
  dirname(
22
22
  fileURLToPath(import.meta.resolve("@anthropic-ai/sandbox-runtime")),
@@ -115,6 +115,7 @@ export type SandboxPolicy = {
115
115
  network: {
116
116
  allowedDomains: string[];
117
117
  deniedDomains: string[];
118
+ strictAllowlist?: boolean;
118
119
  allowLocalBinding: boolean;
119
120
  allowAllUnixSockets: boolean;
120
121
  allowUnixSockets: string[];
@@ -231,6 +232,7 @@ export function createDefaultPolicy(
231
232
  ): SandboxPolicy {
232
233
  const workspace = resolve(cwd);
233
234
  const home = resolve(homedir());
235
+ const runtimePaths = runtimeFilesystemPaths();
234
236
  const denyRead = home === parse(home).root ? [] : [home];
235
237
  const packageRelative = relative(workspace, PACKAGE_ROOT);
236
238
  const packageIsInWorkspace =
@@ -241,7 +243,7 @@ export function createDefaultPolicy(
241
243
  denyRead,
242
244
  allowRead: [
243
245
  workspace,
244
- NODE_INSTALL_ROOT,
246
+ ...runtimePaths.allowRead,
245
247
  SANDBOX_RUNTIME_ROOT,
246
248
  join(home, ".gitconfig"),
247
249
  join(home, ".config", "git", "config"),
@@ -265,13 +267,14 @@ export function createDefaultPolicy(
265
267
  "config.json",
266
268
  ),
267
269
  ...(packageIsInWorkspace ? [] : [PACKAGE_ROOT]),
268
- dirname(process.execPath),
270
+ ...runtimePaths.denyWrite,
269
271
  ...createWorkspaceSecretDenyWritePaths(workspace),
270
272
  ],
271
273
  },
272
274
  network: {
273
275
  allowedDomains: [...(options.network?.allowedDomains ?? [])],
274
276
  deniedDomains: [...(options.network?.deniedDomains ?? [])],
277
+ strictAllowlist: options.network?.strictAllowlist ?? false,
275
278
  allowLocalBinding: false,
276
279
  allowAllUnixSockets: false,
277
280
  allowUnixSockets: [],
@@ -295,6 +298,7 @@ export function toSandboxRuntimeConfig(
295
298
  network: {
296
299
  allowedDomains: [...policy.network.allowedDomains],
297
300
  deniedDomains: [...policy.network.deniedDomains],
301
+ strictAllowlist: policy.network.strictAllowlist ?? false,
298
302
  allowLocalBinding: policy.network.allowLocalBinding,
299
303
  allowAllUnixSockets: policy.network.allowAllUnixSockets,
300
304
  allowUnixSockets: [...policy.network.allowUnixSockets],
package/src/runner.ts CHANGED
@@ -11,6 +11,7 @@ import type { Writable } from "node:stream";
11
11
  import { getShellConfig } from "@earendil-works/pi-coding-agent";
12
12
  import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
13
13
  import type { NetworkEndpoint } from "./approval.ts";
14
+ import { resolveBrokerExecPath } from "./runtime.ts";
14
15
  import {
15
16
  createDefaultPolicy,
16
17
  toSandboxRuntimeConfig,
@@ -173,7 +174,10 @@ function spawnBroker(
173
174
  cwd: options.cwd,
174
175
  detached: true,
175
176
  env,
177
+ execPath: resolveBrokerExecPath(env),
176
178
  execArgv: broker.execArgv ?? [],
179
+ // Bun's default IPC serialization is incompatible with a Node child.
180
+ serialization: "json",
177
181
  stdio: ["pipe", "pipe", "pipe", "ipc"],
178
182
  };
179
183
  return fork(broker.modulePath, [], forkOptions);
@@ -206,12 +210,14 @@ export async function runSandboxedCommand(
206
210
  [SANDBOX_RUNTIME_TMPDIR_ENV]: tempDir,
207
211
  };
208
212
  }
209
- const broker = spawnBroker(options, brokerEnv);
213
+ let broker: ChildProcess | undefined;
210
214
  let timeoutHandle: NodeJS.Timeout | undefined;
211
215
  let timedOut = false;
212
216
  let onAbort: (() => void) | undefined;
213
217
 
214
218
  try {
219
+ broker = spawnBroker(options, brokerEnv);
220
+ const child = broker;
215
221
  broker.stdout?.on("data", (data: Buffer) => {
216
222
  options.onData(data);
217
223
  options.onStdout?.(data);
@@ -222,7 +228,7 @@ export async function runSandboxedCommand(
222
228
  });
223
229
  broker.on("message", (message: unknown) => {
224
230
  if (!isNetworkRequest(message)) return;
225
- void answerNetworkRequest(broker, message, options);
231
+ void answerNetworkRequest(child, message, options);
226
232
  });
227
233
 
228
234
  const init: BrokerInitMessage = {
@@ -242,18 +248,18 @@ export async function runSandboxedCommand(
242
248
  broker.stdin?.end();
243
249
  }
244
250
 
245
- onAbort = () => killProcessTree(broker);
251
+ onAbort = () => killProcessTree(child);
246
252
  options.signal?.addEventListener("abort", onAbort, { once: true });
247
253
  if (options.timeout !== undefined && options.timeout > 0) {
248
254
  timeoutHandle = setTimeout(() => {
249
255
  timedOut = true;
250
- killProcessTree(broker);
256
+ killProcessTree(child);
251
257
  }, options.timeout * 1_000);
252
258
  }
253
259
 
254
260
  const exitCode = await new Promise<number | null>((resolve, reject) => {
255
- broker.once("error", reject);
256
- broker.once("exit", resolve);
261
+ child.once("error", reject);
262
+ child.once("exit", resolve);
257
263
  });
258
264
  if (options.signal?.aborted) throw new Error("aborted");
259
265
  if (timedOut) throw new Error(`timeout:${options.timeout}`);
@@ -261,7 +267,7 @@ export async function runSandboxedCommand(
261
267
  } finally {
262
268
  if (timeoutHandle) clearTimeout(timeoutHandle);
263
269
  if (onAbort) options.signal?.removeEventListener("abort", onAbort);
264
- if (broker.exitCode === null && broker.signalCode === null) {
270
+ if (broker && broker.exitCode === null && broker.signalCode === null) {
265
271
  killProcessTree(broker);
266
272
  }
267
273
  if (tempDir) {
package/src/runtime.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, delimiter, dirname, isAbsolute, join, parse } from "node:path";
4
+
5
+ export type RuntimeProcess = {
6
+ execPath: string;
7
+ argv: readonly string[];
8
+ versions: Readonly<Record<string, string | undefined>>;
9
+ features?: { sea?: boolean; inspector?: boolean };
10
+ };
11
+
12
+ /** The extension's own import.meta.url is on disk even inside compiled Pi. */
13
+ export function isStandaloneExecutable(host: RuntimeProcess = process): boolean {
14
+ return host.features?.sea === true || (
15
+ !!host.versions.bun &&
16
+ /(?:\$bunfs|~BUN|%7EBUN)/i.test(host.argv[1] ?? "")
17
+ );
18
+ }
19
+
20
+ /** fork() must execute a JS runtime, never a compiled Pi application image. */
21
+ export function resolveBrokerExecPath(
22
+ env: NodeJS.ProcessEnv = process.env,
23
+ host: RuntimeProcess = process,
24
+ ): string {
25
+ if (!isStandaloneExecutable(host)) return host.execPath;
26
+
27
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
28
+ // Do not discover executable code in the command's working directory.
29
+ if (!isAbsolute(directory)) continue;
30
+ try {
31
+ const candidate = realpathSync(join(directory, "node"));
32
+ accessSync(candidate, constants.X_OK);
33
+ const candidateStat = statSync(candidate);
34
+ const hostStat = statSync(host.execPath);
35
+ if (!candidateStat.isFile()) continue;
36
+ // Also reject symlinks/hard links from `node` back to the Pi binary.
37
+ if (candidateStat.dev === hostStat.dev && candidateStat.ino === hostStat.ino) continue;
38
+ return candidate;
39
+ } catch {
40
+ // Try the next absolute PATH entry.
41
+ }
42
+ }
43
+ throw new Error(
44
+ "pi-sandbox: standalone Pi requires Node.js >=22.19.0 on PATH to run the Sandbox Runtime broker; no usable node executable was found",
45
+ );
46
+ }
47
+
48
+ function runtimeReadPath(execPath: string): string {
49
+ const bin = dirname(execPath);
50
+ const root = dirname(bin);
51
+ // Node installations under nvm/Nix need their libraries as well as bin/node.
52
+ // Arbitrarily placed executables must not grant a whole home directory or /.
53
+ return basename(bin) === "bin" && root !== parse(root).root && root !== homedir()
54
+ ? root
55
+ : execPath;
56
+ }
57
+
58
+ function runtimeWritePath(execPath: string): string {
59
+ const directory = dirname(execPath);
60
+ return basename(directory) === "bin" ? directory : execPath;
61
+ }
62
+
63
+ export function runtimeFilesystemPaths(
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ host: RuntimeProcess = process,
66
+ ): { allowRead: string[]; denyWrite: string[] } {
67
+ const brokerExecPath = resolveBrokerExecPath(env, host);
68
+ const standalone = isStandaloneExecutable(host);
69
+ return {
70
+ allowRead: [...new Set([
71
+ standalone ? host.execPath : runtimeReadPath(host.execPath),
72
+ runtimeReadPath(brokerExecPath),
73
+ ])],
74
+ denyWrite: [...new Set([
75
+ standalone ? host.execPath : runtimeWritePath(host.execPath),
76
+ runtimeWritePath(brokerExecPath),
77
+ ])],
78
+ };
79
+ }
package/src/subagent.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { Writable } from "node:stream";
5
6
  import type { NetworkEndpoint } from "./approval.ts";
7
+ import { isStandaloneExecutable, type RuntimeProcess } from "./runtime.ts";
6
8
  import {
7
9
  runSandboxedCommand,
8
10
  type SandboxCommandOptions,
@@ -201,19 +203,30 @@ export function validateSubagentModel(
201
203
  };
202
204
  }
203
205
 
204
- export function resolvePiInvocation(): ProcessInvocation {
205
- const currentEntry = process.argv[1];
206
+ export function resolvePiInvocation(host: RuntimeProcess = process): ProcessInvocation {
207
+ if (isStandaloneExecutable(host)) {
208
+ return { command: host.execPath, args: [] };
209
+ }
210
+ let currentEntry = host.argv[1];
211
+ if (currentEntry) {
212
+ try {
213
+ // npm's `pi` executable is usually a symlink to the host's cli.js.
214
+ currentEntry = realpathSync(currentEntry);
215
+ } catch {
216
+ // Preserve source/virtual entries that cannot be canonicalized.
217
+ }
218
+ }
206
219
  if (
207
220
  currentEntry &&
208
221
  /(?:^|[/\\])cli\.(?:js|mjs|cjs|ts)$/.test(currentEntry)
209
222
  ) {
210
- return { command: process.execPath, args: [currentEntry] };
223
+ return { command: host.execPath, args: [currentEntry] };
211
224
  }
212
225
  const packageEntry = fileURLToPath(
213
226
  import.meta.resolve("@earendil-works/pi-coding-agent"),
214
227
  );
215
228
  return {
216
- command: process.execPath,
229
+ command: host.execPath,
217
230
  args: [join(dirname(packageEntry), "cli.js")],
218
231
  };
219
232
  }