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 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 a private scratch directory through `TMPDIR`, `TMP`,
61
- and `TEMP`. Only that directory is writable in addition to the selected project
62
- and explicit file grants; a protected anchor prevents replacing its root with a
63
- symlink. It is separate from Pi's runtime control files.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
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
- if (permissions.projectFiles !== "off") {
622
- mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
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)) mounts.push("--ro-bind", path, 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
- mounts.push("--bind", path, path);
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] : []), ...(policy.runtimeWrite ?? [])]) {
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
- mounts.push("--bind", parent, parent);
743
+ writableAncestors.add(parent);
643
744
  }
644
- for (const path of leaves) mounts.push("--ro-bind", path, path);
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,
@@ -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
- for (let current = resolve(path); dirname(current) !== current; current = dirname(current)) {
32
- try {
33
- if (!lstatSync(current).isSymbolicLink()) continue;
34
- const entry = join(canonicalizePath(dirname(current)), basename(current));
35
- accessSync(dirname(entry), constants.W_OK);
36
- const inProject = entry === root || entry.startsWith(root + sep);
37
- const access = inProject ? permissions.projectFiles : permissions.outsideProject;
38
- if (access === "read-write" || permissions.storedCredentials === "read-write") return entry;
39
- } catch { /* Nonexistent or OS-protected entries cannot be replaced by the task. */ }
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
- const runtimeAlias = permissions && [getAgentDir(), tmpdir(), getPackageDir()]
279
- .map((path) => writableRuntimeAlias(path, projectRoot, permissions)).find(Boolean);
280
- if (runtimeAlias) {
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: `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.` });
292
+ reason: `Runtime compatibility discovery failed: ${error instanceof Error ? error.message : String(error)}` });
285
293
  }
286
-
287
- if (this.#unsafeRootReason !== undefined) {
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: this.#unsafeRootReason,
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 } : {}),