pi-better-background-tasks 0.3.0 → 0.5.0

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.
@@ -18,32 +18,55 @@
18
18
  * `SandboxSeams` argument so callers can plan deterministically in tests.
19
19
  */
20
20
 
21
- import { spawnSync } from "node:child_process";
21
+ import { spawn, spawnSync } from "node:child_process";
22
22
  import { platform as osPlatform } from "node:os";
23
23
  import {
24
24
  accessSync,
25
25
  closeSync,
26
26
  constants,
27
27
  existsSync,
28
+ lstatSync,
28
29
  mkdirSync,
29
30
  openSync,
31
+ readdirSync,
32
+ readlinkSync,
30
33
  realpathSync,
31
34
  statSync,
32
35
  writeFileSync,
33
36
  } from "node:fs";
34
- import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
37
+ import { basename, delimiter, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
35
38
 
36
39
  /** Identifies which kernel mechanism a plan will use. */
37
40
  export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
38
41
 
42
+ /**
43
+ * File access levels for the Project files and Outside project rows.
44
+ *
45
+ * - `off`: no access.
46
+ * - `read`: read only.
47
+ * - `write`: read, create and overwrite in place. Nothing can be removed or
48
+ * renamed away (unlink, rmdir, rename of an existing entry, or a rename that
49
+ * replaces one), except in the always-disposable places: temp, hidden (dot)
50
+ * entries of the home directory, and git worktree folders (`.worktrees/`,
51
+ * `*-worktrees/`). For Outside project this is the broad-write profile:
52
+ * writes across home and temp, and the fixed deny list.
53
+ * - `read-write`: "Write & delete" — the historical read/write level.
54
+ */
55
+ export type SandboxFileAccess = "off" | "read" | "write" | "read-write";
56
+
39
57
  export type SandboxPermissions = {
40
- projectFiles: "off" | "read" | "read-write";
41
- outsideProject: "off" | "read" | "read-write";
58
+ projectFiles: SandboxFileAccess;
59
+ outsideProject: SandboxFileAccess;
42
60
  storedCredentials: "off" | "read" | "read-write";
43
61
  commands: boolean;
44
62
  network: boolean;
45
63
  };
46
64
 
65
+ /** Whether a file access level allows creating and overwriting files. */
66
+ export function canWrite(mode: SandboxFileAccess | undefined): boolean {
67
+ return mode === "write" || mode === "read-write";
68
+ }
69
+
47
70
  /**
48
71
  * What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
49
72
  * be relative or contain symlinks; they are canonicalized before use.
@@ -121,6 +144,16 @@ export type SandboxSeams = {
121
144
  materializeDenyPath?: (path: string) => boolean;
122
145
  /** Defaults to /usr/bin/getconf with a minimal environment (macOS only). */
123
146
  getconf?: (name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR") => string | undefined;
147
+ /** Broad-write Linux planning: directory listing (defaults to `readdirSync`). */
148
+ listDirectory?: (path: string) => { name: string; directory: boolean; symlink: boolean }[];
149
+ /** Broad-write Linux planning: existence (defaults to `existsSync`). */
150
+ pathExists?: (path: string) => boolean;
151
+ /** Broad-write Linux planning: whether an existing path is a directory. */
152
+ isDirectory?: (path: string) => boolean;
153
+ /** Broad-write Linux planning: create an absent directory-type deny entry. */
154
+ makeDirectory?: (path: string) => void;
155
+ /** Broad-write Linux planning: empty mode-000 mask sources. */
156
+ maskSources?: (profilePath: string) => { directory: string; file: string };
124
157
  };
125
158
 
126
159
  /** A policy with every path canonicalized, deduplicated, and ordered. */
@@ -132,6 +165,20 @@ export type CompiledSandboxWritePolicy = {
132
165
  readonly credentialPaths?: readonly string[];
133
166
  readonly runtimeWrite?: readonly string[];
134
167
  readonly compatibilityWrite?: readonly string[];
168
+ /** Present only for the broad-write profile (Outside project = `write`). */
169
+ readonly broad?: CompiledBroadWrite;
170
+ };
171
+
172
+ /** The canonical inputs of a broad-write profile. */
173
+ export type CompiledBroadWrite = {
174
+ /** Canonical home directory. Writes are allowed under it, minus the deny list. */
175
+ readonly home: string;
176
+ /** Canonical temp roots (writable and removal-allowed). */
177
+ readonly tempRoots: readonly string[];
178
+ /** Canonical removal-allowed roots besides temp, dot entries and worktree folders: a Write & delete workspace and runtime scratch. */
179
+ readonly deleteRoots: readonly string[];
180
+ /** Code that runs later: never writable, removable or renamable. Still readable. */
181
+ readonly codePaths: readonly string[];
135
182
  };
136
183
 
137
184
  /** Why a write target is or is not permitted by a compiled policy. */
@@ -140,7 +187,7 @@ export type WriteAccessDecision =
140
187
  | {
141
188
  allowed: false;
142
189
  path: string;
143
- reason: "outside-writable-root" | "write-denied" | "permission-denied";
190
+ reason: "outside-writable-root" | "write-denied" | "permission-denied" | "delete-denied";
144
191
  /** The compiled deny entry that matched, for `write-denied` only. */
145
192
  deniedBy?: string;
146
193
  };
@@ -173,6 +220,43 @@ const CREDENTIAL_LOCATIONS = [
173
220
  ".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
174
221
  ] as const;
175
222
 
223
+ /**
224
+ * Credential stores added by the broad-write profile. They are never readable
225
+ * or writable there, whatever the Stored credentials row says.
226
+ */
227
+ const BROAD_CREDENTIAL_LOCATIONS = [
228
+ "Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
229
+ ".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
230
+ ] as const;
231
+
232
+ /**
233
+ * Code that runs later: shell startup files, agent/harness configuration and
234
+ * login agents. The broad-write profile keeps them readable (skills and
235
+ * extension sources are read by confined tasks) but never writable, removable
236
+ * or renamable. This list is fixed; callers extend it with `denyWrite`.
237
+ */
238
+ const CODE_LATER_LOCATIONS = [
239
+ ".bashrc", ".bash_profile", ".bash_login", ".bash_logout", ".profile",
240
+ ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout",
241
+ ".config/fish", ".config/git", ".gitconfig", ".config/systemd/user", ".config/autostart",
242
+ ".pi", ".claude", ".agents", "Library/LaunchAgents",
243
+ ".local/bin", "bin", ".git-templates", ".oh-my-zsh/custom", ".gradle/init.d",
244
+ ".cargo/config.toml", ".cargo/config",
245
+ ] as const;
246
+
247
+ /** Deny-list entries that are directories when they exist (Linux materializes them as such). */
248
+ const DIRECTORY_LOCATIONS = new Set([
249
+ ".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube", "Library/Keychains",
250
+ ".config/fish", ".config/git", ".config/systemd/user", ".config/autostart",
251
+ ".pi", ".claude", ".agents", "Library/LaunchAgents", ".gnupg", ".config/rclone",
252
+ ".local/bin", "bin", ".git-templates", ".oh-my-zsh/custom", ".gradle/init.d",
253
+ ]);
254
+
255
+ const BROAD_TEMP_ROOTS: Record<string, readonly string[]> = {
256
+ darwin: ["/private/tmp", "/private/var/folders", "/private/var/tmp"],
257
+ linux: ["/tmp", "/var/tmp", "/dev/shm"],
258
+ };
259
+
176
260
  // Executables, dynamic libraries and OS frameworks needed to start a child.
177
261
  // This deliberately excludes the home directory, /etc, and credential stores.
178
262
  // A process relying on /etc or /proc configuration (DNS, certificates, NSS)
@@ -277,25 +361,173 @@ function compile(
277
361
  ? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
278
362
  : canonicalizePath(policy.writableRoot, seams);
279
363
 
364
+ // The broad profile also keeps each literal entry: its removal grants would
365
+ // otherwise let a task replace a symlinked control path.
366
+ const lexicalDeny = isBroadWritePermissions(policy.permissions);
280
367
  const denyWrite = [
281
- ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
368
+ ...new Set((policy.denyWrite ?? []).flatMap((entry) => lexicalDeny
369
+ ? [resolve(entry), ...symlinkHops(entry), canonicalizePath(entry, seams)] : [canonicalizePath(entry, seams)])),
282
370
  ].sort();
283
371
 
284
- const compatibilityWrite = compatibilityPaths(policy, seams);
372
+ const broadMode = isBroadWritePermissions(policy.permissions);
373
+ const compatibilityWrite = broadMode ? [] : compatibilityPaths(policy, seams);
374
+ const runtimeWrite = [...new Set([
375
+ ...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
376
+ ...compatibilityWrite,
377
+ ])];
285
378
  return {
286
379
  writableRoot, denyWrite, home: policy.home,
287
380
  ...(policy.permissions && {
288
- permissions: { ...policy.permissions },
289
- credentialPaths: credentialFilePaths(policy.home, seams),
381
+ // The broad-write profile's credential deny list is not optional.
382
+ permissions: broadMode
383
+ ? { ...policy.permissions, storedCredentials: "off" as const }
384
+ : { ...policy.permissions },
385
+ credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
290
386
  compatibilityWrite,
291
- runtimeWrite: [...new Set([
292
- ...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
293
- ...compatibilityWrite,
294
- ])],
387
+ runtimeWrite,
388
+ }),
389
+ ...(broadMode && {
390
+ broad: compileBroadWrite(policy, writableRoot, runtimeWrite, seams),
295
391
  }),
296
392
  };
297
393
  }
298
394
 
395
+ /** True when a permission profile selects the broad-write profile. */
396
+ export function isBroadWritePermissions(permissions: SandboxPermissions | undefined): boolean {
397
+ return permissions?.outsideProject === "write";
398
+ }
399
+
400
+ function expandHome(path: string, home: string): string {
401
+ return path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
402
+ }
403
+
404
+ /**
405
+ * A fixed-list location both as the literal entry under the canonical home and
406
+ * as the path it resolves to. A dotfiles manager (stow, chezmoi) makes
407
+ * `~/.zshrc` a symlink into an ordinary folder: guarding only the target would
408
+ * let a task remove the link and put a real file in its place.
409
+ */
410
+ function lexicalAndCanonical(home: string, name: string, seams: SandboxSeams): string[] {
411
+ const lexical = join(canonicalizePath(home, seams), name);
412
+ return [lexical, ...symlinkHops(lexical), canonicalizePath(lexical, seams)];
413
+ }
414
+
415
+ /**
416
+ * Every symlink directory entry met while resolving `path`, component by
417
+ * component (as `readlink` would follow them), plus where a dangling chain
418
+ * would land. A chain such as
419
+ * `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
420
+ * in a removable directory; replacing it would retarget the protected entry, so
421
+ * each hop the user could replace is protected too. When a followed link
422
+ * points at something missing, creating it would give the entry content, so
423
+ * the first missing component (which covers every missing directory below it)
424
+ * and the full path it would resolve to are protected as well. Bounded: a loop
425
+ * or more than 40 hops stops the walk (such a path resolves to nothing).
426
+ */
427
+ function symlinkHops(path: string): string[] {
428
+ const { hops, unresolved } = walkSymlinks(path);
429
+ return [...hops, ...unresolved];
430
+ }
431
+
432
+ function walkSymlinks(path: string): { hops: string[]; unresolved: string[] } {
433
+ const hops: string[] = [];
434
+ const seen = new Set<string>();
435
+ let followed = 0;
436
+ const absolute = resolve(path);
437
+ let current = parse(absolute).root;
438
+ let pending = absolute.slice(current.length).split(sep);
439
+ while (pending.length) {
440
+ const component = pending.shift()!;
441
+ if (!component || component === ".") continue;
442
+ if (component === "..") { current = dirname(current); continue; }
443
+ const entry = join(current, component);
444
+ let link: boolean;
445
+ try {
446
+ link = lstatSync(entry).isSymbolicLink();
447
+ } catch {
448
+ // Missing. Only a followed link makes this a dangling target; a
449
+ // plain absent path is guarded as it is.
450
+ return { hops, unresolved: followed ? [...new Set([entry, resolve(entry, ...pending)])] : [] };
451
+ }
452
+ if (!link) { current = entry; continue; }
453
+ if (++followed > 40 || seen.has(entry)) break;
454
+ seen.add(entry);
455
+ // Only a link the user can replace matters (not e.g. macOS's root-owned /tmp -> private/tmp).
456
+ try { accessSync(current, constants.W_OK); hops.push(entry); } catch { /* immutable entry */ }
457
+ let target: string;
458
+ try { target = readlinkSync(entry); } catch { break; }
459
+ if (isAbsolute(target)) {
460
+ current = parse(target).root;
461
+ pending = [...target.slice(current.length).split(sep), ...pending];
462
+ } else {
463
+ pending = [...target.split(sep), ...pending];
464
+ }
465
+ }
466
+ return { hops, unresolved: [] };
467
+ }
468
+
469
+ /** Whether a symlink resolves to nothing: dangling (ENOENT), or looping or over 40 hops (ELOOP). */
470
+ function unresolvable(path: string): boolean {
471
+ try { statSync(path); return false; } catch { return true; }
472
+ }
473
+
474
+ function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
475
+ const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
476
+ return [...new Set([
477
+ ...credentialFilePaths(home, seams),
478
+ ...[...CREDENTIAL_LOCATIONS, ...BROAD_CREDENTIAL_LOCATIONS].flatMap((name) => lexicalAndCanonical(home, name, seams)),
479
+ ...(configuredAgentDir ? [resolve(expandHome(configuredAgentDir, home), "auth.json"),
480
+ ...symlinkHops(resolve(expandHome(configuredAgentDir, home), "auth.json"))] : []),
481
+ ])].sort();
482
+ }
483
+
484
+ function compileBroadWrite(
485
+ policy: SandboxWritePolicy,
486
+ writableRoot: string,
487
+ runtimeWrite: readonly string[],
488
+ seams: SandboxSeams,
489
+ ): CompiledBroadWrite {
490
+ const permissions = policy.permissions!;
491
+ const home = canonicalizePath(policy.home, seams);
492
+ if (home === sep || RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
493
+ throw new Error("The broad-write sandbox profile needs a real home directory, not / or a system runtime root.");
494
+ }
495
+ if (writableRoot === home || contains(writableRoot, home)) {
496
+ throw new Error("The broad-write sandbox profile needs a project directory inside or beside home, not home itself.");
497
+ }
498
+ const tempRoots = [...new Set((BROAD_TEMP_ROOTS[currentPlatform(seams)] ?? [])
499
+ .map((path) => canonicalizePath(path, seams)))];
500
+ const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
501
+ const codePaths = [...new Set([
502
+ ...CODE_LATER_LOCATIONS.flatMap((name) => lexicalAndCanonical(home, name, seams)),
503
+ ...(configuredAgentDir ? [resolve(expandHome(configuredAgentDir, home)),
504
+ ...symlinkHops(expandHome(configuredAgentDir, home)),
505
+ canonicalizePath(expandHome(configuredAgentDir, home), seams)] : []),
506
+ ])].sort();
507
+ return {
508
+ home,
509
+ tempRoots,
510
+ deleteRoots: [...new Set([
511
+ ...(permissions.projectFiles === "read-write" ? [writableRoot] : []),
512
+ ...runtimeWrite,
513
+ ])].sort(),
514
+ codePaths,
515
+ };
516
+ }
517
+
518
+ /** Inside a git worktree folder (`.worktrees/`, `*-worktrees/`) below `root`: its contents, not the folder. */
519
+ function insideWorktreeFolder(path: string, root: string): boolean {
520
+ if (!contains(root, path) || path === root) return false;
521
+ return path.slice(root.length + 1).split(sep).slice(0, -1)
522
+ .some((part) => part === ".worktrees" || /^[^/]+-worktrees$/.test(part));
523
+ }
524
+
525
+ /** Hidden entries of home, and git worktree folders anywhere under it. */
526
+ function disposableHomePath(path: string, home: string): boolean {
527
+ if (!contains(home, path) || path === home) return false;
528
+ return path.slice(home.length + 1).startsWith(".") || insideWorktreeFolder(path, home);
529
+ }
530
+
299
531
  /**
300
532
  * Canonicalize a write policy once so containment checks and backend rules
301
533
  * agree on exactly which paths they are talking about.
@@ -329,6 +561,11 @@ export function evaluateReadAccess(
329
561
  const path = canonicalizePath(target, seams);
330
562
  const permissions = policy.permissions;
331
563
  if (!permissions) return { allowed: true, path };
564
+ if (policy.broad) {
565
+ const denied = isCredential(path, policy) ||
566
+ (permissions.projectFiles === "off" && contains(policy.writableRoot, path));
567
+ return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
568
+ }
332
569
  const mode = isCredential(path, policy) ? permissions.storedCredentials
333
570
  : policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
334
571
  : contains(policy.writableRoot, path) ? permissions.projectFiles
@@ -349,6 +586,7 @@ export function evaluateWriteAccess(
349
586
  seams: SandboxSeams = {},
350
587
  ): WriteAccessDecision {
351
588
  const path = canonicalizePath(target, seams);
589
+ if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
352
590
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
353
591
  return { allowed: false, path, reason: "outside-writable-root" };
354
592
  }
@@ -364,11 +602,180 @@ export function evaluateWriteAccess(
364
602
  : policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
365
603
  : contains(canonicalizePath("/dev", seams), path) ? "read-write"
366
604
  : policy.permissions.outsideProject;
367
- if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
605
+ if (!canWrite(mode)) return { allowed: false, path, reason: "permission-denied" };
368
606
  }
369
607
  return { allowed: true, path };
370
608
  }
371
609
 
610
+ function evaluateBroadWrite(
611
+ path: string,
612
+ policy: CompiledSandboxWritePolicy,
613
+ broad: CompiledBroadWrite,
614
+ seams: SandboxSeams,
615
+ ): WriteAccessDecision {
616
+ for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
617
+ if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
618
+ }
619
+ if (isCredential(path, policy)) return { allowed: false, path, reason: "permission-denied" };
620
+ if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
621
+ if (contains(policy.writableRoot, path)) {
622
+ return canWrite(policy.permissions!.projectFiles)
623
+ ? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
624
+ }
625
+ if ([...broad.tempRoots, ...broad.deleteRoots, canonicalizePath("/dev", seams)].some((root) => contains(root, path))) {
626
+ return { allowed: true, path };
627
+ }
628
+ if (contains(broad.home, path)) {
629
+ // Linux cannot separate removal from writing, so its fallback keeps
630
+ // ordinary (non-dot) top-level home folders read-only instead.
631
+ if (currentPlatform(seams) === "linux" && !disposableHomePath(path, broad.home)) {
632
+ return { allowed: false, path, reason: "permission-denied" };
633
+ }
634
+ return { allowed: true, path };
635
+ }
636
+ return { allowed: false, path, reason: "permission-denied" };
637
+ }
638
+
639
+ /**
640
+ * Decide whether `target` may be removed or renamed away (unlink, rmdir,
641
+ * rename of the source, or rename over an existing destination). Outside the
642
+ * broad-write profile removal follows write access.
643
+ */
644
+ export function evaluateDeleteAccess(
645
+ target: string,
646
+ policy: CompiledSandboxWritePolicy,
647
+ seams: SandboxSeams = {},
648
+ ): WriteAccessDecision {
649
+ const write = evaluateWriteAccess(target, policy, seams);
650
+ const broad = policy.broad;
651
+ if (!write.allowed) return write;
652
+ const path = write.path;
653
+ if (!broad) {
654
+ // Project files = Write outside the broad profile: nothing in the
655
+ // project is removable except worktree folders' contents.
656
+ return policy.permissions?.projectFiles === "write" && contains(policy.writableRoot, path) &&
657
+ !insideWorktreeFolder(path, policy.writableRoot)
658
+ ? { allowed: false, path, reason: "delete-denied" } : write;
659
+ }
660
+ const anchors = broadProtectedPaths(policy, broad);
661
+ if (anchors.some((protectedPath) => protectedPath !== path && contains(path, protectedPath))) {
662
+ return { allowed: false, path, reason: "delete-denied" };
663
+ }
664
+ if (path === policy.writableRoot || contains(path, policy.writableRoot)) {
665
+ return { allowed: false, path, reason: "delete-denied" };
666
+ }
667
+ if ([...broad.tempRoots, ...broad.deleteRoots].some((root) => contains(root, path)) ||
668
+ disposableHomePath(path, broad.home)) {
669
+ return write;
670
+ }
671
+ return { allowed: false, path, reason: "delete-denied" };
672
+ }
673
+
674
+ /**
675
+ * Whether an already-canonical path lies where a Write level still allows
676
+ * removal: temp, hidden home entries, worktree folders, a Write & delete
677
+ * workspace and runtime scratch (broad profile), or a worktree folder inside a
678
+ * Project files = Write workspace. The leaf is not re-resolved, so a caller can
679
+ * ask about a symlink's own directory entry.
680
+ */
681
+ export function isRemovableUnderWrite(path: string, policy: CompiledSandboxWritePolicy): boolean {
682
+ const broad = policy.broad;
683
+ if (broad) {
684
+ if (broadProtectedPaths(policy, broad).some((guard) => contains(guard, path) || contains(path, guard))) return false;
685
+ return [...broad.tempRoots, ...broad.deleteRoots].some((root) => contains(root, path)) ||
686
+ disposableHomePath(path, broad.home);
687
+ }
688
+ return policy.permissions?.projectFiles === "write" && insideWorktreeFolder(path, policy.writableRoot);
689
+ }
690
+
691
+ /** Outcome of a finished snapshot attempt. */
692
+ export type RecoverySnapshotOutcome = { ok: boolean; detail: string };
693
+
694
+ /** Result of a best-effort recovery snapshot request. */
695
+ export type RecoverySnapshotResult =
696
+ | { started: true; done: Promise<RecoverySnapshotOutcome> }
697
+ | { started: false; reason: "not-needed" | "unsupported" | "rate-limited" };
698
+
699
+ export type RecoverySnapshotSeams = {
700
+ platform?: () => string;
701
+ now?: () => number;
702
+ /** Defaults to an unref'd `/usr/bin/tmutil localsnapshot` child with a 30 s limit. */
703
+ run?: () => Promise<RecoverySnapshotOutcome>;
704
+ };
705
+
706
+ let lastRecoverySnapshot = 0;
707
+ const RECOVERY_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000;
708
+
709
+ function runLocalSnapshot(): Promise<RecoverySnapshotOutcome> {
710
+ return new Promise((resolveOutcome) => {
711
+ let output = "";
712
+ let settled = false;
713
+ const finish = (outcome: RecoverySnapshotOutcome) => {
714
+ if (settled) return;
715
+ settled = true;
716
+ clearTimeout(timer);
717
+ resolveOutcome(outcome);
718
+ };
719
+ const child = spawn("/usr/bin/tmutil", ["localsnapshot"], {
720
+ env: { PATH: "/usr/bin:/bin", LANG: "C" }, stdio: ["ignore", "pipe", "pipe"],
721
+ });
722
+ const timer = setTimeout(() => { child.kill("SIGKILL"); finish({ ok: false, detail: "tmutil timed out after 30 s" }); }, 30_000);
723
+ timer.unref();
724
+ child.unref();
725
+ for (const stream of [child.stdout, child.stderr]) {
726
+ stream?.on("data", (chunk: Buffer) => { output = (output + chunk.toString()).slice(-2048); });
727
+ (stream as { unref?: () => void } | null)?.unref?.();
728
+ }
729
+ child.on("error", (error) => finish({ ok: false, detail: error.message }));
730
+ child.on("close", (code) => finish(code === 0
731
+ ? { ok: true, detail: output.trim() }
732
+ : { ok: false, detail: output.trim() || `tmutil exited ${code}` }));
733
+ });
734
+ }
735
+
736
+ /**
737
+ * Start an APFS local snapshot (`tmutil localsnapshot`) before a confined run
738
+ * whose Outside project level lets it change files outside the project: Write
739
+ * & delete can remove them, and Write can still truncate, chmod or chflags
740
+ * them in place. macOS only; it needs no elevated privileges. The snapshot
741
+ * runs in the background and never blocks or fails the run; callers log a
742
+ * failed outcome. Snapshots are volume-wide and purgeable, so one per 15
743
+ * minutes per process is enough. `PI_SANDBOX_RECOVERY_SNAPSHOT=off` disables it
744
+ * (test suites set it so they do not create real snapshots).
745
+ */
746
+ export function takeRecoverySnapshot(
747
+ permissions: SandboxPermissions | undefined,
748
+ seams: RecoverySnapshotSeams = {},
749
+ ): RecoverySnapshotResult {
750
+ if (!canWrite(permissions?.outsideProject) || process.env.PI_SANDBOX_RECOVERY_SNAPSHOT === "off") {
751
+ return { started: false, reason: "not-needed" };
752
+ }
753
+ if ((seams.platform ?? osPlatform)() !== "darwin") return { started: false, reason: "unsupported" };
754
+ const now = (seams.now ?? Date.now)();
755
+ if (lastRecoverySnapshot && now - lastRecoverySnapshot < RECOVERY_SNAPSHOT_INTERVAL_MS) {
756
+ return { started: false, reason: "rate-limited" };
757
+ }
758
+ lastRecoverySnapshot = now;
759
+ let done: Promise<RecoverySnapshotOutcome>;
760
+ try {
761
+ done = (seams.run ?? runLocalSnapshot)().catch((error: unknown) =>
762
+ ({ ok: false, detail: error instanceof Error ? error.message : String(error) }));
763
+ } catch (error) {
764
+ done = Promise.resolve({ ok: false, detail: error instanceof Error ? error.message : String(error) });
765
+ }
766
+ return { started: true, done };
767
+ }
768
+
769
+ /** Test hook: forget the last snapshot time. */
770
+ export function resetRecoverySnapshotClock(): void {
771
+ lastRecoverySnapshot = 0;
772
+ }
773
+
774
+ function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
775
+ return [...policy.denyWrite, ...(policy.credentialPaths ?? []), ...broad.codePaths,
776
+ ...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
777
+ }
778
+
372
779
  /** Quote a path as an SBPL string literal. */
