pi-better-sandbox 0.5.0 → 0.5.1
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 +8 -4
- package/package.json +1 -1
- package/shared-sandbox-core.ts +132 -13
- package/shared-task-sandbox.ts +41 -14
- package/state.ts +16 -12
package/README.md
CHANGED
|
@@ -57,10 +57,14 @@ apply to task operations. Subagents can start with task Network access or Run
|
|
|
57
57
|
commands & applications Off. The fixed file worker remains available according
|
|
58
58
|
to the file permissions even when task commands are Off.
|
|
59
59
|
|
|
60
|
-
The task executor provides
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
The task executor provides private scratch through `TMPDIR`, `TMP`, and `TEMP`.
|
|
61
|
+
Outside Read and Read/write also retain explicit runtime write exceptions for
|
|
62
|
+
`/tmp` (canonical `/private/tmp` on macOS), the current user's macOS temporary
|
|
63
|
+
directory, and that user's Security.framework MDS cache. MDS access lets CLI
|
|
64
|
+
Keychain retrieval initialize and refresh its cache; it does not restrict the
|
|
65
|
+
Keychain API to reads. Other users' caches and arbitrary outside `*.lock` files
|
|
66
|
+
are not writable. Credential-file and Pi control-path protections still win.
|
|
67
|
+
Outside Off does not expose these host runtime directories.
|
|
64
68
|
|
|
65
69
|
The currently admitted model-tool implementations are `read`, `write`, `edit`,
|
|
66
70
|
and `bash`. User-entered `!` and `!!` commands use the same shell policy. Other
|
package/package.json
CHANGED
package/shared-sandbox-core.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* `SandboxSeams` argument so callers can plan deterministically in tests.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
21
22
|
import { platform as osPlatform } from "node:os";
|
|
22
23
|
import {
|
|
23
24
|
accessSync,
|
|
@@ -67,6 +68,8 @@ export type SandboxWritePolicy = {
|
|
|
67
68
|
permissions?: SandboxPermissions;
|
|
68
69
|
/** Trusted per-launch runtime state, never supplied by model tool arguments. */
|
|
69
70
|
runtimeWrite?: readonly string[];
|
|
71
|
+
/** Bounded historical default runtime directories; inactive without a capability profile. */
|
|
72
|
+
runtimeCompatibility?: boolean;
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
@@ -116,6 +119,8 @@ export type SandboxSeams = {
|
|
|
116
119
|
* host running them.
|
|
117
120
|
*/
|
|
118
121
|
materializeDenyPath?: (path: string) => boolean;
|
|
122
|
+
/** Defaults to /usr/bin/getconf with a minimal environment (macOS only). */
|
|
123
|
+
getconf?: (name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR") => string | undefined;
|
|
119
124
|
};
|
|
120
125
|
|
|
121
126
|
/** A policy with every path canonicalized, deduplicated, and ordered. */
|
|
@@ -126,6 +131,7 @@ export type CompiledSandboxWritePolicy = {
|
|
|
126
131
|
readonly permissions?: SandboxPermissions;
|
|
127
132
|
readonly credentialPaths?: readonly string[];
|
|
128
133
|
readonly runtimeWrite?: readonly string[];
|
|
134
|
+
readonly compatibilityWrite?: readonly string[];
|
|
129
135
|
};
|
|
130
136
|
|
|
131
137
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -176,6 +182,46 @@ const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Libra
|
|
|
176
182
|
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
177
183
|
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
178
184
|
|
|
185
|
+
function systemGetconf(name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR"): string | undefined {
|
|
186
|
+
const result = spawnSync("/usr/bin/getconf", [name], {
|
|
187
|
+
encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "C" }, timeout: 3000, maxBuffer: 4096,
|
|
188
|
+
});
|
|
189
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compatibilityPaths(policy: SandboxWritePolicy, seams: SandboxSeams): string[] {
|
|
193
|
+
if (!policy.permissions || !policy.runtimeCompatibility || policy.permissions.outsideProject === "off") return [];
|
|
194
|
+
const platform = currentPlatform(seams);
|
|
195
|
+
if (platform === "linux") return [canonicalizePath("/tmp", seams)];
|
|
196
|
+
if (platform !== "darwin") return [];
|
|
197
|
+
const home = canonicalizePath(policy.home, seams);
|
|
198
|
+
const project = canonicalizePath(policy.writableRoot, seams);
|
|
199
|
+
const paths = [canonicalizePath("/tmp", seams)];
|
|
200
|
+
for (const [key, leaf] of [["DARWIN_USER_TEMP_DIR", ""], ["DARWIN_USER_CACHE_DIR", "mds"]] as const) {
|
|
201
|
+
const raw = (seams.getconf ?? systemGetconf)(key);
|
|
202
|
+
if (!raw || !raw.startsWith("/") || raw.includes("\0") ||
|
|
203
|
+
raw.split("/").some((part) => part === "." || part === "..")) {
|
|
204
|
+
throw new Error(`Invalid ${key} runtime directory`);
|
|
205
|
+
}
|
|
206
|
+
// getconf returns /var/folders on macOS; /var is a system alias for /private/var.
|
|
207
|
+
const normalized = resolve(raw.replace(/^\/var\/folders\//, "/private/var/folders/"));
|
|
208
|
+
const expected = /^\/private\/var\/folders\/[^/]+\/[^/]+\/[TC]$/.test(normalized) &&
|
|
209
|
+
normalized.endsWith(key === "DARWIN_USER_TEMP_DIR" ? "/T" : "/C");
|
|
210
|
+
const canonical = canonicalizePath(normalized, seams);
|
|
211
|
+
if (!expected || canonical !== normalized ||
|
|
212
|
+
(contains(home, canonical) || contains(canonical, home) || contains(project, canonical))) {
|
|
213
|
+
throw new Error(`Unsafe ${key} runtime directory`);
|
|
214
|
+
}
|
|
215
|
+
paths.push(leaf ? join(canonical, leaf) : canonical);
|
|
216
|
+
}
|
|
217
|
+
// An existing MDS symlink must not redirect the narrow write allowance.
|
|
218
|
+
const mds = paths[2];
|
|
219
|
+
if (!mds || canonicalizePath(mds, seams) !== mds || contains(mds, project) || contains(project, mds)) {
|
|
220
|
+
throw new Error("Unsafe MDS runtime directory");
|
|
221
|
+
}
|
|
222
|
+
return [...new Set(paths)];
|
|
223
|
+
}
|
|
224
|
+
|
|
179
225
|
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
180
226
|
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
181
227
|
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
@@ -235,12 +281,17 @@ function compile(
|
|
|
235
281
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
236
282
|
].sort();
|
|
237
283
|
|
|
284
|
+
const compatibilityWrite = compatibilityPaths(policy, seams);
|
|
238
285
|
return {
|
|
239
286
|
writableRoot, denyWrite, home: policy.home,
|
|
240
287
|
...(policy.permissions && {
|
|
241
288
|
permissions: { ...policy.permissions },
|
|
242
289
|
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
243
|
-
|
|
290
|
+
compatibilityWrite,
|
|
291
|
+
runtimeWrite: [...new Set([
|
|
292
|
+
...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
293
|
+
...compatibilityWrite,
|
|
294
|
+
])],
|
|
244
295
|
}),
|
|
245
296
|
};
|
|
246
297
|
}
|
|
@@ -279,8 +330,9 @@ export function evaluateReadAccess(
|
|
|
279
330
|
const permissions = policy.permissions;
|
|
280
331
|
if (!permissions) return { allowed: true, path };
|
|
281
332
|
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
282
|
-
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
333
|
+
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
283
334
|
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
335
|
+
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
284
336
|
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
285
337
|
: permissions.outsideProject;
|
|
286
338
|
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
@@ -307,8 +359,9 @@ export function evaluateWriteAccess(
|
|
|
307
359
|
}
|
|
308
360
|
if (policy.permissions) {
|
|
309
361
|
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
310
|
-
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
362
|
+
: policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
|
|
311
363
|
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
364
|
+
: policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
312
365
|
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
313
366
|
: policy.permissions.outsideProject;
|
|
314
367
|
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
@@ -344,6 +397,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
344
397
|
...policy.denyWrite,
|
|
345
398
|
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
346
399
|
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
400
|
+
...(policy.compatibilityWrite ?? []),
|
|
347
401
|
];
|
|
348
402
|
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
349
403
|
if (permissions.outsideProject === "off") {
|
|
@@ -367,13 +421,15 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
|
|
|
367
421
|
};
|
|
368
422
|
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
369
423
|
// explicit denyWrite entries always override every write allowance.
|
|
424
|
+
for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
|
|
370
425
|
scoped(policy.writableRoot, permissions.projectFiles);
|
|
371
|
-
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
426
|
+
for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
|
|
372
427
|
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
373
428
|
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
374
429
|
// Protect the directory entries, not their contents: unrelated children can
|
|
375
430
|
// still be created, while renaming a parent cannot move a denied subtree.
|
|
376
431
|
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
432
|
+
for (const path of policy.compatibilityWrite ?? []) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
377
433
|
if (!permissions.network) rules.push("(deny network*)");
|
|
378
434
|
return [...rules, ""].join("\n");
|
|
379
435
|
}
|
|
@@ -615,33 +671,96 @@ function buildLinuxPermissionCommand(
|
|
|
615
671
|
}
|
|
616
672
|
}
|
|
617
673
|
} else {
|
|
618
|
-
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
674
|
+
mounts.push(permissions.outsideProject === "read" && !policy.compatibilityWrite?.includes(canonicalizePath("/tmp", seams)) ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
619
675
|
}
|
|
620
676
|
mounts.push("--dev", "/dev");
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
677
|
+
const scopedMounts: { option: "--bind" | "--ro-bind"; path: string }[] = [];
|
|
678
|
+
const writableAncestors = new Set<string>();
|
|
679
|
+
const readOnlyGuards = new Set<string>();
|
|
624
680
|
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
625
681
|
for (const path of credentials) {
|
|
626
|
-
if (existsSync(path) && !contains(project, path))
|
|
682
|
+
if (existsSync(path) && !contains(project, path)) readOnlyGuards.add(path);
|
|
627
683
|
}
|
|
628
684
|
}
|
|
685
|
+
// A broad /tmp bind is already present for visible outside roots. Cover
|
|
686
|
+
// existing leaves and absent leaves via a read-only existing ancestor; do
|
|
687
|
+
// not create credential or control files in host runtime directories.
|
|
688
|
+
const compatibility = policy.compatibilityWrite ?? [];
|
|
689
|
+
if (compatibility.length) {
|
|
690
|
+
const tmp = canonicalizePath("/tmp", seams);
|
|
691
|
+
const protectedInTmp = [...policy.denyWrite, ...(permissions.storedCredentials !== "read-write" ? credentials : [])]
|
|
692
|
+
.filter((path) => contains(tmp, path) && !contains(project, path));
|
|
693
|
+
if (permissions.storedCredentials === "off" && protectedInTmp.some((path) =>
|
|
694
|
+
credentials.some((credential) => credential === path))) {
|
|
695
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a writable runtime directory.");
|
|
696
|
+
}
|
|
697
|
+
if (policy.denyWrite.some((path) => contains(path, tmp))) {
|
|
698
|
+
throw new Error("Runtime directory overlaps a write-denied control path.");
|
|
699
|
+
}
|
|
700
|
+
const guards = new Set<string>();
|
|
701
|
+
for (const path of protectedInTmp) {
|
|
702
|
+
let guard = path;
|
|
703
|
+
while (!existsSync(guard) && contains(tmp, guard) && guard !== tmp) guard = dirname(guard);
|
|
704
|
+
if (guard === tmp || !contains(tmp, guard)) {
|
|
705
|
+
throw new Error("Cannot protect an absent path directly under writable runtime directory.");
|
|
706
|
+
}
|
|
707
|
+
guards.add(guard);
|
|
708
|
+
}
|
|
709
|
+
for (const guard of [...guards].sort((a, b) => a.length - b.length)) {
|
|
710
|
+
if ([...guards].some((parent) => parent !== guard && contains(parent, guard))) continue;
|
|
711
|
+
for (const parent of protectedAncestors([guard]).filter((parent) => contains(tmp, parent) && parent !== tmp)) {
|
|
712
|
+
writableAncestors.add(parent);
|
|
713
|
+
}
|
|
714
|
+
readOnlyGuards.add(guard);
|
|
715
|
+
}
|
|
716
|
+
// A writable /tmp can rename any directory above the captured project
|
|
717
|
+
// root, then substitute a symlink before the next launch. Anchor each
|
|
718
|
+
// ancestor even when there are no explicit control or credential guards.
|
|
719
|
+
if (permissions.projectFiles !== "off" && contains(tmp, project)) {
|
|
720
|
+
for (const parent of protectedAncestors([project]).filter((path) => contains(tmp, path) && path !== tmp)) {
|
|
721
|
+
writableAncestors.add(parent);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (permissions.projectFiles !== "off") {
|
|
726
|
+
scopedMounts.push({ option: writableProject ? "--bind" : "--ro-bind", path: project });
|
|
727
|
+
}
|
|
629
728
|
for (const path of policy.runtimeWrite ?? []) {
|
|
729
|
+
if (compatibility.includes(path)) continue;
|
|
630
730
|
if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
|
|
631
731
|
policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
|
|
632
732
|
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
633
733
|
}
|
|
634
|
-
|
|
734
|
+
scopedMounts.push({ option: "--bind", path });
|
|
635
735
|
}
|
|
636
736
|
const protectedPaths = [...policy.denyWrite,
|
|
637
737
|
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
638
|
-
for (const writableRoot of [...(writableProject ? [project] : []),
|
|
738
|
+
for (const writableRoot of [...(writableProject ? [project] : []),
|
|
739
|
+
...(policy.runtimeWrite ?? []).filter((path) => !compatibility.includes(path))]) {
|
|
639
740
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
640
741
|
const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
|
|
641
742
|
for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
|
|
642
|
-
|
|
743
|
+
writableAncestors.add(parent);
|
|
643
744
|
}
|
|
644
|
-
for (const path of leaves)
|
|
745
|
+
for (const path of leaves) readOnlyGuards.add(path);
|
|
746
|
+
}
|
|
747
|
+
// A nearest-existing guard can be an ancestor of an unrelated writable
|
|
748
|
+
// project (e.g. absent ~/.npmrc with a project under ~/work). Mount that
|
|
749
|
+
// broad guard in path order, keeping its intervening anchors read-only;
|
|
750
|
+
// only the disjoint project subtree may be rebound writable afterwards.
|
|
751
|
+
const ancestorGuards = [...readOnlyGuards].filter((guard) =>
|
|
752
|
+
scopedMounts.some(({ option, path }) => option === "--bind" && guard !== path && contains(guard, path)));
|
|
753
|
+
for (const guard of ancestorGuards) {
|
|
754
|
+
scopedMounts.push({ option: "--ro-bind", path: guard });
|
|
755
|
+
readOnlyGuards.delete(guard);
|
|
756
|
+
}
|
|
757
|
+
for (const path of writableAncestors) scopedMounts.push({
|
|
758
|
+
option: ancestorGuards.some((guard) => contains(guard, path)) ? "--ro-bind" : "--bind", path,
|
|
759
|
+
});
|
|
760
|
+
scopedMounts.sort((a, b) => a.path.length - b.path.length);
|
|
761
|
+
for (const { option, path } of scopedMounts) mounts.push(option, path, path);
|
|
762
|
+
for (const path of [...readOnlyGuards].sort((a, b) => a.length - b.length)) {
|
|
763
|
+
mounts.push("--ro-bind", path, path);
|
|
645
764
|
}
|
|
646
765
|
return {
|
|
647
766
|
file: bwrap,
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -6,10 +6,10 @@ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefin
|
|
|
6
6
|
createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
12
|
-
import { canonicalizePath, maybeBuildSandboxCommand } from "./shared-sandbox-core.ts";
|
|
9
|
+
import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
12
|
+
import { canonicalizePath, compileWritePolicy, maybeBuildSandboxCommand, type SandboxPermissions } from "./shared-sandbox-core.ts";
|
|
13
13
|
import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
|
|
14
14
|
|
|
15
15
|
export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
|
|
@@ -27,16 +27,43 @@ export function runtimeCodeRoot(path: string): string {
|
|
|
27
27
|
|
|
28
28
|
export function writableRuntimeAlias(path: string, root: string, permissions: {
|
|
29
29
|
projectFiles: string; outsideProject: string; storedCredentials: string;
|
|
30
|
-
}): string | undefined {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
}, runtimeCompatibility = false): string | undefined {
|
|
31
|
+
const compatibility = runtimeCompatibility ? compileWritePolicy({ writableRoot: root, home: homedir(),
|
|
32
|
+
permissions: { ...permissions, commands: true, network: true } as SandboxPermissions,
|
|
33
|
+
runtimeCompatibility: true,
|
|
34
|
+
}).compatibilityWrite ?? [] : [];
|
|
35
|
+
const absolute = resolve(path);
|
|
36
|
+
let current = parse(absolute).root;
|
|
37
|
+
let pending = absolute.slice(current.length).split(sep);
|
|
38
|
+
let links = 0;
|
|
39
|
+
while (pending.length) {
|
|
40
|
+
const component = pending.shift()!;
|
|
41
|
+
if (!component || component === ".") continue;
|
|
42
|
+
if (component === "..") { current = dirname(current); continue; }
|
|
43
|
+
const entry = join(current, component);
|
|
44
|
+
let stat;
|
|
45
|
+
try { stat = lstatSync(entry); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (!stat.isSymbolicLink()) { current = entry; continue; }
|
|
51
|
+
if (++links > 40) throw new Error("Too many symlinks in Pi runtime path");
|
|
52
|
+
let replaceable = false;
|
|
53
|
+
try { accessSync(current, constants.W_OK); replaceable = true; }
|
|
54
|
+
catch { /* An OS-protected entry is immutable; its target still needs inspection. */ }
|
|
55
|
+
const inProject = entry === root || entry.startsWith(root + sep);
|
|
56
|
+
const access = inProject ? permissions.projectFiles
|
|
57
|
+
: compatibility.some((directory) => entry === directory || entry.startsWith(directory + sep)) ? "read-write"
|
|
58
|
+
: permissions.outsideProject;
|
|
59
|
+
if (replaceable && (access === "read-write" || permissions.storedCredentials === "read-write")) return entry;
|
|
60
|
+
// Resolve targets component-by-component: realpath would erase the
|
|
61
|
+
// intermediate links whose directory entries need protection.
|
|
62
|
+
const target = readlinkSync(entry);
|
|
63
|
+
if (isAbsolute(target)) {
|
|
64
|
+
current = parse(target).root;
|
|
65
|
+
pending = [...target.slice(current.length).split(sep), ...pending];
|
|
66
|
+
} else pending = [...target.split(sep), ...pending];
|
|
40
67
|
}
|
|
41
68
|
return undefined;
|
|
42
69
|
}
|
package/state.ts
CHANGED
|
@@ -273,26 +273,29 @@ export class ForegroundSandboxController {
|
|
|
273
273
|
});
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
if (this.#unsafeRootReason !== undefined) {
|
|
277
|
+
return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
|
|
278
|
+
backend: support.supported ? support.backend : undefined,
|
|
279
|
+
executable: support.supported ? support.executable : undefined,
|
|
280
|
+
reason: this.#unsafeRootReason });
|
|
281
|
+
}
|
|
276
282
|
const projectRoot = this.#projectRoot;
|
|
277
283
|
const permissions = this.#permissions?.main;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
let runtimeAlias: string | undefined;
|
|
285
|
+
try {
|
|
286
|
+
runtimeAlias = permissions && [getAgentDir(), tmpdir(), getPackageDir()]
|
|
287
|
+
.map((path) => writableRuntimeAlias(path, projectRoot, permissions, true)).find(Boolean);
|
|
288
|
+
} catch (error) {
|
|
281
289
|
return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
|
|
282
290
|
backend: support.supported ? support.backend : undefined,
|
|
283
291
|
executable: support.supported ? support.executable : undefined,
|
|
284
|
-
reason: `
|
|
292
|
+
reason: `Runtime compatibility discovery failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
285
293
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return Object.freeze({
|
|
289
|
-
...base,
|
|
290
|
-
state: "failed",
|
|
291
|
-
writableRoot: undefined,
|
|
294
|
+
if (runtimeAlias) {
|
|
295
|
+
return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
|
|
292
296
|
backend: support.supported ? support.backend : undefined,
|
|
293
297
|
executable: support.supported ? support.executable : undefined,
|
|
294
|
-
reason:
|
|
295
|
-
});
|
|
298
|
+
reason: `A Pi runtime directory uses a task-writable symlink (${runtimeAlias}). Restart Pi with canonical runtime, PI_CODING_AGENT_DIR, and TMPDIR paths before enabling confinement.` });
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
if (!support.supported) {
|
|
@@ -343,6 +346,7 @@ export class ForegroundSandboxController {
|
|
|
343
346
|
const policy: SandboxWritePolicy = {
|
|
344
347
|
writableRoot: status.writableRoot,
|
|
345
348
|
denyWrite: Object.freeze([...status.denyWrite, profileDir, this.#taskScratch.anchor]),
|
|
349
|
+
runtimeCompatibility: true,
|
|
346
350
|
runtimeWrite: Object.freeze([this.#taskScratch.path]),
|
|
347
351
|
home: (this.#seams.home ?? homedir)(),
|
|
348
352
|
...(status.permissions ? { permissions: status.permissions } : {}),
|