@erichll/pi-sandbox 0.4.1 → 0.4.2

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 - 2026-07-30
4
+
5
+ - Load trusted configuration from
6
+ `~/.pi/agent/extensions/pi-sandbox/config.json`, with a read fallback to the
7
+ legacy `~/.pi/agent/pi-sandbox.json` path when the new file is absent.
8
+ - Deny writes to common workspace secrets by default (`.env` variants,
9
+ `secrets/` / `.secrets/`, and discovered `*.pem` / `*.key` / `*.p12` /
10
+ `*.pfx`), while leaving template files such as `.env.example` writable.
11
+ - Protect `~/.pi/agent/extensions` (including the extension-local config) from
12
+ sandboxed writes.
13
+ - On macOS, add git-style secret globs for nested create protection; Linux keeps
14
+ literal absolute paths only because Sandbox Runtime drops globs there.
15
+
3
16
  ## 0.4.1 - 2026-07-29
4
17
 
5
18
  - Republish to refresh the npm search index used by the pi.dev package gallery.
package/README.md CHANGED
@@ -18,8 +18,15 @@ also run complete process-backed subagent trees inside independent sandboxes.
18
18
  - Every Bash command and persistent subagent session owns a separate broker
19
19
  process and Sandbox Runtime manager. Concurrent workers therefore keep
20
20
  independent policy, proxy, approval, and cleanup lifecycles.
21
- - Project and global Pi security configuration, the installed package, Git
22
- hooks, and other Sandbox Runtime mandatory paths remain write-protected.
21
+ - Project and global Pi security configuration, the trusted extensions tree,
22
+ the installed package, Git hooks, and other Sandbox Runtime mandatory paths
23
+ remain write-protected.
24
+ - Common workspace secrets are write-denied by default: root-level `.env`
25
+ variants, `secrets/` / `.secrets/`, plus a shallow scan for nested `.env*`,
26
+ `*.pem`, `*.key`, `*.p12`, and `*.pfx`. Template files such as
27
+ `.env.example` stay writable. On macOS, additional globs also block nested
28
+ creates; Linux Sandbox Runtime only enforces literal paths, so nested creates
29
+ of new secret files remain a residual risk outside the scanned set.
23
30
  - Optional host IPC execution is disabled by default. When enabled, every
24
31
  complete host command still requires a one-shot `pi-auto-review` or human
25
32
  approval and runs outside the OS sandbox.
@@ -43,7 +50,11 @@ macOS uses built-in Seatbelt support but still requires `ripgrep`.
43
50
  ## Subagent provider
44
51
 
45
52
  The provider is selected only from the trusted global file
46
- `~/.pi/agent/pi-sandbox.json`:
53
+ `~/.pi/agent/extensions/pi-sandbox/config.json`:
54
+
55
+ If that file is missing, `pi-sandbox` still loads the legacy path
56
+ `~/.pi/agent/pi-sandbox.json` when present. Prefer the extension-local path for
57
+ new installs.
47
58
 
