@erichll/pi-sandbox 0.3.0 → 0.4.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 ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 - 2026-07-29
4
+
5
+ - Add an optional, approval-gated Host-IPC Bash backend for commands that need
6
+ access to host Unix sockets.
7
+ - Support trusted preflight command prefixes without treating a prefix as
8
+ authorization; every complete command still requires an exact one-shot
9
+ approval.
10
+ - Support one non-recursive host retry after a narrowly detected Unix-socket
11
+ `Operation not permitted` sandbox failure.
12
+ - Preserve the first attempt's output, apply only the original timeout's
13
+ remaining duration, and never retry successful, timed-out, or aborted
14
+ commands.
15
+ - Refuse Host-IPC forwarding for built-in subagents.
16
+ - Add deterministic execution and approval coverage plus a Linux tmux socket
17
+ smoke test.
18
+
19
+ ## 0.3.1 - 2026-07-29
20
+
21
+ - Add trusted global filesystem read-path extensions while keeping default
22
+ write and configuration protections intact.
23
+
24
+ ## 0.3.0 - 2026-07-28
25
+
26
+ - Initial public release.
package/README.md CHANGED
@@ -20,6 +20,9 @@ also run complete process-backed subagent trees inside independent sandboxes.
20
20
  independent policy, proxy, approval, and cleanup lifecycles.
21
21
  - Project and global Pi security configuration, the installed package, Git
22
22
  hooks, and other Sandbox Runtime mandatory paths remain write-protected.
23
+ - Optional host IPC execution is disabled by default. When enabled, every
24
+ complete host command still requires a one-shot `pi-auto-review` or human
25
+ approval and runs outside the OS sandbox.
23
26
 
24
27
  Windows is not supported by this Pi adapter.
25
28
 
@@ -62,6 +65,63 @@ Supported modes:
62
65
  The configuration parser rejects malformed JSON, unknown fields, and unknown
63
66
  providers instead of silently weakening isolation.
64
67
 
68
+ ## Optional host IPC fallback
69
+
70
+ Some host services expose only a Unix socket that the OS sandbox cannot use.
71
+ The trusted global configuration can enable an approval-driven local Bash
72
+ backend for those commands:
73
+
74
+ ```json
75
+ {
76
+ "hostIPC": {
77
+ "mode": "ask",
78
+ "preflightCommandPrefixes": ["tmux", "/usr/bin/tmux"],
79
+ "retryOnUnixSocketError": true
80
+ }
81
+ }
82
+ ```
83
+
84
+ `mode` is `off` by default and only accepts `off` or `ask`. Prefixes are
85
+ trimmed, deduplicated, and must be non-empty. A prefix matches only at the
86
+ start of the trimmed command and must end at whitespace or the end of the
87
+ command. Prefixes select commands for review; they never authorize execution.
88
+ The reviewer and one-shot grant are bound to the full command and working
89
+ directory.
90
+
91
+ With `retryOnUnixSocketError` enabled, commands not selected for preflight
92
+ still run in Sandbox Runtime first. A single host retry is considered only
93
+ after a nonzero exit whose stderr identifies both `Operation not permitted`
94
+ and a socket/connect/IPC operation. The original output is retained, and the
95
+ approval warns that the first attempt may already have had partial side
96
+ effects. Successful, timed-out, or aborted commands are never retried.
97
+
98
+ Host forwarding is intentionally unavailable inside built-in subagents in
99
+ this version.
100
+
101
+ ## Additional trusted read paths
102
+
103
+ The trusted global configuration may append absolute paths to the sandbox's
104
+ default read allowlist. This is useful for executables installed below the
105
+ otherwise-denied home directory:
106
+
107
+ ```json
108
+ {
109
+ "subagents": {
110
+ "provider": "builtin"
111
+ },
112
+ "filesystem": {
113
+ "additionalAllowRead": [
114
+ "/home/user/.local/bin/rtk"
115
+ ]
116
+ }
117
+ }
118
+ ```
119
+
120
+ `filesystem.additionalAllowRead` must be an array of absolute paths. These
121
+ paths extend the defaults; they do not replace the workspace, Node.js, or
122
+ Sandbox Runtime read permissions. Keep entries as narrow as possible and
123
+ prefer an exact executable path over allowing an entire bin directory.
124
+
65
125
  Persistent built-in sessions support `start`, `follow_up`, `wait`, `status`,
