@erichll/pi-sandbox 0.21.1 → 0.22.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,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1 - 2026-10-01
4
+
5
+ - Fix sandboxed Bash on Bun-compiled Pi: launching the Sandbox Runtime broker
6
+ could instead start another unsandboxed Pi session with the broker path as
7
+ its prompt, leaving the requested command unexecuted
8
+ ([#7](https://github.com/erichll/pi-packages/issues/7)). The broker now runs
9
+ through a separate Node executable and communicates over JSON IPC.
10
+ - **Bun-compiled Pi requires Node.js >=22.19.0 on PATH**, with its directory
11
+ listed as an absolute path. If Node cannot be found, commands fail explicitly
12
+ before execution. Aliases back to the Pi executable are rejected.
13
+ - Fix built-in subagent startup for standalone Pi and resolve the host CLI
14
+ symlink for Node-based Pi. Derive filesystem permissions from the actual
15
+ runtime so a binary such as `/tmp/pi-bun` does not grant root reads or deny
16
+ writes to the entire host temp directory.
17
+ - Fix protected `pi-subagents` loading on Bun when `pi-tui` and `typebox` are
18
+ provided by Pi. Reuse the host modules in the internal loader so compiled
19
+ Pi does not need separate copies beside the extension.
20
+ - Clean up command temp directories when broker startup fails. Add isolated
21
+ Node, plain Bun, and compiled Bun regression tests for broker startup,
22
+ capability-ceiling registration, and rejection of enabled schedules.
23
+ - Require `@erichll/pi-auto-review` `^0.22.1` to include the Bun SQLite audit fix.
24
+
25
+ ## 0.22.0 - 2026-09-30
26
+
27
+ - Raise the `@earendil-works/pi-coding-agent` peer floor to `^0.99.1` and the
28
+ development baseline to `^0.99.1`. Validated against Pi 0.99.1 with
29
+ `tsc --noEmit` and the test suite (92 pass, 1 macOS-only skip); the shipped
30
+ `src/` uses no API that changed between 0.87 and 0.99.
31
+ - Adapt two test fixtures to the 0.99 extension surface: `ToolInfo` now requires
32
+ `exposure`, and tool handlers receive `ExtensionToolContext` (`tools`,
33
+ `executeTool`). The shipped extension uses neither field, so runtime behavior
34
+ is unchanged.
35
+ - Raise the `pi-subagents` development dependency to `^0.72.1` and revalidate
36
+ the native-background loader against the published 0.72.1 package:
37
+ `discoverAgents` and `resolveAgentName` keep their signatures,
38
+ `src/agents/agents` only gains an optional `options.globalNpmRoot` argument,
39
+ `typebox` moved from a hard dependency to an optional host peer, and the
40
+ `./capability-ceiling` export still resolves to `.js`.
41
+ - Depend on `@erichll/pi-auto-review` `^0.22.0`.
42
+ - Document the three-way `subagents.provider` choice (`builtin` / `off` /
43
+ `pi-subagents`) with copy-ready example configs, including the duplicate
44
+ `subagent` tool that makes `builtin` unavailable whenever the pi-subagents
45
+ extension is loaded, and the scheduled-runs-disabled pi-subagents config that
46
+ protected mode requires.
47
+ - Bump the development `typescript` baseline to `^7.0.2`.
48
+
3
49
  ## 0.21.1 - 2026-09-26
4
50
 
5
51
  - Alias `@earendil-works/pi-tui` for the `jiti` instance that loads 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):
@@ -92,12 +98,13 @@ Supported modes:
92
98
  ### `pi-subagents` native-background tool boundary
93
99
 
94
100
  This provider supports the `pi-subagents >=0.66.0` line (validated through
95
- 0.71.0) and fails closed if its public ceiling API, module layout, or internal
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
@@ -296,7 +303,7 @@ directory. It never reads or updates production Pi configuration. Model-backed
296
303
  acceptance requires `PI_SUBAGENTS_GATE_MODEL` and an already-exported matching
297
304
  credential; missing prerequisites are reported as `SKIP`. Protected mode with
298
305
  pi-subagents 0.68.0 or newer needs Pi 0.86.0 or newer. The development
299
- baseline is validated against Pi 0.87.1.
306
+ baseline is validated against Pi 0.99.1.
300
307
 
301
308
  The test suite covers real Linux Sandbox Runtime enforcement when its native
302
309
  dependencies are installed, plus deterministic broker, network approval,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.21.1",
3
+ "version": "0.22.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,11 +51,11 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sandbox-runtime": "^0.0.77",
54
- "@erichll/pi-auto-review": "^0.21.0",
54
+ "@erichll/pi-auto-review": "^0.22.1",
55
55
  "jiti": "2.7.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@earendil-works/pi-coding-agent": "^0.87.1",
58
+ "@earendil-works/pi-coding-agent": "^0.99.1",
59
59
  "pi-subagents": ">=0.66.0",
60
60
  "typebox": ">=1.0.0"
61
61
  },
