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.
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * OS-level write sandbox mechanism shared by Pi extensions.
4
4
  *
5
- * Kernel-enforced confinement: the sandboxed process may READ anywhere and use
6
- * the network (so web_fetch and the model API keep working), but may only WRITE
7
- * under a single canonical root plus the system paths pi itself needs. Unlike a
8
- * cooperative guardrails layer (which pattern-matches tool inputs), this cannot
9
- * be evaded by a crafted bash command — the write syscall itself is denied.
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
- return canonicalize(absolute);
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 { writableRoot, denyWrite, home: policy.home };
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 an executable from PATH without starting it or probing namespaces. */
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 ?? executableFromPath)("bwrap");
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) on PATH. Install bubblewrap to enable it.";
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
- return (selectedSandboxBackend(seams) ?? macOSSandboxBackend).buildCommand(args, seams);
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 { listActiveMetasForOrigin, listMetas, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
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: Type.String(), value: Type.Any() }),
18
- Type.Object({ type: Type.Literal("json_path_exists"), path: Type.String() }),
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 listActiveMetasForOrigin(activeSession)) resumeRunningTask(pi, meta, getActiveSession);
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
- return `Started background ${meta.kind} ${label}. Status: ${meta.status}.${remote}${setup}${warning} Log: ${meta.logPath}`;
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
- if (!options.verbose) return formatCompactStatus(meta);
345
- return JSON.stringify(meta, null, 2);
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
- return prefix + (log.text || "(log is empty)");
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) {