48
59
  ```json
49
60
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
package/src/config.ts CHANGED
@@ -31,6 +31,8 @@ export type PiSandboxConfig = {
31
31
 
32
32
  export type LoadPiSandboxConfigOptions = {
33
33
  path?: string;
34
+ /** Override home directory when resolving default/legacy trusted paths. */
35
+ home?: string;
34
36
  };
35
37
 
36
38
  export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freeze(
@@ -50,6 +52,18 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
50
52
  );
51
53
 
52
54
  export function getPiSandboxConfigPath(home = homedir()): string {
55
+ return join(
56
+ home,
57
+ ".pi",
58
+ "agent",
59
+ "extensions",
60
+ "pi-sandbox",
61
+ "config.json",
62
+ );
63
+ }
64
+
65
+ /** Legacy trusted path used before the extension-local config layout. */
66
+ export function getLegacyPiSandboxConfigPath(home = homedir()): string {
53
67
  return join(home, ".pi", "agent", "pi-sandbox.json");
54
68
  }
55
69
 
@@ -182,42 +196,48 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
182
196
  };
183
197
  }
184
198
 
185
- export function loadPiSandboxConfig(
186
- options: LoadPiSandboxConfigOptions = {},
187
- ): PiSandboxConfig {
188
- const path = options.path ?? getPiSandboxConfigPath();
189
- let source: string;
199
+ function defaultPiSandboxConfig(): PiSandboxConfig {
200
+ return {
201
+ subagents: { provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider },
202
+ filesystem: {
203
+ additionalAllowRead: [
204
+ ...DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead,
205
+ ],
206
+ },
207
+ hostIPC: {
208
+ mode: DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
209
+ preflightCommandPrefixes: [
210
+ ...DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes,
211
+ ],
212
+ retryOnUnixSocketError:
213
+ DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError,
214
+ },
215
+ };
216
+ }
217
+
218
+ function isNotFoundError(error: unknown): boolean {
219
+ return (
220
+ typeof error === "object" &&
221
+ error !== null &&
222
+ "code" in error &&
223
+ error.code === "ENOENT"
224
+ );
225
+ }
226
+
227
+ function readPiSandboxConfigFile(path: string): string {
190
228
  try {
191
- source = readFileSync(path, "utf8");
229
+ return readFileSync(path, "utf8");
192
230
  } catch (error) {
193
- if (
194
- typeof error === "object" &&
195
- error !== null &&
196
- "code" in error &&
197
- error.code === "ENOENT"
198
- ) {
199
- return {
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
- },
214
- };
231
+ if (isNotFoundError(error)) {
232
+ throw error;
215
233
  }
216
234
  throw new Error(`failed to read pi-sandbox configuration at ${path}`, {
217
235
  cause: error,
218
236
  });
219
237
  }
238
+ }
220
239
 
240
+ function parsePiSandboxConfigFile(path: string, source: string): PiSandboxConfig {
221
241
  let value: unknown;
222
242
  try {
223
243
  value = JSON.parse(source);
@@ -226,6 +246,35 @@ export function loadPiSandboxConfig(
226
246
  cause: error,
227
247
  });
228
248
  }
229
-
230
249
  return parsePiSandboxConfig(value);
231
250
  }
251
+
252
+ export function loadPiSandboxConfig(
253
+ options: LoadPiSandboxConfigOptions = {},
254
+ ): PiSandboxConfig {
255
+ const path = options.path ?? getPiSandboxConfigPath(options.home);
256
+ try {
257
+ return parsePiSandboxConfigFile(path, readPiSandboxConfigFile(path));
258
+ } catch (error) {
259
+ if (!isNotFoundError(error)) {
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ // Only fall back to the legacy path when loading the default trusted location.
265
+ if (options.path === undefined) {
266
+ const legacyPath = getLegacyPiSandboxConfigPath(options.home);
267
+ try {
268
+ return parsePiSandboxConfigFile(
269
+ legacyPath,
270
+ readPiSandboxConfigFile(legacyPath),
271
+ );
272
+ } catch (error) {
273
+ if (!isNotFoundError(error)) {
274
+ throw error;
275
+ }
276
+ }
277
+ }
278
+
279
+ return defaultPiSandboxConfig();
280
+ }
package/src/policy.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readdirSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import {
3
4
  dirname,
@@ -18,6 +19,88 @@ const SANDBOX_RUNTIME_ROOT = dirname(
18
19
  ),
19
20
  );
20
21
 
22
+ /** Common secret basenames always denied at the workspace root (even if missing). */
23
+ export const WORKSPACE_SECRET_DENY_WRITE_BASENAMES = [
24
+ ".env",
25
+ ".env.local",
26
+ ".env.development",
27
+ ".env.development.local",
28
+ ".env.test",
29
+ ".env.test.local",
30
+ ".env.production",
31
+ ".env.production.local",
32
+ ".env.staging",
33
+ ".env.staging.local",
34
+ ".env.ci",
35
+ ] as const;
36
+
37
+ /** Secret-like directories denied at the workspace root (even if missing). */
38
+ export const WORKSPACE_SECRET_DENY_WRITE_DIRECTORIES = [
39
+ "secrets",
40
+ ".secrets",
41
+ ] as const;
42
+
43
+ /** Private-key / certificate extensions denied when discovered under the workspace. */
44
+ export const WORKSPACE_SECRET_DENY_WRITE_EXTENSIONS = [
45
+ ".pem",
46
+ ".key",
47
+ ".p12",
48
+ ".pfx",
49
+ ] as const;
50
+
51
+ /**
52
+ * Template / sample env files that agents may legitimately create or edit.
53
+ * These are excluded from secret write denials.
54
+ */
55
+ export const WORKSPACE_SECRET_TEMPLATE_BASENAMES = new Set([
56
+ ".env.example",
57
+ ".env.sample",
58
+ ".env.template",
59
+ ".env.dist",
60
+ ]);
61
+
62
+ const WALK_SKIP_DIRECTORIES = new Set([
63
+ ".git",
64
+ "node_modules",
65
+ "dist",
66
+ "build",
67
+ "coverage",
68
+ ".next",
69
+ ".turbo",
70
+ ".cache",
71
+ "target",
72
+ "vendor",
73
+ ]);
74
+
75
+ /** Bounded workspace walk used to discover nested secrets on every platform. */
76
+ const WORKSPACE_SECRET_SCAN_MAX_DEPTH = 4;
77
+
78
+ /**
79
+ * macOS Seatbelt supports git-style globs; Linux bubblewrap silently drops them.
80
+ * Keep these as an extra nested-create defense on Darwin only.
81
+ */
82
+ const DARWIN_SECRET_DENY_WRITE_GLOBS = [
83
+ "**/.env",
84
+ "**/.env.local",
85
+ "**/.env.development",
86
+ "**/.env.development.local",
87
+ "**/.env.test",
88
+ "**/.env.test.local",
89
+ "**/.env.production",
90
+ "**/.env.production.local",
91
+ "**/.env.staging",
92
+ "**/.env.staging.local",
93
+ "**/.env.ci",
94
+ "**/secrets",
95
+ "**/secrets/**",
96
+ "**/.secrets",
97
+ "**/.secrets/**",
98
+ "**/*.pem",
99
+ "**/*.key",
100
+ "**/*.p12",
101
+ "**/*.pfx",
102
+ ] as const;
103
+
21
104
  export type SandboxPolicy = {
22
105
  filesystem: {
23
106
  denyRead: string[];
@@ -39,6 +122,104 @@ export type CreateDefaultPolicyOptions = {
39
122
  additionalAllowRead?: readonly string[];
40
123
  };
41
124
 
125
+ export function isSecretDenyWriteBasename(name: string): boolean {
126
+ if (WORKSPACE_SECRET_TEMPLATE_BASENAMES.has(name)) {
127
+ return false;
128
+ }
129
+ if (
130
+ (WORKSPACE_SECRET_DENY_WRITE_BASENAMES as readonly string[]).includes(name)
131
+ ) {
132
+ return true;
133
+ }
134
+ // Catch less common variants such as `.env.preview` while sparing templates.
135
+ if (name.startsWith(".env.")) {
136
+ return true;
137
+ }
138
+ const lower = name.toLowerCase();
139
+ return WORKSPACE_SECRET_DENY_WRITE_EXTENSIONS.some((extension) =>
140
+ lower.endsWith(extension),
141
+ );
142
+ }
143
+
144
+ function collectNestedSecretDenyWritePaths(
145
+ workspace: string,
146
+ maxDepth = WORKSPACE_SECRET_SCAN_MAX_DEPTH,
147
+ ): string[] {
148
+ const discovered: string[] = [];
149
+
150
+ const visit = (directory: string, depth: number): void => {
151
+ if (depth > maxDepth) {
152
+ return;
153
+ }
154
+ let entries;
155
+ try {
156
+ entries = readdirSync(directory, { withFileTypes: true });
157
+ } catch {
158
+ return;
159
+ }
160
+ for (const entry of entries) {
161
+ if (entry.name === "." || entry.name === "..") {
162
+ continue;
163
+ }
164
+ const fullPath = join(directory, entry.name);
165
+ if (entry.isDirectory()) {
166
+ if (WALK_SKIP_DIRECTORIES.has(entry.name)) {
167
+ continue;
168
+ }
169
+ if (
170
+ (WORKSPACE_SECRET_DENY_WRITE_DIRECTORIES as readonly string[]).includes(
171
+ entry.name,
172
+ )
173
+ ) {
174
+ discovered.push(fullPath);
175
+ continue;
176
+ }
177
+ visit(fullPath, depth + 1);
178
+ continue;
179
+ }
180
+ if (entry.isFile() && isSecretDenyWriteBasename(entry.name)) {
181
+ discovered.push(fullPath);
182
+ }
183
+ }
184
+ };
185
+
186
+ visit(workspace, 0);
187
+ return discovered;
188
+ }
189
+
190
+ /**
191
+ * Build workspace secret write denials.
192
+ *
193
+ * - Always includes root-level secret basenames/directories as absolute paths so
194
+ * Linux can block both existing files and first-time creation.
195
+ * - Scans a shallow workspace tree for nested secrets that already exist.
196
+ * - Adds Darwin-only globs so nested creates are also blocked on macOS.
197
+ */
198
+ export function createWorkspaceSecretDenyWritePaths(
199
+ workspace: string,
200
+ platform: NodeJS.Platform = process.platform,
201
+ ): string[] {
202
+ const root = resolve(workspace);
203
+ const paths = new Set<string>();
204
+
205
+ for (const name of WORKSPACE_SECRET_DENY_WRITE_BASENAMES) {
206
+ paths.add(join(root, name));
207
+ }
208
+ for (const name of WORKSPACE_SECRET_DENY_WRITE_DIRECTORIES) {
209
+ paths.add(join(root, name));
210
+ }
211
+ for (const discovered of collectNestedSecretDenyWritePaths(root)) {
212
+ paths.add(discovered);
213
+ }
214
+ if (platform === "darwin") {
215
+ for (const pattern of DARWIN_SECRET_DENY_WRITE_GLOBS) {
216
+ paths.add(pattern);
217
+ }
218
+ }
219
+
220
+ return [...paths];
221
+ }
222
+
42
223
  export function createDefaultPolicy(
43
224
  cwd: string,
44
225
  options: CreateDefaultPolicyOptions = {},
@@ -69,11 +250,24 @@ export function createDefaultPolicy(
69
250
  join(workspace, ".pi", "pi-auto-review.json"),
70
251
  join(home, ".pi", "agent", "settings.json"),
71
252
  join(home, ".pi", "agent", "permissions.json"),
72
- join(home, ".pi", "agent", "pi-sandbox.json"),
73
253
  join(home, ".pi", "agent", "sandbox.json"),
254
+ // Legacy config path kept write-protected during migration.
255
+ join(home, ".pi", "agent", "pi-sandbox.json"),
74
256
  join(home, ".pi", "agent", "logs"),
257
+ // Prevent the sandbox from installing or rewriting trusted extensions
258
+ // (includes ~/.pi/agent/extensions/pi-sandbox/config.json).
259
+ join(home, ".pi", "agent", "extensions"),
260
+ join(
261
+ home,
262
+ ".pi",
263
+ "agent",
264
+ "extensions",
265
+ "pi-sandbox",
266
+ "config.json",
267
+ ),
75
268
  ...(packageIsInWorkspace ? [] : [PACKAGE_ROOT]),
76
269
  dirname(process.execPath),
270
+ ...createWorkspaceSecretDenyWritePaths(workspace),
77
271
  ],
78
272
  },
79
273
  network: {