66
126
  `stop`, and nested `handoff` operations. At most four sessions are live at
67
127
  once, and nesting depth is capped at three.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sandbox Runtime-backed Linux and macOS sandbox with pi-auto-review approval",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "files": [
31
31
  "src",
32
+ "CHANGELOG.md",
32
33
  "README.md",
33
34
  "NOTICE"
34
35
  ],
@@ -50,7 +51,7 @@
50
51
  },
51
52
  "dependencies": {
52
53
  "@anthropic-ai/sandbox-runtime": "0.0.67",
53
- "@erichll/pi-auto-review": "0.3.0"
54
+ "@erichll/pi-auto-review": "0.3.1"
54
55
  },
55
56
  "peerDependencies": {
56
57
  "@earendil-works/pi-coding-agent": ">=0.82.1",
package/src/approval.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  } from "@erichll/pi-auto-review/broker";
5
5
  import { sandboxTrapToBoundaryRequest } from "@erichll/pi-auto-review/sandbox";
6
6
  import type { SandboxApprovalTrap } from "./traps.ts";
7
+ import type { HostIPCTrigger } from "./host-ipc.ts";
7
8
  import { randomUUID } from "node:crypto";
8
9
  export type NetworkEndpoint = {
9
10
  hostname: string;
@@ -56,6 +57,33 @@ export async function approveDomainEndpoint(
56
57
  return approveBoundaryRequest(request, context);
57
58
  }
58
59
 
60
+ export async function approveHostIPCExecution(
61
+ trigger: HostIPCTrigger,
62
+ context: TrapApprovalContext,
63
+ ): Promise<TrapApprovalResult> {
64
+ const request: BoundaryRequest = {
65
+ id: `sandbox-runtime-host-ipc-${randomUUID()}`,
66
+ source: "sandbox-runtime",
67
+ surface: "host-ipc",
68
+ operation: "execute-host",
69
+ cwd: context.cwd,
70
+ command: context.command,
71
+ matchedPolicy: {
72
+ decision: "ask",
73
+ rule:
74
+ trigger.reason === "preflight-prefix"
75
+ ? `preflight-prefix:${trigger.rule}`
76
+ : "unix-socket-eperm",
77
+ },
78
+ toolInputPreview:
79
+ trigger.reason === "unix-socket-eperm"
80
+ ? "The sandboxed attempt failed and may already have had partial side effects."
81
+ : "A configured prefix requested approval before sandbox execution.",
82
+ agentName: context.agentName,
83
+ };
84
+ return approveBoundaryRequest(request, context);
85
+ }
86
+
59
87
  async function askHuman(
60
88
  request: BoundaryRequest,
61
89
  context: TrapApprovalContext,
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { isAbsolute, join } from "node:path";
4
4
 
5
5
  export const SUBAGENT_PROVIDERS = [
6
6
  "builtin",
@@ -10,10 +10,23 @@ export const SUBAGENT_PROVIDERS = [
10
10
 
11
11
  export type SubagentProvider = (typeof SUBAGENT_PROVIDERS)[number];
12
12
 
13
+ export const HOST_IPC_MODES = ["off", "ask"] as const;
14
+ export type HostIPCMode = (typeof HOST_IPC_MODES)[number];
15
+
16
+ export type HostIPCConfig = {
17
+ mode: HostIPCMode;
18
+ preflightCommandPrefixes: readonly string[];
19
+ retryOnUnixSocketError: boolean;
20
+ };
21
+
13
22
  export type PiSandboxConfig = {
14
23
  subagents: {
15
24
  provider: SubagentProvider;
16
25
  };
26
+ filesystem: {
27
+ additionalAllowRead: readonly string[];
28
+ };
29
+ hostIPC: HostIPCConfig;
17
30
  };
18
31
 
19
32
  export type LoadPiSandboxConfigOptions = {
@@ -25,6 +38,14 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
25
38
  subagents: Object.freeze({
26
39
  provider: "builtin",
27
40
  }),
41
+ filesystem: Object.freeze({
42
+ additionalAllowRead: Object.freeze([]),
43
+ }),
44
+ hostIPC: Object.freeze({
45
+ mode: "off",
46
+ preflightCommandPrefixes: Object.freeze([]),
47
+ retryOnUnixSocketError: false,
48
+ }),
28
49
  },
29
50
  );
30
51
 
@@ -53,22 +74,18 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
53
74
  if (!isRecord(value)) {
54
75
  throw new Error("invalid pi-sandbox configuration: root must be an object");
55
76
  }
56
- rejectUnknownKeys(value, ["subagents"], "root");
77
+ rejectUnknownKeys(value, ["subagents", "filesystem", "hostIPC"], "root");
57
78
 
58
- if (value.subagents === undefined) {
59
- return {
60
- subagents: { provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider },
61
- };
62
- }
63
- if (!isRecord(value.subagents)) {
79
+ if (value.subagents !== undefined && !isRecord(value.subagents)) {
64
80
  throw new Error(
65
81
  "invalid pi-sandbox configuration: subagents must be an object",
66
82
  );
67
83
  }
68
- rejectUnknownKeys(value.subagents, ["provider"], "subagents");
84
+ const subagents = value.subagents ?? {};
85
+ rejectUnknownKeys(subagents, ["provider"], "subagents");
69
86
 
70
87
  const provider =
71
- value.subagents.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
88
+ subagents.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
72
89
  if (
73
90
  typeof provider !== "string" ||
74
91
  !SUBAGENT_PROVIDERS.includes(provider as SubagentProvider)
@@ -78,10 +95,90 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
78
95
  );
79
96
  }
80
97
 
98
+ if (value.filesystem !== undefined && !isRecord(value.filesystem)) {
99
+ throw new Error(
100
+ "invalid pi-sandbox configuration: filesystem must be an object",
101
+ );
102
+ }
103
+ const filesystem = value.filesystem ?? {};
104
+ rejectUnknownKeys(
105
+ filesystem,
106
+ ["additionalAllowRead"],
107
+ "filesystem",
108
+ );
109
+ const additionalAllowRead =
110
+ filesystem.additionalAllowRead ??
111
+ DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead;
112
+ if (
113
+ !Array.isArray(additionalAllowRead) ||
114
+ additionalAllowRead.some(
115
+ (path) =>
116
+ typeof path !== "string" ||
117
+ path.trim() === "" ||
118
+ !isAbsolute(path),
119
+ )
120
+ ) {
121
+ throw new Error(
122
+ "invalid pi-sandbox configuration: filesystem.additionalAllowRead must be an array of absolute paths",
123
+ );
124
+ }
125
+
126
+ if (value.hostIPC !== undefined && !isRecord(value.hostIPC)) {
127
+ throw new Error(
128
+ "invalid pi-sandbox configuration: hostIPC must be an object",
129
+ );
130
+ }
131
+ const hostIPC = value.hostIPC ?? {};
132
+ rejectUnknownKeys(
133
+ hostIPC,
134
+ ["mode", "preflightCommandPrefixes", "retryOnUnixSocketError"],
135
+ "hostIPC",
136
+ );
137
+ const hostIPCMode = hostIPC.mode ?? DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode;
138
+ if (
139
+ typeof hostIPCMode !== "string" ||
140
+ !HOST_IPC_MODES.includes(hostIPCMode as HostIPCMode)
141
+ ) {
142
+ throw new Error(
143
+ `invalid pi-sandbox configuration: hostIPC.mode must be one of ${HOST_IPC_MODES.join(", ")}`,
144
+ );
145
+ }
146
+ const preflightCommandPrefixes =
147
+ hostIPC.preflightCommandPrefixes ??
148
+ DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes;
149
+ if (
150
+ !Array.isArray(preflightCommandPrefixes) ||
151
+ preflightCommandPrefixes.some(
152
+ (prefix) => typeof prefix !== "string" || prefix.trim() === "",
153
+ )
154
+ ) {
155
+ throw new Error(
156
+ "invalid pi-sandbox configuration: hostIPC.preflightCommandPrefixes must be an array of non-empty strings",
157
+ );
158
+ }
159
+ const retryOnUnixSocketError =
160
+ hostIPC.retryOnUnixSocketError ??
161
+ DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError;
162
+ if (typeof retryOnUnixSocketError !== "boolean") {
163
+ throw new Error(
164
+ "invalid pi-sandbox configuration: hostIPC.retryOnUnixSocketError must be a boolean",
165
+ );
166
+ }
167
+
81
168
  return {
82
169
  subagents: {
83
170
  provider: provider as SubagentProvider,
84
171
  },
172
+ filesystem: {
173
+ additionalAllowRead: [...new Set(additionalAllowRead)],
174
+ },
175
+ hostIPC: {
176
+ mode: hostIPCMode as HostIPCMode,
177
+ preflightCommandPrefixes: [
178
+ ...new Set(preflightCommandPrefixes.map((prefix) => prefix.trim())),
179
+ ],
180
+ retryOnUnixSocketError,
181
+ },
85
182
  };
86
183
  }
87
184
 
@@ -101,6 +198,19 @@ export function loadPiSandboxConfig(
101
198
  ) {
102
199
  return {
103
200
  subagents: { provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider },
201
+ filesystem: {
202
+ additionalAllowRead: [
203
+ ...DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead,
204
+ ],
205
+ },
206
+ hostIPC: {
207
+ mode: DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
208
+ preflightCommandPrefixes: [
209
+ ...DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes,
210
+ ],
211
+ retryOnUnixSocketError:
212
+ DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError,
213
+ },
104
214
  };
105
215
  }
106
216
  throw new Error(`failed to read pi-sandbox configuration at ${path}`, {
@@ -0,0 +1,160 @@
1
+ import type { HostIPCConfig } from "./config.ts";
2
+ import type { TrapApprovalResult } from "./approval.ts";
3
+
4
+ const MAX_STDERR_BYTES = 64 * 1024;
5
+ const SUBAGENT_DEPTH_ENV = "PI_SANDBOX_SUBAGENT_DEPTH";
6
+
7
+ export type HostIPCTrigger =
8
+ | {
9
+ reason: "preflight-prefix";
10
+ rule: string;
11
+ }
12
+ | {
13
+ reason: "unix-socket-eperm";
14
+ rule: "unix-socket-eperm";
15
+ };
16
+
17
+ type CommandResult = { exitCode: number | null };
18
+
19
+ export type HostIPCCommandOptions = {
20
+ command: string;
21
+ cwd: string;
22
+ env?: NodeJS.ProcessEnv;
23
+ signal?: AbortSignal;
24
+ timeout?: number;
25
+ onData(data: Buffer): void;
26
+ config: HostIPCConfig;
27
+ approve(trigger: HostIPCTrigger): Promise<TrapApprovalResult>;
28
+ runSandbox(onStderr: (data: Buffer) => void): Promise<CommandResult>;
29
+ runHost(timeout: number | undefined): Promise<CommandResult>;
30
+ now?: () => number;
31
+ };
32
+
33
+ export function matchingPreflightPrefix(
34
+ command: string,
35
+ prefixes: readonly string[],
36
+ ): string | undefined {
37
+ const trimmed = command.trim();
38
+ return prefixes.find(
39
+ (prefix) =>
40
+ trimmed.startsWith(prefix) &&
41
+ (trimmed.length === prefix.length ||
42
+ /\s/.test(trimmed.charAt(prefix.length))),
43
+ );
44
+ }
45
+
46
+ export function isUnixSocketPermissionError(stderr: Buffer | string): boolean {
47
+ const text = typeof stderr === "string" ? stderr : stderr.toString("utf8");
48
+ return (
49
+ /operation not permitted/i.test(text) &&
50
+ /socket|connect(?:ing)?|ipc/i.test(text)
51
+ );
52
+ }
53
+
54
+ function appendBounded(current: Buffer, chunk: Buffer): Buffer {
55
+ if (chunk.length >= MAX_STDERR_BYTES) {
56
+ return chunk.subarray(chunk.length - MAX_STDERR_BYTES);
57
+ }
58
+ const combined = Buffer.concat([current, chunk]);
59
+ return combined.length <= MAX_STDERR_BYTES
60
+ ? combined
61
+ : combined.subarray(combined.length - MAX_STDERR_BYTES);
62
+ }
63
+
64
+ function isBuiltinSubagent(env: NodeJS.ProcessEnv | undefined): boolean {
65
+ return Number(env?.[SUBAGENT_DEPTH_ENV] ?? process.env[SUBAGENT_DEPTH_ENV]) > 0;
66
+ }
67
+
68
+ function remainingTimeout(
69
+ timeout: number | undefined,
70
+ startedAt: number,
71
+ now: () => number,
72
+ ): number | undefined {
73
+ if (timeout === undefined) return undefined;
74
+ const remaining = timeout - (now() - startedAt) / 1_000;
75
+ if (remaining <= 0) throw new Error(`timeout:${timeout}`);
76
+ return remaining;
77
+ }
78
+
79
+ function refusal(options: HostIPCCommandOptions, reason?: string): CommandResult {
80
+ options.onData(
81
+ Buffer.from(
82
+ `pi-sandbox: host-IPC execution denied${reason ? `: ${reason}` : ""}\n`,
83
+ "utf8",
84
+ ),
85
+ );
86
+ return { exitCode: 1 };
87
+ }
88
+
89
+ export async function runCommandWithHostIPC(
90
+ options: HostIPCCommandOptions,
91
+ ): Promise<CommandResult> {
92
+ const now = options.now ?? Date.now;
93
+ const startedAt = now();
94
+ const enabled = options.config.mode === "ask";
95
+ const prefix = enabled
96
+ ? matchingPreflightPrefix(
97
+ options.command,
98
+ options.config.preflightCommandPrefixes,
99
+ )
100
+ : undefined;
101
+
102
+ if (prefix !== undefined) {
103
+ if (isBuiltinSubagent(options.env)) {
104
+ return refusal(
105
+ options,
106
+ "builtin subagents do not support host forwarding",
107
+ );
108
+ }
109
+ const approval = await options.approve({
110
+ reason: "preflight-prefix",
111
+ rule: prefix,
112
+ });
113
+ if (approval.action === "deny") {
114
+ return refusal(options, approval.reason);
115
+ }
116
+ return options.runHost(
117
+ remainingTimeout(options.timeout, startedAt, now),
118
+ );
119
+ }
120
+
121
+ let stderr: Buffer<ArrayBufferLike> = Buffer.alloc(0);
122
+ const sandboxResult = await options.runSandbox((data) => {
123
+ stderr = appendBounded(stderr, data);
124
+ });
125
+ if (
126
+ !enabled ||
127
+ !options.config.retryOnUnixSocketError ||
128
+ sandboxResult.exitCode === 0 ||
129
+ sandboxResult.exitCode === null ||
130
+ options.signal?.aborted ||
131
+ !isUnixSocketPermissionError(stderr)
132
+ ) {
133
+ return sandboxResult;
134
+ }
135
+ if (isBuiltinSubagent(options.env)) {
136
+ options.onData(
137
+ Buffer.from(
138
+ "pi-sandbox: host-IPC retry refused: builtin subagents do not support host forwarding\n",
139
+ "utf8",
140
+ ),
141
+ );
142
+ return sandboxResult;
143
+ }
144
+
145
+ const approval = await options.approve({
146
+ reason: "unix-socket-eperm",
147
+ rule: "unix-socket-eperm",
148
+ });
149
+ if (approval.action === "deny") return sandboxResult;
150
+
151
+ options.onData(
152
+ Buffer.from(
153
+ "\n--- pi-sandbox: approved host-IPC retry (outside OS sandbox) ---\n",
154
+ "utf8",
155
+ ),
156
+ );
157
+ return options.runHost(
158
+ remainingTimeout(options.timeout, startedAt, now),
159
+ );
160
+ }
package/src/index.ts CHANGED
@@ -5,22 +5,27 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  createBashToolDefinition,
8
+ createLocalBashOperations,
8
9
  SettingsManager,
9
10
  } from "@earendil-works/pi-coding-agent";
10
11
  import { getBoundaryBroker } from "@erichll/pi-auto-review/broker";
11
12
  import {
12
13
  approveDomainEndpoint,
14
+ approveHostIPCExecution,
13
15
  approveSandboxTrap,
14
16
  type HumanApproval,
15
17
  } from "./approval.ts";
16
18
  import {
17
19
  loadPiSandboxConfig,
20
+ type HostIPCConfig,
18
21
  type SubagentProvider,
19
22
  } from "./config.ts";
23
+ import { runCommandWithHostIPC } from "./host-ipc.ts";
20
24
  import {
21
25
  runSandboxedCommand,
22
26
  type SandboxCommandOptions,
23
27
  } from "./runner.ts";
28
+ import { createDefaultPolicy } from "./policy.ts";
24
29
  import type {
25
30
  ProcessBackedSubagentManager,
26
31
  ProcessBackedSubagentSession,
@@ -35,6 +40,8 @@ export type PiSandboxExtensionOptions = {
35
40
  subagentManager?: ProcessBackedSubagentManager;
36
41
  createSubagentManager?: () => ProcessBackedSubagentManager;
37
42
  sandbox?: Pick<SandboxCommandOptions, "broker" | "platform">;
43
+ /** Test/embedding override. Normal extension loading uses trusted global config. */
44
+ hostIPC?: HostIPCConfig;
38
45
  };
39
46
 
40
47
  function sessionId(ctx: ExtensionContext): string {
@@ -81,13 +88,21 @@ function humanApproval(ctx: ExtensionContext): HumanApproval {
81
88
  return async (request, reason, signal) => {
82
89
  if (!ctx.hasUI) return "deny";
83
90
  const target =
84
- request.resolvedPath ??
85
- request.path ??
86
- request.destination ??
87
- request.operation;
91
+ request.surface === "host-ipc"
92
+ ? request.command ?? request.operation
93
+ : request.resolvedPath ??
94
+ request.path ??
95
+ request.destination ??
96
+ request.operation;
88
97
  const suffix = reason ? `\n${reason}` : "";
98
+ const hostWarning =
99
+ request.surface === "host-ipc"
100
+ ? request.matchedPolicy?.rule === "unix-socket-eperm"
101
+ ? "\nWarning: the first sandboxed attempt may already have had partial side effects. The retry runs on the host outside the OS sandbox."
102
+ : "\nWarning: this command will run on the host outside the OS sandbox."
103
+ : "";
89
104
  const selected = await ctx.ui.select(
90
- `Sandbox approval required: ${request.operation} ${target}${suffix}`,
105
+ `Sandbox approval required: ${request.operation} ${target}${hostWarning}${suffix}`,
91
106
  ["Allow this exact operation once", "Deny"],
92
107
  { signal },
93
108
  );
@@ -100,52 +115,82 @@ function humanApproval(ctx: ExtensionContext): HumanApproval {
100
115
  function sandboxOperations(
101
116
  ctx: ExtensionContext,
102
117
  turnIndex: () => number,
118
+ additionalAllowRead: readonly string[],
119
+ hostIPC: HostIPCConfig,
103
120
  sandbox?: PiSandboxExtensionOptions["sandbox"],
104
121
  ): BashOperations {
105
122
  return {
106
123
  exec(command, cwd, options) {
107
124
  const currentSessionId = sessionId(ctx);
108
- return runSandboxedCommand({
125
+ const shellPath = SettingsManager.create(cwd).getShellPath();
126
+ const approvalContext = {
127
+ broker: getBoundaryBroker(),
128
+ command,
129
+ cwd,
130
+ sessionId: currentSessionId,
131
+ scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
132
+ signal: options.signal,
133
+ humanApproval: humanApproval(ctx),
134
+ };
135
+ const local = createLocalBashOperations({ shellPath });
136
+ return runCommandWithHostIPC({
109
137
  command,
110
138
  cwd,
111
139
  env: options.env,
112
140
  signal: options.signal,
113
141
  timeout: options.timeout,
114
- ...sandbox,
115
142
  onData: options.onData,
116
- shellPath: SettingsManager.create(cwd).getShellPath(),
117
- review: async (trap) => {
118
- const result = await approveSandboxTrap(trap, {
119
- broker: getBoundaryBroker(),
120
- command,
121
- cwd,
122
- sessionId: currentSessionId,
123
- scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
124
- signal: options.signal,
125
- humanApproval: humanApproval(ctx),
126
- });
127
- if (result.action === "deny" && result.reason) {
128
- if (ctx.hasUI) {
129
- ctx.ui.notify(`Sandbox denied: ${result.reason}`, "warning");
130
- }
143
+ config: hostIPC,
144
+ approve: async (trigger) => {
145
+ const result = await approveHostIPCExecution(
146
+ trigger,
147
+ approvalContext,
148
+ );
149
+ if (result.action === "deny" && result.reason && ctx.hasUI) {
150
+ ctx.ui.notify(`Host-IPC denied: ${result.reason}`, "warning");
131
151
  }
132
- return result.action;
152
+ return result;
133
153
  },
134
- reviewDomain: async (endpoint) => {
135
- const result = await approveDomainEndpoint(endpoint, {
136
- broker: getBoundaryBroker(),
154
+ runHost: (timeout) =>
155
+ local.exec(command, cwd, {
156
+ ...options,
157
+ timeout,
158
+ }),
159
+ runSandbox: (onStderr) =>
160
+ runSandboxedCommand({
137
161
  command,
138
162
  cwd,
139
- sessionId: currentSessionId,
140
- scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
163
+ env: options.env,
141
164
  signal: options.signal,
142
- humanApproval: humanApproval(ctx),
143
- });
144
- if (result.action === "deny" && result.reason && ctx.hasUI) {
145
- ctx.ui.notify(`Domain proxy denied: ${result.reason}`, "warning");
146
- }
147
- return result.action;
148
- },
165
+ timeout: options.timeout,
166
+ ...sandbox,
167
+ onData: options.onData,
168
+ onStderr,
169
+ shellPath,
170
+ policy: createDefaultPolicy(cwd, { additionalAllowRead }),
171
+ review: async (trap) => {
172
+ const result = await approveSandboxTrap(trap, approvalContext);
173
+ if (result.action === "deny" && result.reason) {
174
+ if (ctx.hasUI) {
175
+ ctx.ui.notify(`Sandbox denied: ${result.reason}`, "warning");
176
+ }
177
+ }
178
+ return result.action;
179
+ },
180
+ reviewDomain: async (endpoint) => {
181
+ const result = await approveDomainEndpoint(
182
+ endpoint,
183
+ approvalContext,
184
+ );
185
+ if (result.action === "deny" && result.reason && ctx.hasUI) {
186
+ ctx.ui.notify(
187
+ `Domain proxy denied: ${result.reason}`,
188
+ "warning",
189
+ );
190
+ }
191
+ return result.action;
192
+ },
193
+ }),
149
194
  });
150
195
  },
151
196
  };
@@ -156,8 +201,11 @@ async function performRegistration(
156
201
  options: PiSandboxExtensionOptions,
157
202
  ): Promise<void> {
158
203
  let currentTurn = 0;
204
+ const config = loadPiSandboxConfig();
159
205
  const subagentProvider =
160
- options.subagentProvider ?? loadPiSandboxConfig().subagents.provider;
206
+ options.subagentProvider ?? config.subagents.provider;
207
+ const additionalAllowRead = config.filesystem.additionalAllowRead;
208
+ const hostIPC = options.hostIPC ?? config.hostIPC;
161
209
  const subagents =
162
210
  subagentProvider === "builtin"
163
211
  ? (options.subagentManager ??
@@ -176,7 +224,13 @@ async function performRegistration(
176
224
  async execute(id, params, signal, onUpdate, ctx) {
177
225
  if (!ctx) throw new Error(`${EXTENSION_NAME}: missing extension context`);
178
226
  const tool = createBashToolDefinition(ctx.cwd, {
179
- operations: sandboxOperations(ctx, () => currentTurn, options.sandbox),
227
+ operations: sandboxOperations(
228
+ ctx,
229
+ () => currentTurn,
230
+ additionalAllowRead,
231
+ hostIPC,
232
+ options.sandbox,
233
+ ),
180
234
  });
181
235
  return tool.execute(id, params, signal, onUpdate, ctx);
182
236
  },
@@ -262,6 +316,7 @@ async function performRegistration(
262
316
  cwd: ctx.cwd,
263
317
  model: params.model,
264
318
  tools: pi.getActiveTools().filter((name) => name !== "subagent"),
319
+ policy: createDefaultPolicy(ctx.cwd, { additionalAllowRead }),
265
320
  sandbox: options.sandbox,
266
321
  review: async (trap: Parameters<typeof approveSandboxTrap>[0]) =>
267
322
  (await approveSandboxTrap(trap, approvalContext)).action,
@@ -408,7 +463,13 @@ async function performRegistration(
408
463
  });
409
464
 
410
465
  pi.on("user_bash", (_event, ctx) => ({
411
- operations: sandboxOperations(ctx, () => currentTurn, options.sandbox),
466
+ operations: sandboxOperations(
467
+ ctx,
468
+ () => currentTurn,
469
+ additionalAllowRead,
470
+ hostIPC,
471
+ options.sandbox,
472
+ ),
412
473
  }));
413
474
 
414
475
  pi.on("turn_start", (event) => {
package/src/policy.ts CHANGED
@@ -35,7 +35,14 @@ export type SandboxPolicy = {
35
35
  };
36
36
  };
37
37
 
38
- export function createDefaultPolicy(cwd: string): SandboxPolicy {
38
+ export type CreateDefaultPolicyOptions = {
39
+ additionalAllowRead?: readonly string[];
40
+ };
41
+
42
+ export function createDefaultPolicy(
43
+ cwd: string,
44
+ options: CreateDefaultPolicyOptions = {},
45
+ ): SandboxPolicy {
39
46
  const workspace = resolve(cwd);
40
47
  const home = resolve(homedir());
41
48
  const denyRead = home === parse(home).root ? [] : [home];
@@ -53,6 +60,7 @@ export function createDefaultPolicy(cwd: string): SandboxPolicy {
53
60
  join(home, ".gitconfig"),
54
61
  join(home, ".config", "git", "config"),
55
62
  "/dev/null",
63
+ ...(options.additionalAllowRead ?? []),
56
64
  ],
57
65
  allowWrite: [workspace, "/dev/null"],
58
66
  denyWrite: [