373
780
  function sbpl(path: string): string {
374
781
  return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
@@ -399,6 +806,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
399
806
  ...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
400
807
  ...(policy.compatibilityWrite ?? []),
401
808
  ];
809
+ if (permissions.outsideProject === "write") throw new Error("Outside project = Write uses the broad-write profile.");
402
810
  const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
403
811
  if (permissions.outsideProject === "off") {
404
812
  rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
@@ -416,16 +824,29 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
416
824
  const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
417
825
  if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
418
826
  else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
419
- if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
827
+ if (canWrite(mode)) rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
420
828
  else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
421
829
  };
422
830
  // Last matching SBPL rule wins. Credential rules override project and outside;
423
831
  // explicit denyWrite entries always override every write allowance.
424
832
  for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
425
833
  scoped(policy.writableRoot, permissions.projectFiles);
834
+ if (permissions.projectFiles === "write" && !TEMP_ROOTS.filter((root) => root !== "/dev")
835
+ .some((root) => contains(canonicalizePath(root, seams), policy.writableRoot))) {
836
+ // Write without delete: only a worktree folder's contents stay removable.
837
+ const project = sbplRegexPath(policy.writableRoot);
838
+ rules.push(`(deny file-write-unlink (subpath ${sbpl(policy.writableRoot)}))`,
839
+ `(allow file-write-unlink (regex #"^${project}/(.+/)?[.]worktrees/."))`,
840
+ `(allow file-write-unlink (regex #"^${project}/(.+/)?[^/]+-worktrees/."))`);
841
+ }
426
842
  for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
