pi-better-background-tasks 0.2.17 → 0.2.19
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 +161 -19
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* `SandboxSeams` argument so callers can plan deterministically in tests.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
21
22
|
import { platform as osPlatform } from "node:os";
|
|
22
23
|
import {
|
|
23
24
|
accessSync,
|
|
@@ -67,6 +68,8 @@ export type SandboxWritePolicy = {
|
|
|
67
68
|
permissions?: SandboxPermissions;
|
|
68
69
|
/** Trusted per-launch runtime state, never supplied by model tool arguments. */
|
|
69
70
|
runtimeWrite?: readonly string[];
|
|
71
|
+
/** Bounded historical default runtime directories; inactive without a capability profile. */
|
|
72
|
+
runtimeCompatibility?: boolean;
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
@@ -79,6 +82,8 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
79
82
|
/** Where the macOS backend writes its generated SBPL profile. */
|
|
80
83
|
profilePath: string;
|
|
81
84
|
policy: SandboxWritePolicy;
|
|
85
|
+
/** Fixed internal helper only: expose its executable in a hidden Linux root. */
|
|
86
|
+
internalHelperExecutable?: boolean;
|
|
82
87
|
};
|
|
83
88
|
|
|
84
89
|
/** The wrapper command to spawn: the backend executable and its full argv. */
|
|
@@ -114,6 +119,8 @@ export type SandboxSeams = {
|
|
|
114
119
|
* host running them.
|
|
115
120
|
*/
|
|
116
121
|
materializeDenyPath?: (path: string) => boolean;
|
|
122
|
+
/** Defaults to /usr/bin/getconf with a minimal environment (macOS only). */
|
|
123
|
+
getconf?: (name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR") => string | undefined;
|
|
117
124
|
};
|
|
118
125
|
|
|
119
126
|
/** A policy with every path canonicalized, deduplicated, and ordered. */
|
|
@@ -124,6 +131,7 @@ export type CompiledSandboxWritePolicy = {
|
|
|
124
131
|
readonly permissions?: SandboxPermissions;
|
|
125
132
|
readonly credentialPaths?: readonly string[];
|
|
126
133
|
readonly runtimeWrite?: readonly string[];
|
|
134
|
+
readonly compatibilityWrite?: readonly string[];
|
|
127
135
|
};
|
|
128
136
|
|
|
129
137
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -174,6 +182,46 @@ const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Libra
|
|
|
174
182
|
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
175
183
|
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
176
184
|
|
|
185
|
+
function systemGetconf(name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR"): string | undefined {
|
|
186
|
+
const result = spawnSync("/usr/bin/getconf", [name], {
|
|
187
|
+
encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "C" }, timeout: 3000, maxBuffer: 4096,
|
|
188
|
+
});
|
|
189
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compatibilityPaths(policy: SandboxWritePolicy, seams: SandboxSeams): string[] {
|
|
193
|
+
if (!policy.permissions || !policy.runtimeCompatibility || policy.permissions.outsideProject === "off") return [];
|
|
194
|
+
const platform = currentPlatform(seams);
|
|
195
|
+
if (platform === "linux") return [canonicalizePath("/tmp", seams)];
|
|
196
|
+
if (platform !== "darwin") return [];
|
|
197
|
+
const home = canonicalizePath(policy.home, seams);
|
|
198
|
+
const project = canonicalizePath(policy.writableRoot, seams);
|
|
199
|
+
const paths = [canonicalizePath("/tmp", seams)];
|
|
200
|
+
for (const [key, leaf] of [["DARWIN_USER_TEMP_DIR", ""], ["DARWIN_USER_CACHE_DIR", "mds"]] as const) {
|
|
201
|
+
const raw = (seams.getconf ?? systemGetconf)(key);
|
|
202
|
+
if (!raw || !raw.startsWith("/") || raw.includes("\0") ||
|
|
203
|
+
raw.split("/").some((part) => part === "." || part === "..")) {
|
|
204
|
+
throw new Error(`Invalid ${key} runtime directory`);
|
|
205
|
+
}
|
|
206
|
+
// getconf returns /var/folders on macOS; /var is a system alias for /private/var.
|
|
207
|
+
const normalized = resolve(raw.replace(/^\/var\/folders\//, "/private/var/folders/"));
|
|
208
|
+
const expected = /^\/private\/var\/folders\/[^/]+\/[^/]+\/[TC]$/.test(normalized) &&
|
|
209
|
+
normalized.endsWith(key === "DARWIN_USER_TEMP_DIR" ? "/T" : "/C");
|
|
210
|
+
const canonical = canonicalizePath(normalized, seams);
|
|
211
|
+
if (!expected || canonical !== normalized ||
|
|
212
|
+
(contains(home, canonical) || contains(canonical, home) || contains(project, canonical))) {
|
|
213
|
+
throw new Error(`Unsafe ${key} runtime directory`);
|
|
214
|
+
}
|
|
215
|
+
paths.push(leaf ? join(canonical, leaf) : canonical);
|
|
216
|
+
}
|
|
217
|
+
// An existing MDS symlink must not redirect the narrow write allowance.
|
|
218
|
+
const mds = paths[2];
|
|
219
|
+
if (!mds || canonicalizePath(mds, seams) !== mds || contains(mds, project) || contains(project, mds)) {
|
|
220
|
+
throw new Error("Unsafe MDS runtime directory");
|
|
221
|
+
}
|
|
222
|
+
return [...new Set(paths)];
|
|
223
|
+
}
|
|
224
|
+
|
|
177
225
|
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
178
226
|
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
179
227
|
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
@@ -233,12 +281,17 @@ function compile(
|
|
|
233
281
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
234
282
|
].sort();
|
|
235
283
|
|
|
284
|
+
const compatibilityWrite = compatibilityPaths(policy, seams);
|
|
236
285
|
return {
|
|
237
286
|
writableRoot, denyWrite, home: policy.home,
|
|
238
287
|
...(policy.permissions && {
|
|
239
288
|
permissions: { ...policy.permissions },
|
|
240
289
|
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
241
|
-
|
|
290
|
+
compatibilityWrite,
|
|
291
|
+
runtimeWrite: [...new Set([
|
|
292
|
+
...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
293
|
+
...compatibilityWrite,
|
|
294
|
+
])],
|
|
242
295
|
}),
|
|
243
296
|
};
|
|
244
297
|
}
|
|
@@ -277,8 +330,9 @@ export function evaluateReadAccess(
|
|
|
277
330
|
const permissions = policy.permissions;
|
|
278
331
|
if (!permissions) return { allowed: true, path };
|
|
279
332
|
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
280
|
-
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
333
|
+
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
281
334
|
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
335
|
+
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
282
336
|
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
283
337
|
: permissions.outsideProject;
|
|
284
338
|
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
@@ -305,8 +359,9 @@ export function evaluateWriteAccess(
|
|
|
305
359
|
}
|
|
306
360
|
if (policy.permissions) {
|
|
307
361
|
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
308
|
-
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
362
|
+
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
309
363
|
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
364
|
+
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
310
365
|
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
311
366
|
: policy.permissions.outsideProject;
|
|
312
367
|
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
@@ -342,6 +397,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
342
397
|
...policy.denyWrite,
|
|
343
398
|
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
344
399
|
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
400
|
+
...(policy.compatibilityWrite ?? []),
|
|
345
401
|
];
|
|
346
402
|
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
347
403
|
if (permissions.outsideProject === "off") {
|
|
@@ -365,13 +421,15 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
365
421
|
};
|
|
366
422
|
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
367
423
|
// explicit denyWrite entries always override every write allowance.
|
|
424
|
+
for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
|
|
368
425
|
scoped(policy.writableRoot, permissions.projectFiles);
|
|
369
|
-
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
426
|
+
for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
|
|
370
427
|
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
371
428
|
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
372
429
|
// Protect the directory entries, not their contents: unrelated children can
|
|
373
430
|
// still be created, while renaming a parent cannot move a denied subtree.
|
|
374
431
|
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
432
|
+
for (const path of policy.compatibilityWrite ?? []) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
375
433
|
if (!permissions.network) rules.push("(deny network*)");
|
|
376
434
|
return [...rules, ""].join("\n");
|
|
377
435
|
}
|
|
@@ -410,7 +468,21 @@ const macOSSandboxBackend: SandboxBackend = {
|
|
|
410
468
|
buildCommand: buildMacOSSandboxCommand,
|
|
411
469
|
};
|
|
412
470
|
|
|
413
|
-
/** Resolve
|
|
471
|
+
/** Resolve only a root-owned system executable; never inspect task PATH. */
|
|
472
|
+
function systemSandboxExecutable(name: string): string | undefined {
|
|
473
|
+
// Never resolve the host-side confinement launcher through task-influenced PATH.
|
|
474
|
+
for (const directory of ["/usr/bin", "/bin"]) {
|
|
475
|
+
const candidate = join(directory, name);
|
|
476
|
+
try {
|
|
477
|
+
const info = statSync(candidate);
|
|
478
|
+
if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
|
|
479
|
+
accessSync(candidate, constants.X_OK);
|
|
480
|
+
return candidate;
|
|
481
|
+
} catch { /* Try the next system location. */ }
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
414
486
|
export function executableFromPath(name: string): string | undefined {
|
|
415
487
|
const path = process.env.PATH;
|
|
416
488
|
if (!path) return undefined;
|
|
@@ -592,33 +664,103 @@ function buildLinuxPermissionCommand(
|
|
|
592
664
|
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
593
665
|
}
|
|
594
666
|
mounts.push("--tmpfs", "/tmp");
|
|
667
|
+
if (args.internalHelperExecutable) {
|
|
668
|
+
const executable = canonicalizePath(args.execPath, seams);
|
|
669
|
+
if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
|
|
670
|
+
mounts.push("--ro-bind", executable, executable);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
595
673
|
} else {
|
|
596
|
-
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
674
|
+
mounts.push(permissions.outsideProject === "read" && !policy.compatibilityWrite?.includes(canonicalizePath("/tmp", seams)) ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
597
675
|
}
|
|
598
676
|
mounts.push("--dev", "/dev");
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
677
|
+
const scopedMounts: { option: "--bind" | "--ro-bind"; path: string }[] = [];
|
|
678
|
+
const writableAncestors = new Set<string>();
|
|
679
|
+
const readOnlyGuards = new Set<string>();
|
|
602
680
|
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
603
681
|
for (const path of credentials) {
|
|
604
|
-
if (existsSync(path) && !contains(project, path))
|
|
682
|
+
if (existsSync(path) && !contains(project, path)) readOnlyGuards.add(path);
|
|
605
683
|
}
|
|
606
684
|
}
|
|
685
|
+
// A broad /tmp bind is already present for visible outside roots. Cover
|
|
686
|
+
// existing leaves and absent leaves via a read-only existing ancestor; do
|
|
687
|
+
// not create credential or control files in host runtime directories.
|
|
688
|
+
const compatibility = policy.compatibilityWrite ?? [];
|
|
689
|
+
if (compatibility.length) {
|
|
690
|
+
const tmp = canonicalizePath("/tmp", seams);
|
|
691
|
+
const protectedInTmp = [...policy.denyWrite, ...(permissions.storedCredentials !== "read-write" ? credentials : [])]
|
|
692
|
+
.filter((path) => contains(tmp, path) && !contains(project, path));
|
|
693
|
+
if (permissions.storedCredentials === "off" && protectedInTmp.some((path) =>
|
|
694
|
+
credentials.some((credential) => credential === path))) {
|
|
695
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a writable runtime directory.");
|
|
696
|
+
}
|
|
697
|
+
if (policy.denyWrite.some((path) => contains(path, tmp))) {
|
|
698
|
+
throw new Error("Runtime directory overlaps a write-denied control path.");
|
|
699
|
+
}
|
|
700
|
+
const guards = new Set<string>();
|
|
701
|
+
for (const path of protectedInTmp) {
|
|
702
|
+
let guard = path;
|
|
703
|
+
while (!existsSync(guard) && contains(tmp, guard) && guard !== tmp) guard = dirname(guard);
|
|
704
|
+
if (guard === tmp || !contains(tmp, guard)) {
|
|
705
|
+
throw new Error("Cannot protect an absent path directly under writable runtime directory.");
|
|
706
|
+
}
|
|
707
|
+
guards.add(guard);
|
|
708
|
+
}
|
|
709
|
+
for (const guard of [...guards].sort((a, b) => a.length - b.length)) {
|
|
710
|
+
if ([...guards].some((parent) => parent !== guard && contains(parent, guard))) continue;
|
|
711
|
+
for (const parent of protectedAncestors([guard]).filter((parent) => contains(tmp, parent) && parent !== tmp)) {
|
|
712
|
+
writableAncestors.add(parent);
|
|
713
|
+
}
|
|
714
|
+
readOnlyGuards.add(guard);
|
|
715
|
+
}
|
|
716
|
+
// A writable /tmp can rename any directory above the captured project
|
|
717
|
+
// root, then substitute a symlink before the next launch. Anchor each
|
|
718
|
+
// ancestor even when there are no explicit control or credential guards.
|
|
719
|
+
if (permissions.projectFiles !== "off" && contains(tmp, project)) {
|
|
720
|
+
for (const parent of protectedAncestors([project]).filter((path) => contains(tmp, path) && path !== tmp)) {
|
|
721
|
+
writableAncestors.add(parent);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (permissions.projectFiles !== "off") {
|
|
726
|
+
scopedMounts.push({ option: writableProject ? "--bind" : "--ro-bind", path: project });
|
|
727
|
+
}
|
|
607
728
|
for (const path of policy.runtimeWrite ?? []) {
|
|
608
|
-
if (
|
|
729
|
+
if (compatibility.includes(path)) continue;
|
|
730
|
+
if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
|
|
731
|
+
policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
|
|
609
732
|
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
610
733
|
}
|
|
611
|
-
|
|
734
|
+
scopedMounts.push({ option: "--bind", path });
|
|
612
735
|
}
|
|
613
736
|
const protectedPaths = [...policy.denyWrite,
|
|
614
737
|
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
615
|
-
|
|
738
|
+
for (const writableRoot of [...(writableProject ? [project] : []),
|
|
739
|
+
...(policy.runtimeWrite ?? []).filter((path) => !compatibility.includes(path))]) {
|
|
616
740
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
617
|
-
const leaves = protectedPaths.filter((path) => contains(
|
|
618
|
-
for (const parent of protectedAncestors(leaves).filter((path) => contains(
|
|
619
|
-
|
|
741
|
+
const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
|
|
742
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
|
|
743
|
+
writableAncestors.add(parent);
|
|
620
744
|
}
|
|
621
|
-
for (const path of leaves)
|
|
745
|
+
for (const path of leaves) readOnlyGuards.add(path);
|
|
746
|
+
}
|
|
747
|
+
// A nearest-existing guard can be an ancestor of an unrelated writable
|
|
748
|
+
// project (e.g. absent ~/.npmrc with a project under ~/work). Mount that
|
|
749
|
+
// broad guard in path order, keeping its intervening anchors read-only;
|
|
750
|
+
// only the disjoint project subtree may be rebound writable afterwards.
|
|
751
|
+
const ancestorGuards = [...readOnlyGuards].filter((guard) =>
|
|
752
|
+
scopedMounts.some(({ option, path }) => option === "--bind" && guard !== path && contains(guard, path)));
|
|
753
|
+
for (const guard of ancestorGuards) {
|
|
754
|
+
scopedMounts.push({ option: "--ro-bind", path: guard });
|
|
755
|
+
readOnlyGuards.delete(guard);
|
|
756
|
+
}
|
|
757
|
+
for (const path of writableAncestors) scopedMounts.push({
|
|
758
|
+
option: ancestorGuards.some((guard) => contains(guard, path)) ? "--ro-bind" : "--bind", path,
|
|
759
|
+
});
|
|
760
|
+
scopedMounts.sort((a, b) => a.path.length - b.path.length);
|
|
761
|
+
for (const { option, path } of scopedMounts) mounts.push(option, path, path);
|
|
762
|
+
for (const path of [...readOnlyGuards].sort((a, b) => a.length - b.length)) {
|
|
763
|
+
mounts.push("--ro-bind", path, path);
|
|
622
764
|
}
|
|
623
765
|
return {
|
|
624
766
|
file: bwrap,
|
|
@@ -628,7 +770,7 @@ function buildLinuxPermissionCommand(
|
|
|
628
770
|
}
|
|
629
771
|
|
|
630
772
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
631
|
-
const bwrap = (seams.lookupExecutable ??
|
|
773
|
+
const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
|
|
632
774
|
if (!bwrap) return undefined;
|
|
633
775
|
return {
|
|
634
776
|
id: "linux-bubblewrap",
|
|
@@ -651,7 +793,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
|
|
|
651
793
|
*/
|
|
652
794
|
function unavailableMessage(platform: string): string {
|
|
653
795
|
if (platform === "linux") {
|
|
654
|
-
return "Linux sandbox requires executable bubblewrap (bwrap)
|
|
796
|
+
return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
|
|
655
797
|
}
|
|
656
798
|
if (platform === "darwin") {
|
|
657
799
|
return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
|