pi-better-background-tasks 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -228,10 +228,7 @@ const CREDENTIAL_LOCATIONS = [
228
228
  ".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
229
229
  ] as const;
230
230
 
231
- /**
232
- * Credential stores added by the broad-write profile. They are never readable
233
- * or writable there, whatever the Stored credentials row says.
234
- */
231
+ /** Credential stores added to the Stored credentials row by the broad-write profile. */
235
232
  const BROAD_CREDENTIAL_LOCATIONS = [
236
233
  "Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
237
234
  ".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
@@ -386,10 +383,7 @@ function compile(
386
383
  return {
387
384
  writableRoot, denyWrite, home: policy.home,
388
385
  ...(policy.permissions && {
389
- // The broad-write profile's credential deny list is not optional.
390
- permissions: broadMode
391
- ? { ...policy.permissions, storedCredentials: "off" as const }
392
- : { ...policy.permissions },
386
+ permissions: { ...policy.permissions },
393
387
  credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
394
388
  compatibilityWrite,
395
389
  runtimeWrite,
@@ -552,50 +546,18 @@ function contains(root: string, target: string): boolean {
552
546
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
553
547
  }
554
548
 
555
- /** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
556
- const caseFoldingByDevice = new Map<number, boolean>();
557
-
558
- /**
559
- * Whether the volume holding `path` (its nearest existing ancestor) ignores
560
- * case: an existing component whose case-swapped spelling names the same
561
- * inode proves it. macOS only; false whenever it cannot be shown.
562
- */
563
- function caseInsensitiveVolume(path: string): boolean {
564
- if (process.platform !== "darwin") return false;
565
- let current = path;
566
- while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
567
- let device: number;
568
- try { device = statSync(current).dev; } catch { return false; }
569
- const known = caseFoldingByDevice.get(device);
570
- if (known !== undefined) return known;
571
- for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
572
- const name = basename(probe);
573
- const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
574
- if (swapped === name) continue;
575
- try {
576
- const original = statSync(probe);
577
- if (original.dev !== device) break;
578
- let folded = false;
579
- try {
580
- const other = statSync(join(dirname(probe), swapped));
581
- folded = other.dev === original.dev && other.ino === original.ino;
582
- } catch { /* The swapped name is absent: case-sensitive. */ }
583
- caseFoldingByDevice.set(device, folded);
584
- return folded;
585
- } catch { return false; }
586
- }
587
- return false;
588
- }
589
-
590
549
  /**
591
- * Spell `path` the way the policy spells it when they differ only in case on a
592
- * case-insensitive volume. `canonicalizePath` fixes the case of every existing
593
- * component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
594
- * the caller's case, and the kernel still treats it as the protected name. The
595
- * longest policy path that matches case-insensitively wins, so the pre-check
596
- * decides as the kernel does (#354).
550
+ * Spell `path` the way the policy spells it when they differ only in case, on
551
+ * macOS. `canonicalizePath` fixes the case of every existing component on a
552
+ * case-insensitive volume, but a missing tail (`~/.NPMRC` while `~/.npmrc` is
553
+ * absent) keeps the caller's case, and the kernel still treats it as the
554
+ * protected name. Seatbelt matches paths case-insensitively on every volume,
555
+ * case-sensitive APFS included, so this folds on darwin without probing the
556
+ * volume: the pre-check decides as the kernel does (#354, #358). The longest
557
+ * policy path that matches case-insensitively wins. Other platforms never fold.
597
558
  */
598
- function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
559
+ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
560
+ if (currentPlatform(seams) !== "darwin") return path;
599
561
  const broad = policy.broad;
600
562
  const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
601
563
  ...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
@@ -608,8 +570,7 @@ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): st
608
570
  if (folded.length !== candidate.length || !contains(folded, lower)) continue;
609
571
  if (!best || candidate.length > best.length) best = candidate;
610
572
  }
611
- // Already spelled as the policy spells it: nothing to probe.
612
- if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
573
+ if (!best || path.startsWith(best)) return path;
613
574
  return `${best}${path.slice(best.length)}`;
614
575
  }
615
576
 
@@ -627,11 +588,11 @@ export function evaluateReadAccess(
627
588
  policy: CompiledSandboxWritePolicy,
628
589
  seams: SandboxSeams = {},
629
590
  ): ReadAccessDecision {
630
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
591
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
631
592
  const permissions = policy.permissions;
632
593
  if (!permissions) return { allowed: true, path };
633
594
  if (policy.broad) {
634
- const denied = isCredential(path, policy) ||
595
+ const denied = (isCredential(path, policy) && permissions.storedCredentials === "off") ||
635
596
  (permissions.projectFiles === "off" && contains(policy.writableRoot, path));
636
597
  return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
637
598
  }
@@ -654,7 +615,7 @@ export function evaluateWriteAccess(
654
615
  policy: CompiledSandboxWritePolicy,
655
616
  seams: SandboxSeams = {},
656
617
  ): WriteAccessDecision {
657
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
618
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
658
619
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
659
620
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
660
621
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -685,7 +646,8 @@ function evaluateBroadWrite(
685
646
  for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
686
647
  if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
687
648
  }
688
- if (isCredential(path, policy)) return { allowed: false, path, reason: "permission-denied" };
649
+ if (isCredential(path, policy)) return canWrite(policy.permissions!.storedCredentials)
650
+ ? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
689
651
  if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
690
652
  if (contains(policy.writableRoot, path)) {
691
653
  return canWrite(policy.permissions!.projectFiles)
@@ -841,7 +803,8 @@ export function resetRecoverySnapshotClock(): void {
841
803
  }
842
804
 
843
805
  function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
844
- return [...policy.denyWrite, ...(policy.credentialPaths ?? []), ...broad.codePaths,
806
+ return [...policy.denyWrite,
807
+ ...(policy.permissions?.storedCredentials === "read-write" ? [] : policy.credentialPaths ?? []), ...broad.codePaths,
845
808
  ...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
846
809
  }
847
810
 
@@ -960,7 +923,7 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
960
923
  `(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
961
924
  `(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
962
925
  );
963
- // The fixed deny list overrides every grant above. A rule naming
926
+ // Write-denied controls and credentials override every broad grant above. A rule naming
964
927
  // `file-write-unlink` outranks a `file-write*` wildcard whatever their
965
928
  // order (verified on the real kernel), so each protected path also gets an
966
929
  // explicit unlink denial; otherwise the removal grants above would reopen it.
@@ -969,8 +932,8 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
969
932
  if (!canWrite(permissions.projectFiles)) denyWrite(project);
970
933
  for (const path of broad.codePaths) denyWrite(path);
971
934
  for (const path of policy.credentialPaths ?? []) {
972
- rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
973
- denyWrite(path);
935
+ if (permissions.storedCredentials === "off") rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
936
+ if (permissions.storedCredentials !== "read-write") denyWrite(path);
974
937
  }
975
938
  for (const path of policy.denyWrite) denyWrite(path);
976
939
  // Renaming an ancestor would move a protected subtree to an unprotected path,
@@ -1383,9 +1346,11 @@ function maskSources(profilePath: string): { directory: string; file: string } {
1383
1346
  * the root is read-only; temp, the workspace, hidden home entries and worktree
1384
1347
  * folders are writable (removal included); ordinary top-level home folders and
1385
1348
  * home itself stay read-only, so sibling repositories cannot even be edited in
1386
- * place there. Credentials are masked by empty mode-000 mounts; code that runs
1387
- * later and caller-denied paths are read-only binds. Every writable bind and
1388
- * protected leaf is a mount point, which Linux refuses to rename. A protected
1349
+ * place there. Off credentials are masked by empty mode-000 mounts, Read
1350
+ * credentials are read-only binds, and Read / write credentials follow the
1351
+ * surrounding broad-write rules. Code that runs later and caller-denied paths
1352
+ * are read-only binds. Every writable bind and protected leaf is a mount point,
1353
+ * which Linux refuses to rename. A protected
1389
1354
  * symlink inside a writable directory (a stow link in `~/.config`) makes that
1390
1355
  * directory read-only with each existing entry bound writable again, so only
1391
1356
  * new top-level entries there are refused.
@@ -1406,14 +1371,15 @@ function buildLinuxBroadCommand(
1406
1371
  throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
1407
1372
  }
1408
1373
  const credentials = policy.credentialPaths ?? [];
1374
+ const protectedCredentials = permissions.storedCredentials === "read-write" ? [] : credentials;
1409
1375
  const writeDenied = [...broad.codePaths, ...policy.denyWrite];
1410
1376
  if (permissions.projectFiles === "read-write" &&
1411
- [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
1377
+ [...protectedCredentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
1412
1378
  throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
1413
1379
  }
1414
1380
  const list = seams.listDirectory ?? systemListDirectory;
1415
1381
  const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
1416
- const protectedPaths = [...credentials, ...writeDenied];
1382
+ const protectedPaths = [...protectedCredentials, ...writeDenied];
1417
1383
  const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
1418
1384
 
1419
1385
  const writable = new Set<string>();
@@ -1531,7 +1497,10 @@ function buildLinuxBroadCommand(
1531
1497
  readOnly.add(path);
1532
1498
  }
1533
1499
  };
1534
- for (const path of credentials) guard(path, true);
1500
+ for (const path of credentials) {
1501
+ if (permissions.storedCredentials === "off") guard(path, true);
1502
+ else if (permissions.storedCredentials === "read") guard(path, false);
1503
+ }
1535
1504
  for (const path of writeDenied) guard(path, false);
1536
1505
  if (permissions.projectFiles === "read") readOnly.add(project);
1537
1506
 
@@ -1582,7 +1551,7 @@ function buildLinuxBroadCommand(
1582
1551
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1583
1552
  "--", args.execPath, ...args.execArgs],
1584
1553
  ...(created.length && { notices: [
1585
- `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
1554
+ `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place (remove it before creating the real target): ${created.join(", ")}`,
1586
1555
  ] }),
1587
1556
  };
1588
1557
  }