@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 +13 -0
- package/README.md +14 -3
- package/package.json +1 -1
- package/src/config.ts +78 -29
- package/src/policy.ts +195 -1
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
|
|
22
|
-
hooks, and other Sandbox Runtime mandatory paths
|
|
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
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
229
|
+
return readFileSync(path, "utf8");
|
|
192
230
|
} catch (error) {
|
|
193
|
-
if (
|
|
194
|
-
|
|
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: {
|