@@ -65,12 +65,12 @@
65
65
  }
66
66
  },
67
67
  "devDependencies": {
68
- "@earendil-works/pi-ai": "^0.87.1",
69
- "@earendil-works/pi-coding-agent": "^0.87.1",
70
- "@earendil-works/pi-server": "^0.87.1",
68
+ "@earendil-works/pi-ai": "^0.99.1",
69
+ "@earendil-works/pi-coding-agent": "^0.99.1",
70
+ "@earendil-works/pi-server": "^0.99.1",
71
71
  "@types/node": "^26.6.2",
72
- "pi-subagents": "^0.71.0",
72
+ "pi-subagents": "^0.72.1",
73
73
  "typebox": "^1.3.34",
74
- "typescript": "^6.0.3"
74
+ "typescript": "^7.0.2"
75
75
  }
76
76
  }
@@ -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
@@ -16,10 +18,16 @@ import { createJiti } from "jiti";
16
18
  * 2026-09-23 against the published 0.71.0 package: the ceiling implementation
17
19
  * and the config loader are byte-identical to 0.70.0, `discoverAgents` and
18
20
  * `resolveAgentName` keep their signatures, and the `./capability-ceiling`
19
- * export still resolves to `.js`). Protected mode needs Pi 0.86.0 or newer
21
+ * export still resolves to `.js`), and revalidated 2026-09-27 against the
22
+ * published 0.72.1 package: the ceiling implementation and the config loader
23
+ * are still byte-identical to 0.70.0, `discoverAgents` and `resolveAgentName`
24
+ * keep their signatures, `src/agents/agents` only gains an optional
25
+ * `options.globalNpmRoot` argument, `typebox` moved from a hard dependency to
26
+ * an optional host peer, and the `./capability-ceiling` export still resolves
27
+ * to `.js`). Protected mode needs Pi 0.86.0 or newer
20
28
  * (the pi-subagents 0.68.0+ floor): 0.85.0 does not ship
21
29
  * `@earendil-works/pi-server`, so background children fail to launch there.
22
- * This package's own peer floor is Pi 0.87.1 (validated 2026-09-23).
30
+ * This package's own peer floor is Pi 0.99.1 (validated 2026-09-30).
23
31
  */
24
32
  export const PI_SUBAGENTS_COMPAT_RANGE = ">=0.66.0";
25
33
  export const NATIVE_CHILD_TOOLS = ["bash", "read", "grep", "find", "ls"] as const;
@@ -248,10 +256,23 @@ export async function loadPiSubagentsNativeRuntime(
248
256
  }
249
257
  const extension = resolvePiSubagentsModuleExtension(packageJson.exports);
250
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() };
251
272
  const jiti = createJiti(import.meta.url, {
252
273
  interopDefault: false,
253
274
  fsCache: false,
254
- alias: await resolvePiSubagentsHostAliases(),
275
+ ...resolutionOptions,
255
276
  });
256
277
  const internal = (relative: string): string =>
257
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")),
@@ -231,6 +231,7 @@ export function createDefaultPolicy(
231
231
  ): SandboxPolicy {
232
232
  const workspace = resolve(cwd);
233
233
  const home = resolve(homedir());
234
+ const runtimePaths = runtimeFilesystemPaths();
234
235
  const denyRead = home === parse(home).root ? [] : [home];
235
236
  const packageRelative = relative(workspace, PACKAGE_ROOT);
236
237
  const packageIsInWorkspace =
@@ -241,7 +242,7 @@ export function createDefaultPolicy(
241
242
  denyRead,
242
243
  allowRead: [
243
244
  workspace,
244
- NODE_INSTALL_ROOT,
245
+ ...runtimePaths.allowRead,
245
246
  SANDBOX_RUNTIME_ROOT,
246
247
  join(home, ".gitconfig"),
247
248
  join(home, ".config", "git", "config"),
@@ -265,7 +266,7 @@ export function createDefaultPolicy(
265
266
  "config.json",
266
267
  ),
267
268
  ...(packageIsInWorkspace ? [] : [PACKAGE_ROOT]),
268
- dirname(process.execPath),
269
+ ...runtimePaths.denyWrite,
269
270
  ...createWorkspaceSecretDenyWritePaths(workspace),
270
271
  ],
271
272
  },
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
  }