427
843
  for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
428
- for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
844
+ // Explicit unlink denials too: a `file-write-unlink` allowance above would
845
+ // otherwise outrank a `file-write*` denial.
846
+ for (const path of policy.denyWrite) {
847
+ rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
848
+ if (permissions.projectFiles === "write") rules.push(`(deny file-write-unlink (subpath ${sbpl(path)}))`);
849
+ }
429
850
  // Protect the directory entries, not their contents: unrelated children can
430
851
  // still be created, while renaming a parent cannot move a denied subtree.
431
852
  for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
@@ -434,13 +855,73 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
434
855
  return [...rules, ""].join("\n");
435
856
  }
436
857
 
858
+ /** Escape a canonical path for an SBPL regular expression literal. */
859
+ function sbplRegexPath(path: string): string {
860
+ return path.replace(/[\\.^$|?*+()[\]{}"]/g, (char) => `\\${char}`);
861
+ }
862
+
863
+ /**
864
+ * The broad-write profile on Seatbelt.
865
+ *
866
+ * Seatbelt has no separate rename operation: `file-write-unlink` guards unlink,
867
+ * rmdir, the source of a rename, and an existing destination a rename replaces.
868
+ * Denying it outside the disposable roots therefore refuses `rm`, `rm -rf` and
869
+ * `mv dir elsewhere`, and also a save that renames a temp file over an existing
870
+ * file there. Writing a file in place (truncate + write) stays allowed.
871
+ */
872
+ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
873
+ const permissions = policy.permissions!;
874
+ const broad = policy.broad!;
875
+ const rules = ["(version 1)", "(allow default)", "(deny file-write*)",
876
+ `(allow file-write* (subpath ${sbpl(broad.home)}))`];
877
+ const project = policy.writableRoot;
878
+ for (const root of [...broad.tempRoots, canonicalizePath("/dev", seams), ...broad.deleteRoots,
879
+ ...(policy.runtimeWrite ?? []), ...(canWrite(permissions.projectFiles) ? [project] : [])]) {
880
+ rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
881
+ }
882
+ if (permissions.projectFiles === "off") rules.push(`(deny file-read* (subpath ${sbpl(project)}))`);
883
+ rules.push("(deny file-write-unlink)");
884
+ for (const root of [...broad.tempRoots, ...broad.deleteRoots]) {
885
+ rules.push(`(allow file-write-unlink (subpath ${sbpl(root)}))`);
886
+ }
887
+ const home = sbplRegexPath(broad.home);
888
+ rules.push(
889
+ `(allow file-write-unlink (regex #"^${home}/[.][^/]+$"))`,
890
+ `(allow file-write-unlink (regex #"^${home}/[.][^/]+/"))`,
891
+ `(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
892
+ `(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
893
+ );
894
+ // The fixed deny list overrides every grant above. A rule naming
895
+ // `file-write-unlink` outranks a `file-write*` wildcard whatever their
896
+ // order (verified on the real kernel), so each protected path also gets an
897
+ // explicit unlink denial; otherwise the removal grants above would reopen it.
898
+ const denyWrite = (path: string) => rules.push(
899
+ `(deny file-write* (subpath ${sbpl(path)}))`, `(deny file-write-unlink (subpath ${sbpl(path)}))`);
900
+ if (!canWrite(permissions.projectFiles)) denyWrite(project);
901
+ for (const path of broad.codePaths) denyWrite(path);
902
+ for (const path of policy.credentialPaths ?? []) {
903
+ rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
904
+ denyWrite(path);
905
+ }
906
+ for (const path of policy.denyWrite) denyWrite(path);
907
+ // Renaming an ancestor would move a protected subtree to an unprotected path,
908
+ // and renaming the workspace's ancestors could redirect the next launch.
909
+ const anchors = [...broadProtectedPaths(policy, broad), project];
910
+ for (const path of [...new Set([...protectedAncestors(anchors), project])]) {
911
+ rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
912
+ }
913
+ if (!permissions.network) rules.push("(deny network*)");
914
+ return [...rules, ""].join("\n");
915
+ }
916
+
437
917
  /** Build the macOS sandbox-exec wrapper and its SBPL profile. */
438
918
  function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
439
919
  // Match on the real (symlink-resolved) path — sandbox-exec evaluates the
440
920
  // canonical path, so /tmp/x must be written as /private/tmp/x.
441
921
  const policy = compile(args.policy, seams, false);
442
922
 
443
- const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
923
+ const profile = policy.broad ? buildBroadProfile(policy, seams)
924
+ : policy.permissions ? buildPermissionProfile(policy, seams) : [
444
925
  "(version 1)",
445
926
  "(allow default)", // permissive base: reads, exec, network
446
927
  "(deny file-write*)", // ...then deny all writes...
@@ -566,6 +1047,7 @@ function buildLinuxSandboxCommand(
566
1047
  // boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
567
1048
  // widening the writable root.
568
1049
  const policy = compile(args.policy, seams, true);
1050
+ if (policy.broad) return buildLinuxBroadCommand(bwrap, args, policy, seams);
569
1051
  if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
570
1052
  const materialize = seams.materializeDenyPath ?? materializeDenyPath;
571
1053
  const denyBinds = policy.denyWrite.flatMap((path) => {
@@ -600,6 +1082,9 @@ function buildLinuxPermissionCommand(
600
1082
  ): SandboxCommand {
601
1083
  const permissions = policy.permissions!;
602
1084
  validateRuntimeHome(policy, seams);
1085
+ if (permissions.projectFiles === "write" || permissions.outsideProject === "write") {
1086
+ throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
1087
+ }
603
1088
  const project = policy.writableRoot;
604
1089
  const credentials = policy.credentialPaths ?? [];
605
1090
  const writableProject = permissions.projectFiles === "read-write";
@@ -772,6 +1257,228 @@ function buildLinuxPermissionCommand(
772
1257
  };
773
1258
  }
774
1259
 
1260
+ type DirectoryEntry = { name: string; directory: boolean; symlink: boolean };
1261
+
1262
+ function systemListDirectory(path: string): DirectoryEntry[] {
1263
+ try {
1264
+ return readdirSync(path, { withFileTypes: true }).map((entry) => ({
1265
+ name: entry.name, directory: entry.isDirectory(), symlink: entry.isSymbolicLink(),
1266
+ }));
1267
+ } catch {
1268
+ return [];
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Worktree folders (`.worktrees`, `*-worktrees`) within three levels of home.
1274
+ * Bounded so a large home cannot stall a launch; a folder created after launch
1275
+ * or deeper than that stays read-only on Linux.
1276
+ */
1277
+ function discoverWorktreeFolders(home: string, list: (path: string) => DirectoryEntry[]): string[] {
1278
+ const found: string[] = [];
1279
+ let budget = 4000;
1280
+ const walk = (directory: string, depth: number) => {
1281
+ for (const entry of list(directory)) {
1282
+ if (--budget < 0 || !entry.directory || entry.symlink) continue;
1283
+ const path = join(directory, entry.name);
1284
+ if (entry.name === ".worktrees" || entry.name.endsWith("-worktrees")) found.push(path);
1285
+ else if (!entry.name.startsWith(".") && depth < 3) walk(path, depth + 1);
1286
+ }
1287
+ };
1288
+ walk(home, 1);
1289
+ return found;
1290
+ }
1291
+
1292
+ /** Empty, mode-000 mask sources a hidden credential is bound over. */
1293
+ function maskSources(profilePath: string): { directory: string; file: string } {
1294
+ const base = join(dirname(profilePath), ".pi-sandbox-mask");
1295
+ mkdirSync(base, { recursive: true, mode: 0o700 });
1296
+ const directory = join(base, "directory");
1297
+ const file = join(base, "file");
1298
+ if (!existsSync(directory)) mkdirSync(directory, { mode: 0o000 });
1299
+ if (!existsSync(file)) closeSync(openSync(file, "wx", 0o000));
1300
+ for (const [path, isDirectory] of [[directory, true], [file, false]] as const) {
1301
+ const info = lstatSync(path);
1302
+ if (info.isDirectory() !== isDirectory || (info.mode & 0o777) !== 0 || (!isDirectory && info.size !== 0)) {
1303
+ throw new Error(`Unsafe sandbox mask source: ${path}`);
1304
+ }
1305
+ }
1306
+ return { directory, file };
1307
+ }
1308
+
1309
+ /**
1310
+ * The broad-write profile on Bubblewrap.
1311
+ *
1312
+ * Mount namespaces cannot separate removal from writing, and Landlock is not
1313
+ * reachable from Node or bwrap here, so Linux uses the documented fallback:
1314
+ * the root is read-only; temp, the workspace, hidden home entries and worktree
1315
+ * folders are writable (removal included); ordinary top-level home folders and
1316
+ * home itself stay read-only, so sibling repositories cannot even be edited in
1317
+ * place there. Credentials are masked by empty mode-000 mounts; code that runs
1318
+ * later and caller-denied paths are read-only binds. Every writable bind and
1319
+ * protected leaf is a mount point, which Linux refuses to rename. A protected
1320
+ * symlink inside a writable directory (a stow link in `~/.config`) makes that
1321
+ * directory read-only with each existing entry bound writable again, so only
1322
+ * new top-level entries there are refused.
1323
+ */
1324
+ function buildLinuxBroadCommand(
1325
+ bwrap: string,
1326
+ args: SandboxCommandArgs,
1327
+ policy: CompiledSandboxWritePolicy,
1328
+ seams: SandboxSeams,
1329
+ ): SandboxCommand {
1330
+ const permissions = policy.permissions!;
1331
+ const broad = policy.broad!;
1332
+ const project = policy.writableRoot;
1333
+ if (permissions.projectFiles === "off") {
1334
+ throw new Error("Linux bubblewrap cannot hide a project inside the broad-write profile's visible root.");
1335
+ }
1336
+ if (permissions.projectFiles === "write") {
1337
+ throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
1338
+ }
1339
+ const credentials = policy.credentialPaths ?? [];
1340
+ const writeDenied = [...broad.codePaths, ...policy.denyWrite];
1341
+ if (permissions.projectFiles === "read-write" &&
1342
+ [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
1343
+ throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
1344
+ }
1345
+ const list = seams.listDirectory ?? systemListDirectory;
1346
+ const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
1347
+ const protectedPaths = [...credentials, ...writeDenied];
1348
+ const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
1349
+
1350
+ const writable = new Set<string>();
1351
+ for (const root of broad.tempRoots) if (exists(root)) writable.add(root);
1352
+ for (const entry of list(broad.home)) {
1353
+ if (entry.symlink) continue;
1354
+ const path = join(broad.home, entry.name);
1355
+ if (entry.name.startsWith(".") && !insideProtected(path)) writable.add(path);
1356
+ }
1357
+ for (const path of discoverWorktreeFolders(broad.home, list)) if (!insideProtected(path)) writable.add(path);
1358
+ for (const path of [...broad.deleteRoots, ...(policy.runtimeWrite ?? [])]) {
1359
+ if (path === project || !exists(path)) continue;
1360
+ if (insideProtected(path)) throw new Error(`Writable sandbox root overlaps a protected path: ${path}`);
1361
+ writable.add(path);
1362
+ }
1363
+ if (permissions.projectFiles === "read-write") writable.add(project);
1364
+ const writableRoots = [...writable];
1365
+ const writableAt = (path: string) => writableRoots.some((root) => contains(root, path));
1366
+
1367
+ const mask = (seams.maskSources ?? maskSources)(args.profilePath);
1368
+ const materialize = seams.materializeDenyPath ?? materializeDenyPath;
1369
+ const hidden: { path: string; source: string }[] = [];
1370
+ const readOnly = new Set<string>();
1371
+ const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
1372
+ .some((name) => canonicalizePath(join(broad.home, name), seams) === path);
1373
+ // Directories holding a protected symlink: read-only, so the link cannot be
1374
+ // replaced, with their existing entries bound writable again below.
1375
+ const linkParents = new Set<string>();
1376
+ const danglingTargets = new Set(protectedPaths.flatMap((path) => walkSymlinks(path).unresolved));
1377
+ const guard = (path: string, hide: boolean) => {
1378
+ let directory: boolean | undefined;
1379
+ let link = false;
1380
+ try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
1381
+ if (canonicalizePath(path, seams) !== path || (link && unresolvable(path))) {
1382
+ // A literal twin of a canonical deny entry: its target is guarded on
1383
+ // its own. A looping link resolves to nothing (ELOOP), and a dangling
1384
+ // one's missing target is guarded below, so only the link itself
1385
+ // needs guarding here. A symlink leaf inside a writable root could be
1386
+ // replaced, so its directory is protected; home already is.
1387
+ if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
1388
+ return;
1389
+ }
1390
+ if (danglingTargets.has(path)) {
1391
+ // Where a dangling link would land: nothing may be created there.
1392
+ // Lock its nearest existing ancestor (entries stay writable, see
1393
+ // below) rather than plant a placeholder in the user's files. Temp
1394
+ // and the workspace root stay open to new entries, so a placeholder
1395
+ // is bound there instead, failing closed if it cannot be made.
1396
+ if (!writableAt(path)) return;
1397
+ let ancestor = dirname(path);
1398
+ while (!exists(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
1399
+ if (ancestor === project || broad.tempRoots.includes(ancestor)) {
1400
+ if (!materialize(path)) throw new Error(`Cannot protect denied path: ${path}`);
1401
+ readOnly.add(path);
1402
+ } else if (ancestor !== broad.home) {
1403
+ linkParents.add(ancestor);
1404
+ }
1405
+ return;
1406
+ }
1407
+ if (!exists(path)) {
1408
+ if (!writableAt(path)) return; // Nothing inside the sandbox can create it.
1409
+ let ancestor = dirname(path);
1410
+ while (!exists(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
1411
+ if (directoryLocation(path)) {
1412
+ (seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 })))(path);
1413
+ directory = true;
1414
+ } else if (writableRoots.includes(ancestor) || contains(project, ancestor) ||
1415
+ broad.tempRoots.some((root) => contains(root, ancestor))) {
1416
+ if (!materialize(path)) throw new Error(`Cannot protect denied path: ${path}`);
1417
+ } else {
1418
+ readOnly.add(ancestor);
1419
+ return;
1420
+ }
1421
+ }
1422
+ if (hide) {
1423
+ directory ??= seams.isDirectory ? seams.isDirectory(path) : statSync(path).isDirectory();
1424
+ hidden.push({ path, source: directory ? mask.directory : mask.file });
1425
+ } else {
1426
+ readOnly.add(path);
1427
+ }
1428
+ };
1429
+ for (const path of credentials) guard(path, true);
1430
+ for (const path of writeDenied) guard(path, false);
1431
+ if (permissions.projectFiles === "read") readOnly.add(project);
1432
+
1433
+ // A read-only link parent keeps its existing entries writable: each one is
1434
+ // bound back read-write (a mount point, so it cannot be renamed away either).
1435
+ // Only new top-level entries in that directory are lost. Protected entries
1436
+ // keep their own mounts, and a symlink entry is never bound: a bind follows
1437
+ // it, and writes through it already meet its target's own mounts.
1438
+ const rebound = new Set<string>();
1439
+ for (const parent of linkParents) readOnly.add(parent);
1440
+ for (const parent of linkParents) {
1441
+ for (const entry of list(parent)) {
1442
+ if (entry.symlink) continue;
1443
+ const path = join(parent, entry.name);
1444
+ if (insideProtected(path) || readOnly.has(path) || writable.has(path)) continue;
1445
+ rebound.add(path);
1446
+ }
1447
+ }
1448
+
1449
+ // Intermediate directories between a writable root and anything bound
1450
+ // inside it become mount points too, so none of them can be renamed away.
1451
+ const anchors = new Set<string>();
1452
+ for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
1453
+ for (const parent of protectedAncestors([target])) {
1454
+ if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
1455
+ !readOnly.has(parent) && !writable.has(parent) && !rebound.has(parent)) anchors.add(parent);
1456
+ }
1457
+ }
1458
+ type Mount = { option: "--bind" | "--bind-try" | "--ro-bind"; source: string; path: string; rank: number };
1459
+ const mounts: Mount[] = [
1460
+ ...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
1461
+ ...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
1462
+ // `-try`: an entry removed before a resumed launch stays read-only instead of failing it.
1463
+ ...[...rebound].map((path) => ({ option: "--bind-try" as const, source: path, path, rank: 1 })),
1464
+ ...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
1465
+ ...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
1466
+ ].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
1467
+ const seen = new Set<string>();
1468
+ const fileArgs = ["--ro-bind", "/", "/", "--dev", "/dev"];
1469
+ for (const mount of mounts) {
1470
+ const key = `${mount.option}\0${mount.source}\0${mount.path}`;
1471
+ if (seen.has(key)) continue;
1472
+ seen.add(key);
1473
+ fileArgs.push(mount.option, mount.source, mount.path);
1474
+ }
1475
+ return {
1476
+ file: bwrap,
1477
+ fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1478
+ "--", args.execPath, ...args.execArgs],
1479
+ };
1480
+ }
1481
+
775
1482
  function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
776
1483
  const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
777
1484
  if (!bwrap) return undefined;