pi-better-background-tasks 0.2.20 → 0.4.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.
- package/README.md +46 -2
- package/package.json +1 -1
- package/src/failures.ts +101 -6
- package/src/index.ts +5 -0
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +4 -3
- package/src/output.ts +783 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +228 -33
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +358 -29
- package/src/shared-failure-observations.ts +833 -23
- package/src/shared-log-utils.ts +1139 -2
- package/src/shared-navigator.ts +12 -10
- package/src/shared-sandbox-core.ts +660 -17
- package/src/tools.ts +201 -156
- package/src/types.ts +23 -0
|
@@ -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:
|
|
41
|
-
outsideProject:
|
|
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,153 @@ 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 ?? []).
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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). A chain such as
|
|
418
|
+
* `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
|
|
419
|
+
* in a removable directory; replacing it would retarget the protected entry, so
|
|
420
|
+
* each hop the user could replace is protected too. Bounded: a loop or more
|
|
421
|
+
* than 40 hops stops the walk (such a path resolves to nothing).
|
|
422
|
+
*/
|
|
423
|
+
function symlinkHops(path: string): string[] {
|
|
424
|
+
const hops: string[] = [];
|
|
425
|
+
const seen = new Set<string>();
|
|
426
|
+
let followed = 0;
|
|
427
|
+
const absolute = resolve(path);
|
|
428
|
+
let current = parse(absolute).root;
|
|
429
|
+
let pending = absolute.slice(current.length).split(sep);
|
|
430
|
+
while (pending.length) {
|
|
431
|
+
const component = pending.shift()!;
|
|
432
|
+
if (!component || component === ".") continue;
|
|
433
|
+
if (component === "..") { current = dirname(current); continue; }
|
|
434
|
+
const entry = join(current, component);
|
|
435
|
+
let link: boolean;
|
|
436
|
+
try { link = lstatSync(entry).isSymbolicLink(); } catch { break; }
|
|
437
|
+
if (!link) { current = entry; continue; }
|
|
438
|
+
if (++followed > 40 || seen.has(entry)) break;
|
|
439
|
+
seen.add(entry);
|
|
440
|
+
// Only a link the user can replace matters (not e.g. macOS's root-owned /tmp -> private/tmp).
|
|
441
|
+
try { accessSync(current, constants.W_OK); hops.push(entry); } catch { /* immutable entry */ }
|
|
442
|
+
let target: string;
|
|
443
|
+
try { target = readlinkSync(entry); } catch { break; }
|
|
444
|
+
if (isAbsolute(target)) {
|
|
445
|
+
current = parse(target).root;
|
|
446
|
+
pending = [...target.slice(current.length).split(sep), ...pending];
|
|
447
|
+
} else {
|
|
448
|
+
pending = [...target.split(sep), ...pending];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return hops;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
|
|
455
|
+
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
456
|
+
return [...new Set([
|
|
457
|
+
...credentialFilePaths(home, seams),
|
|
458
|
+
...[...CREDENTIAL_LOCATIONS, ...BROAD_CREDENTIAL_LOCATIONS].flatMap((name) => lexicalAndCanonical(home, name, seams)),
|
|
459
|
+
...(configuredAgentDir ? [resolve(expandHome(configuredAgentDir, home), "auth.json"),
|
|
460
|
+
...symlinkHops(resolve(expandHome(configuredAgentDir, home), "auth.json"))] : []),
|
|
461
|
+
])].sort();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function compileBroadWrite(
|
|
465
|
+
policy: SandboxWritePolicy,
|
|
466
|
+
writableRoot: string,
|
|
467
|
+
runtimeWrite: readonly string[],
|
|
468
|
+
seams: SandboxSeams,
|
|
469
|
+
): CompiledBroadWrite {
|
|
470
|
+
const permissions = policy.permissions!;
|
|
471
|
+
const home = canonicalizePath(policy.home, seams);
|
|
472
|
+
if (home === sep || RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
|
|
473
|
+
throw new Error("The broad-write sandbox profile needs a real home directory, not / or a system runtime root.");
|
|
474
|
+
}
|
|
475
|
+
if (writableRoot === home || contains(writableRoot, home)) {
|
|
476
|
+
throw new Error("The broad-write sandbox profile needs a project directory inside or beside home, not home itself.");
|
|
477
|
+
}
|
|
478
|
+
const tempRoots = [...new Set((BROAD_TEMP_ROOTS[currentPlatform(seams)] ?? [])
|
|
479
|
+
.map((path) => canonicalizePath(path, seams)))];
|
|
480
|
+
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
481
|
+
const codePaths = [...new Set([
|
|
482
|
+
...CODE_LATER_LOCATIONS.flatMap((name) => lexicalAndCanonical(home, name, seams)),
|
|
483
|
+
...(configuredAgentDir ? [resolve(expandHome(configuredAgentDir, home)),
|
|
484
|
+
...symlinkHops(expandHome(configuredAgentDir, home)),
|
|
485
|
+
canonicalizePath(expandHome(configuredAgentDir, home), seams)] : []),
|
|
486
|
+
])].sort();
|
|
487
|
+
return {
|
|
488
|
+
home,
|
|
489
|
+
tempRoots,
|
|
490
|
+
deleteRoots: [...new Set([
|
|
491
|
+
...(permissions.projectFiles === "read-write" ? [writableRoot] : []),
|
|
492
|
+
...runtimeWrite,
|
|
493
|
+
])].sort(),
|
|
494
|
+
codePaths,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Inside a git worktree folder (`.worktrees/`, `*-worktrees/`) below `root`: its contents, not the folder. */
|
|
499
|
+
function insideWorktreeFolder(path: string, root: string): boolean {
|
|
500
|
+
if (!contains(root, path) || path === root) return false;
|
|
501
|
+
return path.slice(root.length + 1).split(sep).slice(0, -1)
|
|
502
|
+
.some((part) => part === ".worktrees" || /^[^/]+-worktrees$/.test(part));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Hidden entries of home, and git worktree folders anywhere under it. */
|
|
506
|
+
function disposableHomePath(path: string, home: string): boolean {
|
|
507
|
+
if (!contains(home, path) || path === home) return false;
|
|
508
|
+
return path.slice(home.length + 1).startsWith(".") || insideWorktreeFolder(path, home);
|
|
509
|
+
}
|
|
510
|
+
|
|
299
511
|
/**
|
|
300
512
|
* Canonicalize a write policy once so containment checks and backend rules
|
|
301
513
|
* agree on exactly which paths they are talking about.
|
|
@@ -329,6 +541,11 @@ export function evaluateReadAccess(
|
|
|
329
541
|
const path = canonicalizePath(target, seams);
|
|
330
542
|
const permissions = policy.permissions;
|
|
331
543
|
if (!permissions) return { allowed: true, path };
|
|
544
|
+
if (policy.broad) {
|
|
545
|
+
const denied = isCredential(path, policy) ||
|
|
546
|
+
(permissions.projectFiles === "off" && contains(policy.writableRoot, path));
|
|
547
|
+
return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
548
|
+
}
|
|
332
549
|
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
333
550
|
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
334
551
|
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
@@ -349,6 +566,7 @@ export function evaluateWriteAccess(
|
|
|
349
566
|
seams: SandboxSeams = {},
|
|
350
567
|
): WriteAccessDecision {
|
|
351
568
|
const path = canonicalizePath(target, seams);
|
|
569
|
+
if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
|
|
352
570
|
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
353
571
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
354
572
|
}
|
|
@@ -364,11 +582,180 @@ export function evaluateWriteAccess(
|
|
|
364
582
|
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
365
583
|
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
366
584
|
: policy.permissions.outsideProject;
|
|
367
|
-
if (mode
|
|
585
|
+
if (!canWrite(mode)) return { allowed: false, path, reason: "permission-denied" };
|
|
368
586
|
}
|
|
369
587
|
return { allowed: true, path };
|
|
370
588
|
}
|
|
371
589
|
|
|
590
|
+
function evaluateBroadWrite(
|
|
591
|
+
path: string,
|
|
592
|
+
policy: CompiledSandboxWritePolicy,
|
|
593
|
+
broad: CompiledBroadWrite,
|
|
594
|
+
seams: SandboxSeams,
|
|
595
|
+
): WriteAccessDecision {
|
|
596
|
+
for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
|
|
597
|
+
if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
598
|
+
}
|
|
599
|
+
if (isCredential(path, policy)) return { allowed: false, path, reason: "permission-denied" };
|
|
600
|
+
if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
|
|
601
|
+
if (contains(policy.writableRoot, path)) {
|
|
602
|
+
return canWrite(policy.permissions!.projectFiles)
|
|
603
|
+
? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
|
|
604
|
+
}
|
|
605
|
+
if ([...broad.tempRoots, ...broad.deleteRoots, canonicalizePath("/dev", seams)].some((root) => contains(root, path))) {
|
|
606
|
+
return { allowed: true, path };
|
|
607
|
+
}
|
|
608
|
+
if (contains(broad.home, path)) {
|
|
609
|
+
// Linux cannot separate removal from writing, so its fallback keeps
|
|
610
|
+
// ordinary (non-dot) top-level home folders read-only instead.
|
|
611
|
+
if (currentPlatform(seams) === "linux" && !disposableHomePath(path, broad.home)) {
|
|
612
|
+
return { allowed: false, path, reason: "permission-denied" };
|
|
613
|
+
}
|
|
614
|
+
return { allowed: true, path };
|
|
615
|
+
}
|
|
616
|
+
return { allowed: false, path, reason: "permission-denied" };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Decide whether `target` may be removed or renamed away (unlink, rmdir,
|
|
621
|
+
* rename of the source, or rename over an existing destination). Outside the
|
|
622
|
+
* broad-write profile removal follows write access.
|
|
623
|
+
*/
|
|
624
|
+
export function evaluateDeleteAccess(
|
|
625
|
+
target: string,
|
|
626
|
+
policy: CompiledSandboxWritePolicy,
|
|
627
|
+
seams: SandboxSeams = {},
|
|
628
|
+
): WriteAccessDecision {
|
|
629
|
+
const write = evaluateWriteAccess(target, policy, seams);
|
|
630
|
+
const broad = policy.broad;
|
|
631
|
+
if (!write.allowed) return write;
|
|
632
|
+
const path = write.path;
|
|
633
|
+
if (!broad) {
|
|
634
|
+
// Project files = Write outside the broad profile: nothing in the
|
|
635
|
+
// project is removable except worktree folders' contents.
|
|
636
|
+
return policy.permissions?.projectFiles === "write" && contains(policy.writableRoot, path) &&
|
|
637
|
+
!insideWorktreeFolder(path, policy.writableRoot)
|
|
638
|
+
? { allowed: false, path, reason: "delete-denied" } : write;
|
|
639
|
+
}
|
|
640
|
+
const anchors = broadProtectedPaths(policy, broad);
|
|
641
|
+
if (anchors.some((protectedPath) => protectedPath !== path && contains(path, protectedPath))) {
|
|
642
|
+
return { allowed: false, path, reason: "delete-denied" };
|
|
643
|
+
}
|
|
644
|
+
if (path === policy.writableRoot || contains(path, policy.writableRoot)) {
|
|
645
|
+
return { allowed: false, path, reason: "delete-denied" };
|
|
646
|
+
}
|
|
647
|
+
if ([...broad.tempRoots, ...broad.deleteRoots].some((root) => contains(root, path)) ||
|
|
648
|
+
disposableHomePath(path, broad.home)) {
|
|
649
|
+
return write;
|
|
650
|
+
}
|
|
651
|
+
return { allowed: false, path, reason: "delete-denied" };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Whether an already-canonical path lies where a Write level still allows
|
|
656
|
+
* removal: temp, hidden home entries, worktree folders, a Write & delete
|
|
657
|
+
* workspace and runtime scratch (broad profile), or a worktree folder inside a
|
|
658
|
+
* Project files = Write workspace. The leaf is not re-resolved, so a caller can
|
|
659
|
+
* ask about a symlink's own directory entry.
|
|
660
|
+
*/
|
|
661
|
+
export function isRemovableUnderWrite(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
662
|
+
const broad = policy.broad;
|
|
663
|
+
if (broad) {
|
|
664
|
+
if (broadProtectedPaths(policy, broad).some((guard) => contains(guard, path) || contains(path, guard))) return false;
|
|
665
|
+
return [...broad.tempRoots, ...broad.deleteRoots].some((root) => contains(root, path)) ||
|
|
666
|
+
disposableHomePath(path, broad.home);
|
|
667
|
+
}
|
|
668
|
+
return policy.permissions?.projectFiles === "write" && insideWorktreeFolder(path, policy.writableRoot);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Outcome of a finished snapshot attempt. */
|
|
672
|
+
export type RecoverySnapshotOutcome = { ok: boolean; detail: string };
|
|
673
|
+
|
|
674
|
+
/** Result of a best-effort recovery snapshot request. */
|
|
675
|
+
export type RecoverySnapshotResult =
|
|
676
|
+
| { started: true; done: Promise<RecoverySnapshotOutcome> }
|
|
677
|
+
| { started: false; reason: "not-needed" | "unsupported" | "rate-limited" };
|
|
678
|
+
|
|
679
|
+
export type RecoverySnapshotSeams = {
|
|
680
|
+
platform?: () => string;
|
|
681
|
+
now?: () => number;
|
|
682
|
+
/** Defaults to an unref'd `/usr/bin/tmutil localsnapshot` child with a 30 s limit. */
|
|
683
|
+
run?: () => Promise<RecoverySnapshotOutcome>;
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
let lastRecoverySnapshot = 0;
|
|
687
|
+
const RECOVERY_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000;
|
|
688
|
+
|
|
689
|
+
function runLocalSnapshot(): Promise<RecoverySnapshotOutcome> {
|
|
690
|
+
return new Promise((resolveOutcome) => {
|
|
691
|
+
let output = "";
|
|
692
|
+
let settled = false;
|
|
693
|
+
const finish = (outcome: RecoverySnapshotOutcome) => {
|
|
694
|
+
if (settled) return;
|
|
695
|
+
settled = true;
|
|
696
|
+
clearTimeout(timer);
|
|
697
|
+
resolveOutcome(outcome);
|
|
698
|
+
};
|
|
699
|
+
const child = spawn("/usr/bin/tmutil", ["localsnapshot"], {
|
|
700
|
+
env: { PATH: "/usr/bin:/bin", LANG: "C" }, stdio: ["ignore", "pipe", "pipe"],
|
|
701
|
+
});
|
|
702
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); finish({ ok: false, detail: "tmutil timed out after 30 s" }); }, 30_000);
|
|
703
|
+
timer.unref();
|
|
704
|
+
child.unref();
|
|
705
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
706
|
+
stream?.on("data", (chunk: Buffer) => { output = (output + chunk.toString()).slice(-2048); });
|
|
707
|
+
(stream as { unref?: () => void } | null)?.unref?.();
|
|
708
|
+
}
|
|
709
|
+
child.on("error", (error) => finish({ ok: false, detail: error.message }));
|
|
710
|
+
child.on("close", (code) => finish(code === 0
|
|
711
|
+
? { ok: true, detail: output.trim() }
|
|
712
|
+
: { ok: false, detail: output.trim() || `tmutil exited ${code}` }));
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Start an APFS local snapshot (`tmutil localsnapshot`) before a confined run
|
|
718
|
+
* whose Outside project level lets it change files outside the project: Write
|
|
719
|
+
* & delete can remove them, and Write can still truncate, chmod or chflags
|
|
720
|
+
* them in place. macOS only; it needs no elevated privileges. The snapshot
|
|
721
|
+
* runs in the background and never blocks or fails the run; callers log a
|
|
722
|
+
* failed outcome. Snapshots are volume-wide and purgeable, so one per 15
|
|
723
|
+
* minutes per process is enough. `PI_SANDBOX_RECOVERY_SNAPSHOT=off` disables it
|
|
724
|
+
* (test suites set it so they do not create real snapshots).
|
|
725
|
+
*/
|
|
726
|
+
export function takeRecoverySnapshot(
|
|
727
|
+
permissions: SandboxPermissions | undefined,
|
|
728
|
+
seams: RecoverySnapshotSeams = {},
|
|
729
|
+
): RecoverySnapshotResult {
|
|
730
|
+
if (!canWrite(permissions?.outsideProject) || process.env.PI_SANDBOX_RECOVERY_SNAPSHOT === "off") {
|
|
731
|
+
return { started: false, reason: "not-needed" };
|
|
732
|
+
}
|
|
733
|
+
if ((seams.platform ?? osPlatform)() !== "darwin") return { started: false, reason: "unsupported" };
|
|
734
|
+
const now = (seams.now ?? Date.now)();
|
|
735
|
+
if (lastRecoverySnapshot && now - lastRecoverySnapshot < RECOVERY_SNAPSHOT_INTERVAL_MS) {
|
|
736
|
+
return { started: false, reason: "rate-limited" };
|
|
737
|
+
}
|
|
738
|
+
lastRecoverySnapshot = now;
|
|
739
|
+
let done: Promise<RecoverySnapshotOutcome>;
|
|
740
|
+
try {
|
|
741
|
+
done = (seams.run ?? runLocalSnapshot)().catch((error: unknown) =>
|
|
742
|
+
({ ok: false, detail: error instanceof Error ? error.message : String(error) }));
|
|
743
|
+
} catch (error) {
|
|
744
|
+
done = Promise.resolve({ ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
745
|
+
}
|
|
746
|
+
return { started: true, done };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Test hook: forget the last snapshot time. */
|
|
750
|
+
export function resetRecoverySnapshotClock(): void {
|
|
751
|
+
lastRecoverySnapshot = 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
|
|
755
|
+
return [...policy.denyWrite, ...(policy.credentialPaths ?? []), ...broad.codePaths,
|
|
756
|
+
...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
|
|
757
|
+
}
|
|
758
|
+
|
|
372
759
|
/** Quote a path as an SBPL string literal. */
|
|
373
760
|
function sbpl(path: string): string {
|
|
374
761
|
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
@@ -399,6 +786,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
399
786
|
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
400
787
|
...(policy.compatibilityWrite ?? []),
|
|
401
788
|
];
|
|
789
|
+
if (permissions.outsideProject === "write") throw new Error("Outside project = Write uses the broad-write profile.");
|
|
402
790
|
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
403
791
|
if (permissions.outsideProject === "off") {
|
|
404
792
|
rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
|
|
@@ -416,16 +804,29 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
416
804
|
const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
|
|
417
805
|
if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
|
|
418
806
|
else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
419
|
-
if (mode
|
|
807
|
+
if (canWrite(mode)) rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
420
808
|
else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
|
|
421
809
|
};
|
|
422
810
|
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
423
811
|
// explicit denyWrite entries always override every write allowance.
|
|
424
812
|
for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
|
|
425
813
|
scoped(policy.writableRoot, permissions.projectFiles);
|
|
814
|
+
if (permissions.projectFiles === "write" && !TEMP_ROOTS.filter((root) => root !== "/dev")
|
|
815
|
+
.some((root) => contains(canonicalizePath(root, seams), policy.writableRoot))) {
|
|
816
|
+
// Write without delete: only a worktree folder's contents stay removable.
|
|
817
|
+
const project = sbplRegexPath(policy.writableRoot);
|
|
818
|
+
rules.push(`(deny file-write-unlink (subpath ${sbpl(policy.writableRoot)}))`,
|
|
819
|
+
`(allow file-write-unlink (regex #"^${project}/(.+/)?[.]worktrees/."))`,
|
|
820
|
+
`(allow file-write-unlink (regex #"^${project}/(.+/)?[^/]+-worktrees/."))`);
|
|
821
|
+
}
|
|
426
822
|
for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
|
|
427
823
|
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
428
|
-
|
|
824
|
+
// Explicit unlink denials too: a `file-write-unlink` allowance above would
|
|
825
|
+
// otherwise outrank a `file-write*` denial.
|
|
826
|
+
for (const path of policy.denyWrite) {
|
|
827
|
+
rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
828
|
+
if (permissions.projectFiles === "write") rules.push(`(deny file-write-unlink (subpath ${sbpl(path)}))`);
|
|
829
|
+
}
|
|
429
830
|
// Protect the directory entries, not their contents: unrelated children can
|
|
430
831
|
// still be created, while renaming a parent cannot move a denied subtree.
|
|
431
832
|
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
@@ -434,13 +835,73 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
434
835
|
return [...rules, ""].join("\n");
|
|
435
836
|
}
|
|
436
837
|
|
|
838
|
+
/** Escape a canonical path for an SBPL regular expression literal. */
|
|
839
|
+
function sbplRegexPath(path: string): string {
|
|
840
|
+
return path.replace(/[\\.^$|?*+()[\]{}"]/g, (char) => `\\${char}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* The broad-write profile on Seatbelt.
|
|
845
|
+
*
|
|
846
|
+
* Seatbelt has no separate rename operation: `file-write-unlink` guards unlink,
|
|
847
|
+
* rmdir, the source of a rename, and an existing destination a rename replaces.
|
|
848
|
+
* Denying it outside the disposable roots therefore refuses `rm`, `rm -rf` and
|
|
849
|
+
* `mv dir elsewhere`, and also a save that renames a temp file over an existing
|
|
850
|
+
* file there. Writing a file in place (truncate + write) stays allowed.
|
|
851
|
+
*/
|
|
852
|
+
function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
|
|
853
|
+
const permissions = policy.permissions!;
|
|
854
|
+
const broad = policy.broad!;
|
|
855
|
+
const rules = ["(version 1)", "(allow default)", "(deny file-write*)",
|
|
856
|
+
`(allow file-write* (subpath ${sbpl(broad.home)}))`];
|
|
857
|
+
const project = policy.writableRoot;
|
|
858
|
+
for (const root of [...broad.tempRoots, canonicalizePath("/dev", seams), ...broad.deleteRoots,
|
|
859
|
+
...(policy.runtimeWrite ?? []), ...(canWrite(permissions.projectFiles) ? [project] : [])]) {
|
|
860
|
+
rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
861
|
+
}
|
|
862
|
+
if (permissions.projectFiles === "off") rules.push(`(deny file-read* (subpath ${sbpl(project)}))`);
|
|
863
|
+
rules.push("(deny file-write-unlink)");
|
|
864
|
+
for (const root of [...broad.tempRoots, ...broad.deleteRoots]) {
|
|
865
|
+
rules.push(`(allow file-write-unlink (subpath ${sbpl(root)}))`);
|
|
866
|
+
}
|
|
867
|
+
const home = sbplRegexPath(broad.home);
|
|
868
|
+
rules.push(
|
|
869
|
+
`(allow file-write-unlink (regex #"^${home}/[.][^/]+$"))`,
|
|
870
|
+
`(allow file-write-unlink (regex #"^${home}/[.][^/]+/"))`,
|
|
871
|
+
`(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
|
|
872
|
+
`(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
|
|
873
|
+
);
|
|
874
|
+
// The fixed deny list overrides every grant above. A rule naming
|
|
875
|
+
// `file-write-unlink` outranks a `file-write*` wildcard whatever their
|
|
876
|
+
// order (verified on the real kernel), so each protected path also gets an
|
|
877
|
+
// explicit unlink denial; otherwise the removal grants above would reopen it.
|
|
878
|
+
const denyWrite = (path: string) => rules.push(
|
|
879
|
+
`(deny file-write* (subpath ${sbpl(path)}))`, `(deny file-write-unlink (subpath ${sbpl(path)}))`);
|
|
880
|
+
if (!canWrite(permissions.projectFiles)) denyWrite(project);
|
|
881
|
+
for (const path of broad.codePaths) denyWrite(path);
|
|
882
|
+
for (const path of policy.credentialPaths ?? []) {
|
|
883
|
+
rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
884
|
+
denyWrite(path);
|
|
885
|
+
}
|
|
886
|
+
for (const path of policy.denyWrite) denyWrite(path);
|
|
887
|
+
// Renaming an ancestor would move a protected subtree to an unprotected path,
|
|
888
|
+
// and renaming the workspace's ancestors could redirect the next launch.
|
|
889
|
+
const anchors = [...broadProtectedPaths(policy, broad), project];
|
|
890
|
+
for (const path of [...new Set([...protectedAncestors(anchors), project])]) {
|
|
891
|
+
rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
892
|
+
}
|
|
893
|
+
if (!permissions.network) rules.push("(deny network*)");
|
|
894
|
+
return [...rules, ""].join("\n");
|
|
895
|
+
}
|
|
896
|
+
|
|
437
897
|
/** Build the macOS sandbox-exec wrapper and its SBPL profile. */
|
|
438
898
|
function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
|
|
439
899
|
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
440
900
|
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
441
901
|
const policy = compile(args.policy, seams, false);
|
|
442
902
|
|
|
443
|
-
const profile = policy.
|
|
903
|
+
const profile = policy.broad ? buildBroadProfile(policy, seams)
|
|
904
|
+
: policy.permissions ? buildPermissionProfile(policy, seams) : [
|
|
444
905
|
"(version 1)",
|
|
445
906
|
"(allow default)", // permissive base: reads, exec, network
|
|
446
907
|
"(deny file-write*)", // ...then deny all writes...
|
|
@@ -566,6 +1027,7 @@ function buildLinuxSandboxCommand(
|
|
|
566
1027
|
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
567
1028
|
// widening the writable root.
|
|
568
1029
|
const policy = compile(args.policy, seams, true);
|
|
1030
|
+
if (policy.broad) return buildLinuxBroadCommand(bwrap, args, policy, seams);
|
|
569
1031
|
if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
|
|
570
1032
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
571
1033
|
const denyBinds = policy.denyWrite.flatMap((path) => {
|
|
@@ -600,6 +1062,9 @@ function buildLinuxPermissionCommand(
|
|
|
600
1062
|
): SandboxCommand {
|
|
601
1063
|
const permissions = policy.permissions!;
|
|
602
1064
|
validateRuntimeHome(policy, seams);
|
|
1065
|
+
if (permissions.projectFiles === "write" || permissions.outsideProject === "write") {
|
|
1066
|
+
throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
|
|
1067
|
+
}
|
|
603
1068
|
const project = policy.writableRoot;
|
|
604
1069
|
const credentials = policy.credentialPaths ?? [];
|
|
605
1070
|
const writableProject = permissions.projectFiles === "read-write";
|
|
@@ -772,6 +1237,184 @@ function buildLinuxPermissionCommand(
|
|
|
772
1237
|
};
|
|
773
1238
|
}
|
|
774
1239
|
|
|
1240
|
+
type DirectoryEntry = { name: string; directory: boolean; symlink: boolean };
|
|
1241
|
+
|
|
1242
|
+
function systemListDirectory(path: string): DirectoryEntry[] {
|
|
1243
|
+
try {
|
|
1244
|
+
return readdirSync(path, { withFileTypes: true }).map((entry) => ({
|
|
1245
|
+
name: entry.name, directory: entry.isDirectory(), symlink: entry.isSymbolicLink(),
|
|
1246
|
+
}));
|
|
1247
|
+
} catch {
|
|
1248
|
+
return [];
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Worktree folders (`.worktrees`, `*-worktrees`) within three levels of home.
|
|
1254
|
+
* Bounded so a large home cannot stall a launch; a folder created after launch
|
|
1255
|
+
* or deeper than that stays read-only on Linux.
|
|
1256
|
+
*/
|
|
1257
|
+
function discoverWorktreeFolders(home: string, list: (path: string) => DirectoryEntry[]): string[] {
|
|
1258
|
+
const found: string[] = [];
|
|
1259
|
+
let budget = 4000;
|
|
1260
|
+
const walk = (directory: string, depth: number) => {
|
|
1261
|
+
for (const entry of list(directory)) {
|
|
1262
|
+
if (--budget < 0 || !entry.directory || entry.symlink) continue;
|
|
1263
|
+
const path = join(directory, entry.name);
|
|
1264
|
+
if (entry.name === ".worktrees" || entry.name.endsWith("-worktrees")) found.push(path);
|
|
1265
|
+
else if (!entry.name.startsWith(".") && depth < 3) walk(path, depth + 1);
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
walk(home, 1);
|
|
1269
|
+
return found;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Empty, mode-000 mask sources a hidden credential is bound over. */
|
|
1273
|
+
function maskSources(profilePath: string): { directory: string; file: string } {
|
|
1274
|
+
const base = join(dirname(profilePath), ".pi-sandbox-mask");
|
|
1275
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
1276
|
+
const directory = join(base, "directory");
|
|
1277
|
+
const file = join(base, "file");
|
|
1278
|
+
if (!existsSync(directory)) mkdirSync(directory, { mode: 0o000 });
|
|
1279
|
+
if (!existsSync(file)) closeSync(openSync(file, "wx", 0o000));
|
|
1280
|
+
for (const [path, isDirectory] of [[directory, true], [file, false]] as const) {
|
|
1281
|
+
const info = lstatSync(path);
|
|
1282
|
+
if (info.isDirectory() !== isDirectory || (info.mode & 0o777) !== 0 || (!isDirectory && info.size !== 0)) {
|
|
1283
|
+
throw new Error(`Unsafe sandbox mask source: ${path}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return { directory, file };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* The broad-write profile on Bubblewrap.
|
|
1291
|
+
*
|
|
1292
|
+
* Mount namespaces cannot separate removal from writing, and Landlock is not
|
|
1293
|
+
* reachable from Node or bwrap here, so Linux uses the documented fallback:
|
|
1294
|
+
* the root is read-only; temp, the workspace, hidden home entries and worktree
|
|
1295
|
+
* folders are writable (removal included); ordinary top-level home folders and
|
|
1296
|
+
* home itself stay read-only, so sibling repositories cannot even be edited in
|
|
1297
|
+
* place there. Credentials are masked by empty mode-000 mounts; code that runs
|
|
1298
|
+
* later and caller-denied paths are read-only binds. Every writable bind and
|
|
1299
|
+
* protected leaf is a mount point, which Linux refuses to rename.
|
|
1300
|
+
*/
|
|
1301
|
+
function buildLinuxBroadCommand(
|
|
1302
|
+
bwrap: string,
|
|
1303
|
+
args: SandboxCommandArgs,
|
|
1304
|
+
policy: CompiledSandboxWritePolicy,
|
|
1305
|
+
seams: SandboxSeams,
|
|
1306
|
+
): SandboxCommand {
|
|
1307
|
+
const permissions = policy.permissions!;
|
|
1308
|
+
const broad = policy.broad!;
|
|
1309
|
+
const project = policy.writableRoot;
|
|
1310
|
+
if (permissions.projectFiles === "off") {
|
|
1311
|
+
throw new Error("Linux bubblewrap cannot hide a project inside the broad-write profile's visible root.");
|
|
1312
|
+
}
|
|
1313
|
+
if (permissions.projectFiles === "write") {
|
|
1314
|
+
throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
|
|
1315
|
+
}
|
|
1316
|
+
const credentials = policy.credentialPaths ?? [];
|
|
1317
|
+
const writeDenied = [...broad.codePaths, ...policy.denyWrite];
|
|
1318
|
+
if (permissions.projectFiles === "read-write" &&
|
|
1319
|
+
[...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
|
|
1320
|
+
throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
|
|
1321
|
+
}
|
|
1322
|
+
const list = seams.listDirectory ?? systemListDirectory;
|
|
1323
|
+
const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
|
|
1324
|
+
const protectedPaths = [...credentials, ...writeDenied];
|
|
1325
|
+
const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
|
|
1326
|
+
|
|
1327
|
+
const writable = new Set<string>();
|
|
1328
|
+
for (const root of broad.tempRoots) if (exists(root)) writable.add(root);
|
|
1329
|
+
for (const entry of list(broad.home)) {
|
|
1330
|
+
if (entry.symlink) continue;
|
|
1331
|
+
const path = join(broad.home, entry.name);
|
|
1332
|
+
if (entry.name.startsWith(".") && !insideProtected(path)) writable.add(path);
|
|
1333
|
+
}
|
|
1334
|
+
for (const path of discoverWorktreeFolders(broad.home, list)) if (!insideProtected(path)) writable.add(path);
|
|
1335
|
+
for (const path of [...broad.deleteRoots, ...(policy.runtimeWrite ?? [])]) {
|
|
1336
|
+
if (path === project || !exists(path)) continue;
|
|
1337
|
+
if (insideProtected(path)) throw new Error(`Writable sandbox root overlaps a protected path: ${path}`);
|
|
1338
|
+
writable.add(path);
|
|
1339
|
+
}
|
|
1340
|
+
if (permissions.projectFiles === "read-write") writable.add(project);
|
|
1341
|
+
const writableRoots = [...writable];
|
|
1342
|
+
const writableAt = (path: string) => writableRoots.some((root) => contains(root, path));
|
|
1343
|
+
|
|
1344
|
+
const mask = (seams.maskSources ?? maskSources)(args.profilePath);
|
|
1345
|
+
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
1346
|
+
const hidden: { path: string; source: string }[] = [];
|
|
1347
|
+
const readOnly = new Set<string>();
|
|
1348
|
+
const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
|
|
1349
|
+
.some((name) => canonicalizePath(join(broad.home, name), seams) === path);
|
|
1350
|
+
const guard = (path: string, hide: boolean) => {
|
|
1351
|
+
let directory: boolean | undefined;
|
|
1352
|
+
if (canonicalizePath(path, seams) !== path) {
|
|
1353
|
+
// A literal twin of a canonical deny entry: its target is guarded on
|
|
1354
|
+
// its own. A symlink leaf inside a writable root could be replaced,
|
|
1355
|
+
// so its directory becomes read-only; home itself already is.
|
|
1356
|
+
let link = false;
|
|
1357
|
+
try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
|
|
1358
|
+
if (link && dirname(path) !== broad.home && writableAt(dirname(path))) readOnly.add(dirname(path));
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (!exists(path)) {
|
|
1362
|
+
if (!writableAt(path)) return; // Nothing inside the sandbox can create it.
|
|
1363
|
+
let ancestor = dirname(path);
|
|
1364
|
+
while (!exists(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
|
|
1365
|
+
if (directoryLocation(path)) {
|
|
1366
|
+
(seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 })))(path);
|
|
1367
|
+
directory = true;
|
|
1368
|
+
} else if (writableRoots.includes(ancestor) || contains(project, ancestor) ||
|
|
1369
|
+
broad.tempRoots.some((root) => contains(root, ancestor))) {
|
|
1370
|
+
if (!materialize(path)) throw new Error(`Cannot protect denied path: ${path}`);
|
|
1371
|
+
} else {
|
|
1372
|
+
readOnly.add(ancestor);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (hide) {
|
|
1377
|
+
directory ??= seams.isDirectory ? seams.isDirectory(path) : statSync(path).isDirectory();
|
|
1378
|
+
hidden.push({ path, source: directory ? mask.directory : mask.file });
|
|
1379
|
+
} else {
|
|
1380
|
+
readOnly.add(path);
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
for (const path of credentials) guard(path, true);
|
|
1384
|
+
for (const path of writeDenied) guard(path, false);
|
|
1385
|
+
if (permissions.projectFiles === "read") readOnly.add(project);
|
|
1386
|
+
|
|
1387
|
+
// Intermediate directories between a writable root and anything bound
|
|
1388
|
+
// inside it become mount points too, so none of them can be renamed away.
|
|
1389
|
+
const anchors = new Set<string>();
|
|
1390
|
+
for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
|
|
1391
|
+
for (const parent of protectedAncestors([target])) {
|
|
1392
|
+
if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
|
|
1393
|
+
!readOnly.has(parent) && !writable.has(parent)) anchors.add(parent);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
type Mount = { option: "--bind" | "--ro-bind"; source: string; path: string; rank: number };
|
|
1397
|
+
const mounts: Mount[] = [
|
|
1398
|
+
...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
|
|
1399
|
+
...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
|
|
1400
|
+
...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
|
|
1401
|
+
...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
|
|
1402
|
+
].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
|
|
1403
|
+
const seen = new Set<string>();
|
|
1404
|
+
const fileArgs = ["--ro-bind", "/", "/", "--dev", "/dev"];
|
|
1405
|
+
for (const mount of mounts) {
|
|
1406
|
+
const key = `${mount.option}\0${mount.source}\0${mount.path}`;
|
|
1407
|
+
if (seen.has(key)) continue;
|
|
1408
|
+
seen.add(key);
|
|
1409
|
+
fileArgs.push(mount.option, mount.source, mount.path);
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
file: bwrap,
|
|
1413
|
+
fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
1414
|
+
"--", args.execPath, ...args.execArgs],
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
775
1418
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
776
1419
|
const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
|
|
777
1420
|
if (!bwrap) return undefined;
|