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 +1 -1
- package/src/shared-sandbox-core.ts +36 -67
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* the caller's case, and the kernel still treats it as the
|
|
595
|
-
*
|
|
596
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
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.
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
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
|
-
[...
|
|
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 = [...
|
|
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)
|
|
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
|
}
|