pi-better-subagents 0.2.0 → 0.3.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/README.md +2 -2
- package/docs/failure-observations.md +11 -0
- package/failures.ts +188 -0
- package/finalization.ts +14 -0
- package/index.ts +112 -23
- package/list.mjs +4 -1
- package/package.json +3 -2
- package/permission-policy.ts +106 -0
- package/sandbox.ts +26 -5
- package/shared-callback-batcher.ts +42 -16
- package/shared-failure-observations.ts +205 -0
- package/shared-sandbox-core.ts +283 -11
- package/tools.ts +9 -4
package/shared-sandbox-core.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OS-level write sandbox mechanism shared by Pi extensions.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* the network
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Legacy policies are write-only: a sandboxed process may READ anywhere and use
|
|
6
|
+
* the network, but may only WRITE under a canonical root plus runtime paths.
|
|
7
|
+
* Optional permissions add capability restrictions for reads, writes, launches
|
|
8
|
+
* and network access. They cover known credential files, not OS keychains,
|
|
9
|
+
* credential services, or tokens inherited in the child environment.
|
|
10
10
|
*
|
|
11
11
|
* This module owns the mechanism only: backend discovery, canonical path
|
|
12
12
|
* containment, write-deny compilation, macOS SBPL profile construction, Linux
|
|
@@ -35,6 +35,14 @@ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
|
35
35
|
/** Identifies which kernel mechanism a plan will use. */
|
|
36
36
|
export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
|
|
37
37
|
|
|
38
|
+
export type SandboxPermissions = {
|
|
39
|
+
projectFiles: "off" | "read" | "read-write";
|
|
40
|
+
outsideProject: "off" | "read" | "read-write";
|
|
41
|
+
storedCredentials: "off" | "read" | "read-write";
|
|
42
|
+
commands: boolean;
|
|
43
|
+
network: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
38
46
|
/**
|
|
39
47
|
* What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
|
|
40
48
|
* be relative or contain symlinks; they are canonicalized before use.
|
|
@@ -55,6 +63,10 @@ export type SandboxWritePolicy = {
|
|
|
55
63
|
denyWrite?: readonly string[];
|
|
56
64
|
/** Home directory whose `~/.pi` state stays writable on macOS. */
|
|
57
65
|
home: string;
|
|
66
|
+
/** Optional capability profile; omission preserves the original write-only sandbox. */
|
|
67
|
+
permissions?: SandboxPermissions;
|
|
68
|
+
/** Trusted per-launch runtime state, never supplied by model tool arguments. */
|
|
69
|
+
runtimeWrite?: readonly string[];
|
|
58
70
|
};
|
|
59
71
|
|
|
60
72
|
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
@@ -109,6 +121,9 @@ export type CompiledSandboxWritePolicy = {
|
|
|
109
121
|
readonly writableRoot: string;
|
|
110
122
|
readonly denyWrite: readonly string[];
|
|
111
123
|
readonly home: string;
|
|
124
|
+
readonly permissions?: SandboxPermissions;
|
|
125
|
+
readonly credentialPaths?: readonly string[];
|
|
126
|
+
readonly runtimeWrite?: readonly string[];
|
|
112
127
|
};
|
|
113
128
|
|
|
114
129
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -117,11 +132,15 @@ export type WriteAccessDecision =
|
|
|
117
132
|
| {
|
|
118
133
|
allowed: false;
|
|
119
134
|
path: string;
|
|
120
|
-
reason: "outside-writable-root" | "write-denied";
|
|
135
|
+
reason: "outside-writable-root" | "write-denied" | "permission-denied";
|
|
121
136
|
/** The compiled deny entry that matched, for `write-denied` only. */
|
|
122
137
|
deniedBy?: string;
|
|
123
138
|
};
|
|
124
139
|
|
|
140
|
+
export type ReadAccessDecision =
|
|
141
|
+
| { allowed: true; path: string }
|
|
142
|
+
| { allowed: false; path: string; reason: "read-denied" };
|
|
143
|
+
|
|
125
144
|
/** What the current platform can enforce, and why it cannot when it cannot. */
|
|
126
145
|
export type SandboxSupport =
|
|
127
146
|
| { supported: true; platform: string; backend: SandboxBackendId; executable: string }
|
|
@@ -141,6 +160,29 @@ type SandboxBackend = {
|
|
|
141
160
|
|
|
142
161
|
const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
143
162
|
|
|
163
|
+
const CREDENTIAL_LOCATIONS = [
|
|
164
|
+
".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube",
|
|
165
|
+
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
166
|
+
] as const;
|
|
167
|
+
|
|
168
|
+
// Executables, dynamic libraries and OS frameworks needed to start a child.
|
|
169
|
+
// This deliberately excludes the home directory, /etc, and credential stores.
|
|
170
|
+
// A process relying on /etc or /proc configuration (DNS, certificates, NSS)
|
|
171
|
+
// may not start or function under Linux outsideProject=off; callers must not
|
|
172
|
+
// silently remount these broad host trees to work around that failure.
|
|
173
|
+
const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Library", "/System/Cryptexes", "/Library/Apple", "/Library/Developer", "/opt/homebrew"];
|
|
174
|
+
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
175
|
+
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
176
|
+
|
|
177
|
+
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
178
|
+
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
179
|
+
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
180
|
+
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
181
|
+
if (configuredAgentDir) paths.push(join(configuredAgentDir.startsWith("~/")
|
|
182
|
+
? join(home, configuredAgentDir.slice(2)) : configuredAgentDir, "auth.json"));
|
|
183
|
+
return [...new Set(paths.map((path) => canonicalizePath(path, seams)))].sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
function currentPlatform(seams: SandboxSeams): string {
|
|
145
187
|
return (seams.platform ?? osPlatform)();
|
|
146
188
|
}
|
|
@@ -154,7 +196,19 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
154
196
|
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
155
197
|
const absolute = resolve(path);
|
|
156
198
|
try {
|
|
157
|
-
|
|
199
|
+
const resolved = canonicalize(absolute);
|
|
200
|
+
// APFS firmlinks are not resolved by realpath. Normalize the Data-volume
|
|
201
|
+
// alias only when both names demonstrably refer to the same inode.
|
|
202
|
+
const dataPrefix = "/System/Volumes/Data";
|
|
203
|
+
if (!seams.canonicalize && currentPlatform(seams) === "darwin" && resolved.startsWith(`${dataPrefix}/`)) {
|
|
204
|
+
const candidate = resolved.slice(dataPrefix.length);
|
|
205
|
+
try {
|
|
206
|
+
const source = statSync(resolved);
|
|
207
|
+
const alias = statSync(candidate);
|
|
208
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
209
|
+
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
210
|
+
}
|
|
211
|
+
return resolved;
|
|
158
212
|
} catch {
|
|
159
213
|
// Not created yet (or unreadable): canonicalize the parent instead.
|
|
160
214
|
}
|
|
@@ -179,7 +233,14 @@ function compile(
|
|
|
179
233
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
180
234
|
].sort();
|
|
181
235
|
|
|
182
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
writableRoot, denyWrite, home: policy.home,
|
|
238
|
+
...(policy.permissions && {
|
|
239
|
+
permissions: { ...policy.permissions },
|
|
240
|
+
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
241
|
+
runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
183
244
|
}
|
|
184
245
|
|
|
185
246
|
/**
|
|
@@ -198,6 +259,31 @@ function contains(root: string, target: string): boolean {
|
|
|
198
259
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
199
260
|
}
|
|
200
261
|
|
|
262
|
+
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
263
|
+
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function runtimeRoots(seams: SandboxSeams): string[] {
|
|
267
|
+
return [...new Set(READ_RUNTIME_ROOTS.map((path) => canonicalizePath(path, seams)))];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Decide read access using the same canonical path and capability precedence as writes. */
|
|
271
|
+
export function evaluateReadAccess(
|
|
272
|
+
target: string,
|
|
273
|
+
policy: CompiledSandboxWritePolicy,
|
|
274
|
+
seams: SandboxSeams = {},
|
|
275
|
+
): ReadAccessDecision {
|
|
276
|
+
const path = canonicalizePath(target, seams);
|
|
277
|
+
const permissions = policy.permissions;
|
|
278
|
+
if (!permissions) return { allowed: true, path };
|
|
279
|
+
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
280
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
281
|
+
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
282
|
+
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
283
|
+
: permissions.outsideProject;
|
|
284
|
+
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
285
|
+
}
|
|
286
|
+
|
|
201
287
|
/**
|
|
202
288
|
* Decide whether an in-process write to `target` is permitted by a compiled
|
|
203
289
|
* policy. This is the same containment rule the kernel backends enforce, for
|
|
@@ -209,7 +295,7 @@ export function evaluateWriteAccess(
|
|
|
209
295
|
seams: SandboxSeams = {},
|
|
210
296
|
): WriteAccessDecision {
|
|
211
297
|
const path = canonicalizePath(target, seams);
|
|
212
|
-
if (!contains(policy.writableRoot, path)) {
|
|
298
|
+
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
213
299
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
214
300
|
}
|
|
215
301
|
for (const denied of policy.denyWrite) {
|
|
@@ -217,6 +303,14 @@ export function evaluateWriteAccess(
|
|
|
217
303
|
return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
218
304
|
}
|
|
219
305
|
}
|
|
306
|
+
if (policy.permissions) {
|
|
307
|
+
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
308
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
309
|
+
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
310
|
+
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
311
|
+
: policy.permissions.outsideProject;
|
|
312
|
+
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
313
|
+
}
|
|
220
314
|
return { allowed: true, path };
|
|
221
315
|
}
|
|
222
316
|
|
|
@@ -225,13 +319,70 @@ function sbpl(path: string): string {
|
|
|
225
319
|
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
226
320
|
}
|
|
227
321
|
|
|
322
|
+
function validateRuntimeHome(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): void {
|
|
323
|
+
if (policy.permissions?.outsideProject !== "off") return;
|
|
324
|
+
const home = canonicalizePath(policy.home, seams);
|
|
325
|
+
if (RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
|
|
326
|
+
throw new Error("outsideProject=off cannot expose a home under a required system runtime root; move the home or use another permission mode.");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function protectedAncestors(paths: readonly string[]): string[] {
|
|
331
|
+
const parents = new Set<string>();
|
|
332
|
+
for (const path of paths) {
|
|
333
|
+
for (let parent = dirname(path); dirname(parent) !== parent; parent = dirname(parent)) parents.add(parent);
|
|
334
|
+
}
|
|
335
|
+
return [...parents].sort((a, b) => a.length - b.length);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
|
|
339
|
+
validateRuntimeHome(policy, seams);
|
|
340
|
+
const permissions = policy.permissions!;
|
|
341
|
+
const protectedPaths = [
|
|
342
|
+
...policy.denyWrite,
|
|
343
|
+
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
344
|
+
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
345
|
+
];
|
|
346
|
+
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
347
|
+
if (permissions.outsideProject === "off") {
|
|
348
|
+
rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
|
|
349
|
+
for (const root of runtimeRoots(seams)) {
|
|
350
|
+
rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (permissions.outsideProject === "read-write") rules.push("(allow file-write*)");
|
|
354
|
+
// Temporary host paths are not granted for off/read: doing so would expose
|
|
355
|
+
// other users' files in temp. /dev remains necessary for basic shell I/O.
|
|
356
|
+
for (const root of TEMP_ROOTS.filter((path) => permissions.outsideProject === "read-write" || path === "/dev")
|
|
357
|
+
.map((path) => canonicalizePath(path, seams))) {
|
|
358
|
+
rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
359
|
+
}
|
|
360
|
+
const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
|
|
361
|
+
if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
|
|
362
|
+
else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
363
|
+
if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
364
|
+
else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
|
|
365
|
+
};
|
|
366
|
+
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
367
|
+
// explicit denyWrite entries always override every write allowance.
|
|
368
|
+
scoped(policy.writableRoot, permissions.projectFiles);
|
|
369
|
+
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
370
|
+
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
371
|
+
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
372
|
+
// Protect the directory entries, not their contents: unrelated children can
|
|
373
|
+
// still be created, while renaming a parent cannot move a denied subtree.
|
|
374
|
+
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
375
|
+
if (!permissions.network) rules.push("(deny network*)");
|
|
376
|
+
return [...rules, ""].join("\n");
|
|
377
|
+
}
|
|
378
|
+
|
|
228
379
|
/** Build the macOS sandbox-exec wrapper and its SBPL profile. */
|
|
229
380
|
function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
|
|
230
381
|
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
231
382
|
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
232
383
|
const policy = compile(args.policy, seams, false);
|
|
233
384
|
|
|
234
|
-
const profile = [
|
|
385
|
+
const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
|
|
235
386
|
"(version 1)",
|
|
236
387
|
"(allow default)", // permissive base: reads, exec, network
|
|
237
388
|
"(deny file-write*)", // ...then deny all writes...
|
|
@@ -344,6 +495,7 @@ function buildLinuxSandboxCommand(
|
|
|
344
495
|
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
345
496
|
// widening the writable root.
|
|
346
497
|
const policy = compile(args.policy, seams, true);
|
|
498
|
+
if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
|
|
347
499
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
348
500
|
const denyBinds = policy.denyWrite.flatMap((path) => {
|
|
349
501
|
const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
|
|
@@ -368,6 +520,113 @@ function buildLinuxSandboxCommand(
|
|
|
368
520
|
};
|
|
369
521
|
}
|
|
370
522
|
|
|
523
|
+
function buildLinuxPermissionCommand(
|
|
524
|
+
bwrap: string,
|
|
525
|
+
args: SandboxCommandArgs,
|
|
526
|
+
policy: CompiledSandboxWritePolicy,
|
|
527
|
+
seams: SandboxSeams,
|
|
528
|
+
): SandboxCommand {
|
|
529
|
+
const permissions = policy.permissions!;
|
|
530
|
+
validateRuntimeHome(policy, seams);
|
|
531
|
+
const project = policy.writableRoot;
|
|
532
|
+
const credentials = policy.credentialPaths ?? [];
|
|
533
|
+
const writableProject = permissions.projectFiles === "read-write";
|
|
534
|
+
const overlappingCredentials = credentials.filter((path) => contains(project, path));
|
|
535
|
+
if (credentials.some((path) => contains(path, project)) &&
|
|
536
|
+
permissions.projectFiles !== permissions.storedCredentials) {
|
|
537
|
+
throw new Error("Linux bubblewrap cannot apply differing project and credential permissions when a credential directory contains the project.");
|
|
538
|
+
}
|
|
539
|
+
if (writableProject && policy.denyWrite.some((path) => contains(path, project))) {
|
|
540
|
+
throw new Error("Linux bubblewrap cannot make a project writable inside a write-denied directory.");
|
|
541
|
+
}
|
|
542
|
+
// Protected leaves and their writable ancestors become mount points below.
|
|
543
|
+
// Linux refuses renaming mount points, preventing ancestor replacement.
|
|
544
|
+
if (permissions.outsideProject === "read-write" && (
|
|
545
|
+
permissions.projectFiles !== "read-write" || permissions.storedCredentials !== "read-write" ||
|
|
546
|
+
policy.denyWrite.length > 0
|
|
547
|
+
)) {
|
|
548
|
+
throw new Error("Linux bubblewrap cannot enforce restricted project/credential/denyWrite paths under a writable outsideProject mount.");
|
|
549
|
+
}
|
|
550
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "read-write" &&
|
|
551
|
+
credentials.some((path) => !contains(project, path))) {
|
|
552
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only outsideProject root.");
|
|
553
|
+
}
|
|
554
|
+
if (permissions.projectFiles === "read" && permissions.storedCredentials === "read-write" &&
|
|
555
|
+
overlappingCredentials.length > 0) {
|
|
556
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only project mount.");
|
|
557
|
+
}
|
|
558
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "off") {
|
|
559
|
+
throw new Error("Linux bubblewrap cannot hide stored credentials in a read-only whole-root bind.");
|
|
560
|
+
}
|
|
561
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "off" &&
|
|
562
|
+
credentials.some((path) => contains(project, path) && permissions.projectFiles !== "off")) {
|
|
563
|
+
throw new Error("Linux bubblewrap cannot hide credentials inside a visible read-only project.");
|
|
564
|
+
}
|
|
565
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
566
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), project))) {
|
|
567
|
+
throw new Error("Linux bubblewrap cannot hide a project nested under a required system runtime bind.");
|
|
568
|
+
}
|
|
569
|
+
if (permissions.outsideProject === "off" && credentials.some((path) =>
|
|
570
|
+
!contains(project, path) &&
|
|
571
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), path)) &&
|
|
572
|
+
permissions.storedCredentials === "off")) {
|
|
573
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a required system runtime bind.");
|
|
574
|
+
}
|
|
575
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read-write" &&
|
|
576
|
+
credentials.some((path) => !contains(project, path))) {
|
|
577
|
+
throw new Error("Linux bubblewrap cannot create or safely bind writable credential stores outside a hidden root.");
|
|
578
|
+
}
|
|
579
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
580
|
+
permissions.storedCredentials !== "off" && credentials.some((path) => contains(project, path))) {
|
|
581
|
+
throw new Error("Linux bubblewrap cannot expose credentials inside a hidden project without exposing the project.");
|
|
582
|
+
}
|
|
583
|
+
if (permissions.outsideProject !== "off" && permissions.projectFiles === "off") {
|
|
584
|
+
throw new Error("Linux bubblewrap cannot hide a project inside a visible outsideProject root.");
|
|
585
|
+
}
|
|
586
|
+
const mounts: string[] = permissions.outsideProject === "off" ? ["--tmpfs", "/"]
|
|
587
|
+
: [permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/", "/"];
|
|
588
|
+
if (permissions.outsideProject === "off") {
|
|
589
|
+
// Bounded system executable/library roots only. /tmp is private, not a
|
|
590
|
+
// host bind: otherwise outsideProject=off would expose user temp data.
|
|
591
|
+
for (const root of RUNTIME_ROOTS) {
|
|
592
|
+
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
593
|
+
}
|
|
594
|
+
mounts.push("--tmpfs", "/tmp");
|
|
595
|
+
} else {
|
|
596
|
+
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
597
|
+
}
|
|
598
|
+
mounts.push("--dev", "/dev");
|
|
599
|
+
if (permissions.projectFiles !== "off") {
|
|
600
|
+
mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
|
|
601
|
+
}
|
|
602
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
603
|
+
for (const path of credentials) {
|
|
604
|
+
if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const path of policy.runtimeWrite ?? []) {
|
|
608
|
+
if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
|
|
609
|
+
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
610
|
+
}
|
|
611
|
+
mounts.push("--bind", path, path);
|
|
612
|
+
}
|
|
613
|
+
const protectedPaths = [...policy.denyWrite,
|
|
614
|
+
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
615
|
+
if (writableProject) {
|
|
616
|
+
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
617
|
+
const leaves = protectedPaths.filter((path) => contains(project, path) && materialize(path));
|
|
618
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(project, path) && path !== project)) {
|
|
619
|
+
mounts.push("--bind", parent, parent);
|
|
620
|
+
}
|
|
621
|
+
for (const path of leaves) mounts.push("--ro-bind", path, path);
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
file: bwrap,
|
|
625
|
+
fileArgs: [...mounts, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
626
|
+
"--", args.execPath, ...args.execArgs],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
371
630
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
372
631
|
const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
|
|
373
632
|
if (!bwrap) return undefined;
|
|
@@ -436,10 +695,16 @@ export function maybeBuildSandboxCommand(
|
|
|
436
695
|
request: SandboxRequest,
|
|
437
696
|
seams: SandboxSeams = {},
|
|
438
697
|
): SandboxCommand | undefined {
|
|
698
|
+
if (args.policy.permissions?.commands === false) {
|
|
699
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
700
|
+
}
|
|
439
701
|
if (!request.sandboxEnabled) return undefined;
|
|
440
702
|
|
|
441
703
|
const backend = selectedSandboxBackend(seams);
|
|
442
704
|
if (!backend) {
|
|
705
|
+
if (args.policy.permissions) {
|
|
706
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
707
|
+
}
|
|
443
708
|
if (request.explicitSandbox) {
|
|
444
709
|
const reason = sandboxUnavailableMessage(seams);
|
|
445
710
|
throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
|
|
@@ -458,5 +723,12 @@ export function buildSandboxCommand(
|
|
|
458
723
|
args: SandboxCommandArgs,
|
|
459
724
|
seams: SandboxSeams = {},
|
|
460
725
|
): SandboxCommand {
|
|
461
|
-
|
|
726
|
+
if (args.policy.permissions?.commands === false) {
|
|
727
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
728
|
+
}
|
|
729
|
+
const backend = selectedSandboxBackend(seams);
|
|
730
|
+
if (!backend && args.policy.permissions) {
|
|
731
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
732
|
+
}
|
|
733
|
+
return (backend ?? macOSSandboxBackend).buildCommand(args, seams);
|
|
462
734
|
}
|
package/tools.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
18
18
|
import { readMeta, listMetas, effectiveStatus, isFinalResultStatus, type RunMeta, type RunStatus } from "./registry.ts";
|
|
19
19
|
import { parseRun, tailLog, formatSubagentOutputBody } from "./parse.ts";
|
|
20
20
|
import { buildSubagentResultText } from "./finalization.ts";
|
|
21
|
+
import { failureSummary, prependFailureSummary } from "./failures.ts";
|
|
21
22
|
import { formatOrphanedResult } from "./lifecycle.ts";
|
|
22
23
|
import { stopRun } from "./stop.ts";
|
|
23
24
|
import { fmtElapsed, fmtSpend } from "./widget.ts";
|
|
@@ -258,6 +259,10 @@ export function subagentListTool(Type: TypeModule): ToolDefinition {
|
|
|
258
259
|
statusOf: effectiveStatus,
|
|
259
260
|
usageById: (id: string) => parseRun(id).usage,
|
|
260
261
|
healthById,
|
|
262
|
+
failureById: (id: string) => {
|
|
263
|
+
const meta = metas.find((m) => m.id === id);
|
|
264
|
+
return meta ? failureSummary(id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned") : "";
|
|
265
|
+
},
|
|
261
266
|
}));
|
|
262
267
|
},
|
|
263
268
|
} as ToolDefinition;
|
|
@@ -300,7 +305,7 @@ export function subagentOutputTool(Type: TypeModule): ToolDefinition {
|
|
|
300
305
|
// Health diagnostics for orphaned/lost/degraded only (#67). Healthy/quiet
|
|
301
306
|
// stays on today's body. Independent of meta.callback.
|
|
302
307
|
const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
|
|
303
|
-
return text(appendHealthDiagnostic(body, healthLine));
|
|
308
|
+
return text(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd, st !== "running" && st !== "orphaned")));
|
|
304
309
|
},
|
|
305
310
|
} as ToolDefinition;
|
|
306
311
|
}
|
|
@@ -342,9 +347,9 @@ export function subagentResultTool(Type: TypeModule): ToolDefinition {
|
|
|
342
347
|
const rawTail = tailLog(p.id, 40);
|
|
343
348
|
const body = `${head}\n${formatOrphanedResult(r, rawTail)}`;
|
|
344
349
|
const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
|
|
345
|
-
return subagentResultText(appendHealthDiagnostic(body, healthLine));
|
|
350
|
+
return subagentResultText(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd)));
|
|
346
351
|
}
|
|
347
|
-
return subagentResultText(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll
|
|
352
|
+
return subagentResultText(prependFailureSummary(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`, failureSummary(p.id, meta.cwd)));
|
|
348
353
|
}
|
|
349
354
|
// Lifecycle-aware body (complete-stream authority + diagnostics).
|
|
350
355
|
// Lost runs go through formatLostResult inside formatSubagentResult (#65).
|
|
@@ -356,7 +361,7 @@ export function subagentResultTool(Type: TypeModule): ToolDefinition {
|
|
|
356
361
|
// Append degraded/lost health facts when present; completed/failed
|
|
357
362
|
// happy paths stay quiet when observation is non-actionable.
|
|
358
363
|
const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
|
|
359
|
-
return subagentResultText(appendHealthDiagnostic(body, healthLine));
|
|
364
|
+
return subagentResultText(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd, true)));
|
|
360
365
|
},
|
|
361
366
|
} as ToolDefinition;
|
|
362
367
|
}
|