pi-better-background-tasks 0.2.16 → 0.2.18
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 +40 -0
- package/package.json +1 -1
- package/src/conditions.ts +7 -0
- package/src/failures.ts +99 -0
- package/src/maintenance.ts +2 -0
- package/src/navigator-provider.ts +9 -3
- package/src/runtime.ts +76 -21
- package/src/sandbox.ts +74 -14
- package/src/shared-callback-batcher.ts +42 -16
- package/src/shared-failure-observations.ts +205 -0
- package/src/shared-sandbox-core.ts +309 -14
- package/src/tools.ts +30 -9
|
@@ -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. */
|
|
@@ -67,6 +79,8 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
67
79
|
/** Where the macOS backend writes its generated SBPL profile. */
|
|
68
80
|
profilePath: string;
|
|
69
81
|
policy: SandboxWritePolicy;
|
|
82
|
+
/** Fixed internal helper only: expose its executable in a hidden Linux root. */
|
|
83
|
+
internalHelperExecutable?: boolean;
|
|
70
84
|
};
|
|
71
85
|
|
|
72
86
|
/** The wrapper command to spawn: the backend executable and its full argv. */
|
|
@@ -109,6 +123,9 @@ export type CompiledSandboxWritePolicy = {
|
|
|
109
123
|
readonly writableRoot: string;
|
|
110
124
|
readonly denyWrite: readonly string[];
|
|
111
125
|
readonly home: string;
|
|
126
|
+
readonly permissions?: SandboxPermissions;
|
|
127
|
+
readonly credentialPaths?: readonly string[];
|
|
128
|
+
readonly runtimeWrite?: readonly string[];
|
|
112
129
|
};
|
|
113
130
|
|
|
114
131
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -117,11 +134,15 @@ export type WriteAccessDecision =
|
|
|
117
134
|
| {
|
|
118
135
|
allowed: false;
|
|
119
136
|
path: string;
|
|
120
|
-
reason: "outside-writable-root" | "write-denied";
|
|
137
|
+
reason: "outside-writable-root" | "write-denied" | "permission-denied";
|
|
121
138
|
/** The compiled deny entry that matched, for `write-denied` only. */
|
|
122
139
|
deniedBy?: string;
|
|
123
140
|
};
|
|
124
141
|
|
|
142
|
+
export type ReadAccessDecision =
|
|
143
|
+
| { allowed: true; path: string }
|
|
144
|
+
| { allowed: false; path: string; reason: "read-denied" };
|
|
145
|
+
|
|
125
146
|
/** What the current platform can enforce, and why it cannot when it cannot. */
|
|
126
147
|
export type SandboxSupport =
|
|
127
148
|
| { supported: true; platform: string; backend: SandboxBackendId; executable: string }
|
|
@@ -141,6 +162,29 @@ type SandboxBackend = {
|
|
|
141
162
|
|
|
142
163
|
const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
143
164
|
|
|
165
|
+
const CREDENTIAL_LOCATIONS = [
|
|
166
|
+
".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube",
|
|
167
|
+
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
168
|
+
] as const;
|
|
169
|
+
|
|
170
|
+
// Executables, dynamic libraries and OS frameworks needed to start a child.
|
|
171
|
+
// This deliberately excludes the home directory, /etc, and credential stores.
|
|
172
|
+
// A process relying on /etc or /proc configuration (DNS, certificates, NSS)
|
|
173
|
+
// may not start or function under Linux outsideProject=off; callers must not
|
|
174
|
+
// silently remount these broad host trees to work around that failure.
|
|
175
|
+
const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Library", "/System/Cryptexes", "/Library/Apple", "/Library/Developer", "/opt/homebrew"];
|
|
176
|
+
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
177
|
+
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
178
|
+
|
|
179
|
+
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
180
|
+
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
181
|
+
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
182
|
+
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
183
|
+
if (configuredAgentDir) paths.push(join(configuredAgentDir.startsWith("~/")
|
|
184
|
+
? join(home, configuredAgentDir.slice(2)) : configuredAgentDir, "auth.json"));
|
|
185
|
+
return [...new Set(paths.map((path) => canonicalizePath(path, seams)))].sort();
|
|
186
|
+
}
|
|
187
|
+
|
|
144
188
|
function currentPlatform(seams: SandboxSeams): string {
|
|
145
189
|
return (seams.platform ?? osPlatform)();
|
|
146
190
|
}
|
|
@@ -154,7 +198,19 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
154
198
|
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
155
199
|
const absolute = resolve(path);
|
|
156
200
|
try {
|
|
157
|
-
|
|
201
|
+
const resolved = canonicalize(absolute);
|
|
202
|
+
// APFS firmlinks are not resolved by realpath. Normalize the Data-volume
|
|
203
|
+
// alias only when both names demonstrably refer to the same inode.
|
|
204
|
+
const dataPrefix = "/System/Volumes/Data";
|
|
205
|
+
if (!seams.canonicalize && currentPlatform(seams) === "darwin" && resolved.startsWith(`${dataPrefix}/`)) {
|
|
206
|
+
const candidate = resolved.slice(dataPrefix.length);
|
|
207
|
+
try {
|
|
208
|
+
const source = statSync(resolved);
|
|
209
|
+
const alias = statSync(candidate);
|
|
210
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
211
|
+
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
212
|
+
}
|
|
213
|
+
return resolved;
|
|
158
214
|
} catch {
|
|
159
215
|
// Not created yet (or unreadable): canonicalize the parent instead.
|
|
160
216
|
}
|
|
@@ -179,7 +235,14 @@ function compile(
|
|
|
179
235
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
180
236
|
].sort();
|
|
181
237
|
|
|
182
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
writableRoot, denyWrite, home: policy.home,
|
|
240
|
+
...(policy.permissions && {
|
|
241
|
+
permissions: { ...policy.permissions },
|
|
242
|
+
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
243
|
+
runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
183
246
|
}
|
|
184
247
|
|
|
185
248
|
/**
|
|
@@ -198,6 +261,31 @@ function contains(root: string, target: string): boolean {
|
|
|
198
261
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
199
262
|
}
|
|
200
263
|
|
|
264
|
+
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
265
|
+
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function runtimeRoots(seams: SandboxSeams): string[] {
|
|
269
|
+
return [...new Set(READ_RUNTIME_ROOTS.map((path) => canonicalizePath(path, seams)))];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Decide read access using the same canonical path and capability precedence as writes. */
|
|
273
|
+
export function evaluateReadAccess(
|
|
274
|
+
target: string,
|
|
275
|
+
policy: CompiledSandboxWritePolicy,
|
|
276
|
+
seams: SandboxSeams = {},
|
|
277
|
+
): ReadAccessDecision {
|
|
278
|
+
const path = canonicalizePath(target, seams);
|
|
279
|
+
const permissions = policy.permissions;
|
|
280
|
+
if (!permissions) return { allowed: true, path };
|
|
281
|
+
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
282
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
283
|
+
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
284
|
+
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
285
|
+
: permissions.outsideProject;
|
|
286
|
+
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
287
|
+
}
|
|
288
|
+
|
|
201
289
|
/**
|
|
202
290
|
* Decide whether an in-process write to `target` is permitted by a compiled
|
|
203
291
|
* policy. This is the same containment rule the kernel backends enforce, for
|
|
@@ -209,7 +297,7 @@ export function evaluateWriteAccess(
|
|
|
209
297
|
seams: SandboxSeams = {},
|
|
210
298
|
): WriteAccessDecision {
|
|
211
299
|
const path = canonicalizePath(target, seams);
|
|
212
|
-
if (!contains(policy.writableRoot, path)) {
|
|
300
|
+
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
213
301
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
214
302
|
}
|
|
215
303
|
for (const denied of policy.denyWrite) {
|
|
@@ -217,6 +305,14 @@ export function evaluateWriteAccess(
|
|
|
217
305
|
return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
218
306
|
}
|
|
219
307
|
}
|
|
308
|
+
if (policy.permissions) {
|
|
309
|
+
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
310
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
311
|
+
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
312
|
+
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
313
|
+
: policy.permissions.outsideProject;
|
|
314
|
+
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
315
|
+
}
|
|
220
316
|
return { allowed: true, path };
|
|
221
317
|
}
|
|
222
318
|
|
|
@@ -225,13 +321,70 @@ function sbpl(path: string): string {
|
|
|
225
321
|
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
226
322
|
}
|
|
227
323
|
|
|
324
|
+
function validateRuntimeHome(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): void {
|
|
325
|
+
if (policy.permissions?.outsideProject !== "off") return;
|
|
326
|
+
const home = canonicalizePath(policy.home, seams);
|
|
327
|
+
if (RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
|
|
328
|
+
throw new Error("outsideProject=off cannot expose a home under a required system runtime root; move the home or use another permission mode.");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function protectedAncestors(paths: readonly string[]): string[] {
|
|
333
|
+
const parents = new Set<string>();
|
|
334
|
+
for (const path of paths) {
|
|
335
|
+
for (let parent = dirname(path); dirname(parent) !== parent; parent = dirname(parent)) parents.add(parent);
|
|
336
|
+
}
|
|
337
|
+
return [...parents].sort((a, b) => a.length - b.length);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
|
|
341
|
+
validateRuntimeHome(policy, seams);
|
|
342
|
+
const permissions = policy.permissions!;
|
|
343
|
+
const protectedPaths = [
|
|
344
|
+
...policy.denyWrite,
|
|
345
|
+
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
346
|
+
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
347
|
+
];
|
|
348
|
+
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
349
|
+
if (permissions.outsideProject === "off") {
|
|
350
|
+
rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
|
|
351
|
+
for (const root of runtimeRoots(seams)) {
|
|
352
|
+
rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (permissions.outsideProject === "read-write") rules.push("(allow file-write*)");
|
|
356
|
+
// Temporary host paths are not granted for off/read: doing so would expose
|
|
357
|
+
// other users' files in temp. /dev remains necessary for basic shell I/O.
|
|
358
|
+
for (const root of TEMP_ROOTS.filter((path) => permissions.outsideProject === "read-write" || path === "/dev")
|
|
359
|
+
.map((path) => canonicalizePath(path, seams))) {
|
|
360
|
+
rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
361
|
+
}
|
|
362
|
+
const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
|
|
363
|
+
if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
|
|
364
|
+
else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
365
|
+
if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
366
|
+
else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
|
|
367
|
+
};
|
|
368
|
+
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
369
|
+
// explicit denyWrite entries always override every write allowance.
|
|
370
|
+
scoped(policy.writableRoot, permissions.projectFiles);
|
|
371
|
+
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
372
|
+
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
373
|
+
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
374
|
+
// Protect the directory entries, not their contents: unrelated children can
|
|
375
|
+
// still be created, while renaming a parent cannot move a denied subtree.
|
|
376
|
+
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
377
|
+
if (!permissions.network) rules.push("(deny network*)");
|
|
378
|
+
return [...rules, ""].join("\n");
|
|
379
|
+
}
|
|
380
|
+
|
|
228
381
|
/** Build the macOS sandbox-exec wrapper and its SBPL profile. */
|
|
229
382
|
function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
|
|
230
383
|
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
231
384
|
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
232
385
|
const policy = compile(args.policy, seams, false);
|
|
233
386
|
|
|
234
|
-
const profile = [
|
|
387
|
+
const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
|
|
235
388
|
"(version 1)",
|
|
236
389
|
"(allow default)", // permissive base: reads, exec, network
|
|
237
390
|
"(deny file-write*)", // ...then deny all writes...
|
|
@@ -259,7 +412,21 @@ const macOSSandboxBackend: SandboxBackend = {
|
|
|
259
412
|
buildCommand: buildMacOSSandboxCommand,
|
|
260
413
|
};
|
|
261
414
|
|
|
262
|
-
/** Resolve
|
|
415
|
+
/** Resolve only a root-owned system executable; never inspect task PATH. */
|
|
416
|
+
function systemSandboxExecutable(name: string): string | undefined {
|
|
417
|
+
// Never resolve the host-side confinement launcher through task-influenced PATH.
|
|
418
|
+
for (const directory of ["/usr/bin", "/bin"]) {
|
|
419
|
+
const candidate = join(directory, name);
|
|
420
|
+
try {
|
|
421
|
+
const info = statSync(candidate);
|
|
422
|
+
if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
|
|
423
|
+
accessSync(candidate, constants.X_OK);
|
|
424
|
+
return candidate;
|
|
425
|
+
} catch { /* Try the next system location. */ }
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
263
430
|
export function executableFromPath(name: string): string | undefined {
|
|
264
431
|
const path = process.env.PATH;
|
|
265
432
|
if (!path) return undefined;
|
|
@@ -344,6 +511,7 @@ function buildLinuxSandboxCommand(
|
|
|
344
511
|
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
345
512
|
// widening the writable root.
|
|
346
513
|
const policy = compile(args.policy, seams, true);
|
|
514
|
+
if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
|
|
347
515
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
348
516
|
const denyBinds = policy.denyWrite.flatMap((path) => {
|
|
349
517
|
const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
|
|
@@ -368,8 +536,122 @@ function buildLinuxSandboxCommand(
|
|
|
368
536
|
};
|
|
369
537
|
}
|
|
370
538
|
|
|
539
|
+
function buildLinuxPermissionCommand(
|
|
540
|
+
bwrap: string,
|
|
541
|
+
args: SandboxCommandArgs,
|
|
542
|
+
policy: CompiledSandboxWritePolicy,
|
|
543
|
+
seams: SandboxSeams,
|
|
544
|
+
): SandboxCommand {
|
|
545
|
+
const permissions = policy.permissions!;
|
|
546
|
+
validateRuntimeHome(policy, seams);
|
|
547
|
+
const project = policy.writableRoot;
|
|
548
|
+
const credentials = policy.credentialPaths ?? [];
|
|
549
|
+
const writableProject = permissions.projectFiles === "read-write";
|
|
550
|
+
const overlappingCredentials = credentials.filter((path) => contains(project, path));
|
|
551
|
+
if (credentials.some((path) => contains(path, project)) &&
|
|
552
|
+
permissions.projectFiles !== permissions.storedCredentials) {
|
|
553
|
+
throw new Error("Linux bubblewrap cannot apply differing project and credential permissions when a credential directory contains the project.");
|
|
554
|
+
}
|
|
555
|
+
if (writableProject && policy.denyWrite.some((path) => contains(path, project))) {
|
|
556
|
+
throw new Error("Linux bubblewrap cannot make a project writable inside a write-denied directory.");
|
|
557
|
+
}
|
|
558
|
+
// Protected leaves and their writable ancestors become mount points below.
|
|
559
|
+
// Linux refuses renaming mount points, preventing ancestor replacement.
|
|
560
|
+
if (permissions.outsideProject === "read-write" && (
|
|
561
|
+
permissions.projectFiles !== "read-write" || permissions.storedCredentials !== "read-write" ||
|
|
562
|
+
policy.denyWrite.length > 0
|
|
563
|
+
)) {
|
|
564
|
+
throw new Error("Linux bubblewrap cannot enforce restricted project/credential/denyWrite paths under a writable outsideProject mount.");
|
|
565
|
+
}
|
|
566
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "read-write" &&
|
|
567
|
+
credentials.some((path) => !contains(project, path))) {
|
|
568
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only outsideProject root.");
|
|
569
|
+
}
|
|
570
|
+
if (permissions.projectFiles === "read" && permissions.storedCredentials === "read-write" &&
|
|
571
|
+
overlappingCredentials.length > 0) {
|
|
572
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only project mount.");
|
|
573
|
+
}
|
|
574
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "off") {
|
|
575
|
+
throw new Error("Linux bubblewrap cannot hide stored credentials in a read-only whole-root bind.");
|
|
576
|
+
}
|
|
577
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "off" &&
|
|
578
|
+
credentials.some((path) => contains(project, path) && permissions.projectFiles !== "off")) {
|
|
579
|
+
throw new Error("Linux bubblewrap cannot hide credentials inside a visible read-only project.");
|
|
580
|
+
}
|
|
581
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
582
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), project))) {
|
|
583
|
+
throw new Error("Linux bubblewrap cannot hide a project nested under a required system runtime bind.");
|
|
584
|
+
}
|
|
585
|
+
if (permissions.outsideProject === "off" && credentials.some((path) =>
|
|
586
|
+
!contains(project, path) &&
|
|
587
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), path)) &&
|
|
588
|
+
permissions.storedCredentials === "off")) {
|
|
589
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a required system runtime bind.");
|
|
590
|
+
}
|
|
591
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read-write" &&
|
|
592
|
+
credentials.some((path) => !contains(project, path))) {
|
|
593
|
+
throw new Error("Linux bubblewrap cannot create or safely bind writable credential stores outside a hidden root.");
|
|
594
|
+
}
|
|
595
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
596
|
+
permissions.storedCredentials !== "off" && credentials.some((path) => contains(project, path))) {
|
|
597
|
+
throw new Error("Linux bubblewrap cannot expose credentials inside a hidden project without exposing the project.");
|
|
598
|
+
}
|
|
599
|
+
if (permissions.outsideProject !== "off" && permissions.projectFiles === "off") {
|
|
600
|
+
throw new Error("Linux bubblewrap cannot hide a project inside a visible outsideProject root.");
|
|
601
|
+
}
|
|
602
|
+
const mounts: string[] = permissions.outsideProject === "off" ? ["--tmpfs", "/"]
|
|
603
|
+
: [permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/", "/"];
|
|
604
|
+
if (permissions.outsideProject === "off") {
|
|
605
|
+
// Bounded system executable/library roots only. /tmp is private, not a
|
|
606
|
+
// host bind: otherwise outsideProject=off would expose user temp data.
|
|
607
|
+
for (const root of RUNTIME_ROOTS) {
|
|
608
|
+
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
609
|
+
}
|
|
610
|
+
mounts.push("--tmpfs", "/tmp");
|
|
611
|
+
if (args.internalHelperExecutable) {
|
|
612
|
+
const executable = canonicalizePath(args.execPath, seams);
|
|
613
|
+
if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
|
|
614
|
+
mounts.push("--ro-bind", executable, executable);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
619
|
+
}
|
|
620
|
+
mounts.push("--dev", "/dev");
|
|
621
|
+
if (permissions.projectFiles !== "off") {
|
|
622
|
+
mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
|
|
623
|
+
}
|
|
624
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
625
|
+
for (const path of credentials) {
|
|
626
|
+
if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const path of policy.runtimeWrite ?? []) {
|
|
630
|
+
if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
|
|
631
|
+
policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
|
|
632
|
+
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
633
|
+
}
|
|
634
|
+
mounts.push("--bind", path, path);
|
|
635
|
+
}
|
|
636
|
+
const protectedPaths = [...policy.denyWrite,
|
|
637
|
+
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
638
|
+
for (const writableRoot of [...(writableProject ? [project] : []), ...(policy.runtimeWrite ?? [])]) {
|
|
639
|
+
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
640
|
+
const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
|
|
641
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
|
|
642
|
+
mounts.push("--bind", parent, parent);
|
|
643
|
+
}
|
|
644
|
+
for (const path of leaves) mounts.push("--ro-bind", path, path);
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
file: bwrap,
|
|
648
|
+
fileArgs: [...mounts, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
649
|
+
"--", args.execPath, ...args.execArgs],
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
371
653
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
372
|
-
const bwrap = (seams.lookupExecutable ??
|
|
654
|
+
const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
|
|
373
655
|
if (!bwrap) return undefined;
|
|
374
656
|
return {
|
|
375
657
|
id: "linux-bubblewrap",
|
|
@@ -392,7 +674,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
|
|
|
392
674
|
*/
|
|
393
675
|
function unavailableMessage(platform: string): string {
|
|
394
676
|
if (platform === "linux") {
|
|
395
|
-
return "Linux sandbox requires executable bubblewrap (bwrap)
|
|
677
|
+
return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
|
|
396
678
|
}
|
|
397
679
|
if (platform === "darwin") {
|
|
398
680
|
return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
|
|
@@ -436,10 +718,16 @@ export function maybeBuildSandboxCommand(
|
|
|
436
718
|
request: SandboxRequest,
|
|
437
719
|
seams: SandboxSeams = {},
|
|
438
720
|
): SandboxCommand | undefined {
|
|
721
|
+
if (args.policy.permissions?.commands === false) {
|
|
722
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
723
|
+
}
|
|
439
724
|
if (!request.sandboxEnabled) return undefined;
|
|
440
725
|
|
|
441
726
|
const backend = selectedSandboxBackend(seams);
|
|
442
727
|
if (!backend) {
|
|
728
|
+
if (args.policy.permissions) {
|
|
729
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
730
|
+
}
|
|
443
731
|
if (request.explicitSandbox) {
|
|
444
732
|
const reason = sandboxUnavailableMessage(seams);
|
|
445
733
|
throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
|
|
@@ -458,5 +746,12 @@ export function buildSandboxCommand(
|
|
|
458
746
|
args: SandboxCommandArgs,
|
|
459
747
|
seams: SandboxSeams = {},
|
|
460
748
|
): SandboxCommand {
|
|
461
|
-
|
|
749
|
+
if (args.policy.permissions?.commands === false) {
|
|
750
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
751
|
+
}
|
|
752
|
+
const backend = selectedSandboxBackend(seams);
|
|
753
|
+
if (!backend && args.policy.permissions) {
|
|
754
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
755
|
+
}
|
|
756
|
+
return (backend ?? macOSSandboxBackend).buildCommand(args, seams);
|
|
462
757
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { failurePath, failureSummary } from "./failures.js";
|
|
4
|
+
import { readFailureState } from "./shared-failure-observations.js";
|
|
3
5
|
import { readLog } from "./logs.js";
|
|
4
6
|
import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
5
7
|
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
6
|
-
import {
|
|
8
|
+
import { listMetas, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
7
9
|
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
8
10
|
import { runTaskMaintenance } from "./maintenance.js";
|
|
9
11
|
import { ForegroundSandboxBlockedError } from "./sandbox.js";
|
|
10
12
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
11
13
|
import { isTerminalStatus } from "./types.js";
|
|
12
14
|
|
|
15
|
+
const JsonPathSchema = Type.String({
|
|
16
|
+
pattern: "^\\$",
|
|
17
|
+
description: "Root-prefixed JSON path, e.g. $.status, $.terminalFailure, or $.steps[0].status. Bare keys and wildcards are unsupported.",
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
const ConditionSchema = Type.Union([
|
|
14
21
|
Type.Object({ type: Type.Literal("exit_code"), equals: Type.Number() }),
|
|
15
22
|
Type.Object({ type: Type.Literal("stdout_contains"), value: Type.String() }),
|
|
16
23
|
Type.Object({ type: Type.Literal("stderr_contains"), value: Type.String() }),
|
|
17
|
-
Type.Object({ type: Type.Literal("json_path_equals"), path:
|
|
18
|
-
Type.Object({ type: Type.Literal("json_path_exists"), path:
|
|
24
|
+
Type.Object({ type: Type.Literal("json_path_equals"), path: JsonPathSchema, value: Type.Any() }),
|
|
25
|
+
Type.Object({ type: Type.Literal("json_path_exists"), path: JsonPathSchema }),
|
|
19
26
|
]);
|
|
20
27
|
|
|
21
28
|
const SshSchema = Type.Object({
|
|
@@ -115,7 +122,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
115
122
|
|
|
116
123
|
pi.on("session_start", async (_event, ctx) => {
|
|
117
124
|
activeSession = getCallbackOrigin(ctx);
|
|
118
|
-
for (const meta of
|
|
125
|
+
for (const meta of listMetasForOrigin(activeSession)) {
|
|
126
|
+
if (meta.status === "running" || (meta.callback !== false && !meta.callbackSentAt && !meta.callbackSuppressedAt)) {
|
|
127
|
+
resumeRunningTask(pi, meta, getActiveSession);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
119
130
|
runTaskMaintenance({ activeOrigin: activeSession });
|
|
120
131
|
});
|
|
121
132
|
pi.on("session_before_switch", () => {
|
|
@@ -326,23 +337,32 @@ export function formatLaunch(meta: BackgroundTaskMeta): string {
|
|
|
326
337
|
: "";
|
|
327
338
|
const setup = meta.remote?.bootstrapMessage ? ` Remote setup: ${meta.remote.bootstrapMessage}` : "";
|
|
328
339
|
const warning = meta.remote?.warning ? ` Warning: ${meta.remote.warning}` : "";
|
|
329
|
-
|
|
340
|
+
const failure = failureSummary(meta.id);
|
|
341
|
+
return `${failure ? `${failure}\n` : ""}Started background ${meta.kind} ${label}. Status: ${meta.status}.${remote}${setup}${warning} Log: ${meta.logPath}`;
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
function formatList(metas: BackgroundTaskMeta[]): string {
|
|
333
345
|
if (metas.length === 0) return "No background tasks found.";
|
|
334
346
|
return metas.map((meta) => {
|
|
347
|
+
const failure = failureSummary(meta.id);
|
|
335
348
|
const age = formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
336
349
|
const label = meta.name ? `${meta.name} ` : "";
|
|
337
350
|
const remote = meta.ssh ? ` ${meta.ssh.target}${meta.remote?.session ? ` ${meta.remote.session}` : ""}` : "";
|
|
338
|
-
return `${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
|
|
351
|
+
return `${failure ? `${failure}\n` : ""}${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
|
|
339
352
|
}).join("\n");
|
|
340
353
|
}
|
|
341
354
|
|
|
342
355
|
function formatStatus(meta: BackgroundTaskMeta | undefined, id?: string, options: { verbose?: boolean } = {}): string {
|
|
343
356
|
if (!meta) return `No background task found${id ? ` for id ${id}` : ""}.`;
|
|
344
|
-
|
|
345
|
-
|
|
357
|
+
const failure = failureSummary(meta.id);
|
|
358
|
+
if (!options.verbose) return [failure, formatCompactStatus(meta)].filter(Boolean).join("\n");
|
|
359
|
+
const state = readFailureState(failurePath(meta.id));
|
|
360
|
+
const observations = Object.values(state.observations);
|
|
361
|
+
return JSON.stringify(observations.length ? {
|
|
362
|
+
failureSummary: failure, failureJournal: failurePath(meta.id),
|
|
363
|
+
failureObservations: observations.map((observation) => ({ ...observation, attentionDeliveredAt: state.delivered[observation.id] })),
|
|
364
|
+
...meta,
|
|
365
|
+
} : meta, null, 2);
|
|
346
366
|
}
|
|
347
367
|
|
|
348
368
|
function formatCompactStatus(meta: BackgroundTaskMeta): string {
|
|
@@ -389,7 +409,8 @@ function formatLog(id: string, tailLines?: number): string {
|
|
|
389
409
|
if (!meta) return `No background task found for id ${id}.`;
|
|
390
410
|
const log = readLog(meta.logPath, tailLines ?? 5);
|
|
391
411
|
const prefix = log.truncated ? `[showing tail of ${meta.logPath}]\n` : `[${meta.logPath}]\n`;
|
|
392
|
-
|
|
412
|
+
const failure = failureSummary(id);
|
|
413
|
+
return `${failure ? `${failure}\n` : ""}${prefix}${log.text || "(log is empty)"}`;
|
|
393
414
|
}
|
|
394
415
|
|
|
395
416
|
function logText(id: string, tailLines?: number) {
|