toolroll 0.9.68 → 0.9.70

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +204 -81
  5. package/dist/agent-fence.js +467 -397
  6. package/dist/agentconfig.d.ts +1 -1
  7. package/dist/agentconfig.js +11 -2
  8. package/dist/batch-checks.js +14 -26
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  10. package/dist/builder.d.ts +9 -0
  11. package/dist/builder.js +50 -4
  12. package/dist/check-access.d.ts +51 -0
  13. package/dist/check-access.js +77 -0
  14. package/dist/coding-provider.d.ts +14 -0
  15. package/dist/coding-provider.js +33 -1
  16. package/dist/coding-workspace.d.ts +15 -0
  17. package/dist/coding-workspace.js +9 -2
  18. package/dist/contracts/console-api.d.ts +2 -0
  19. package/dist/contracts/console-api.js +2 -0
  20. package/dist/doctor.d.ts +7 -0
  21. package/dist/doctor.js +4 -0
  22. package/dist/egress.d.ts +2 -0
  23. package/dist/egress.js +13 -4
  24. package/dist/exec.d.ts +17 -3
  25. package/dist/exec.js +54 -17
  26. package/dist/flow-actions.d.ts +1 -0
  27. package/dist/flow-actions.js +3 -2
  28. package/dist/flow-code.d.ts +31 -2
  29. package/dist/flow-code.js +57 -5
  30. package/dist/flow-steps.d.ts +4 -0
  31. package/dist/flow-steps.js +9 -5
  32. package/dist/flow-triggers.d.ts +4 -0
  33. package/dist/flow-triggers.js +8 -3
  34. package/dist/invoke.d.ts +50 -3
  35. package/dist/invoke.js +204 -66
  36. package/dist/operate.d.ts +4 -0
  37. package/dist/operate.js +119 -0
  38. package/dist/project-delete-ui.d.ts +23 -6
  39. package/dist/project-delete-ui.js +94 -1
  40. package/dist/project-sandbox.d.ts +59 -0
  41. package/dist/project-sandbox.js +83 -0
  42. package/dist/project-tools.d.ts +29 -5
  43. package/dist/project-tools.js +43 -7
  44. package/dist/release.d.ts +1 -1
  45. package/dist/release.js +1 -0
  46. package/dist/result-follow-ups.js +10 -15
  47. package/dist/sandbox-launch.d.ts +1 -0
  48. package/dist/sandbox-launch.js +148 -0
  49. package/dist/sandbox-runtime.d.ts +62 -0
  50. package/dist/sandbox-runtime.js +140 -0
  51. package/dist/scope.d.ts +37 -0
  52. package/dist/scope.js +32 -5
  53. package/dist/scout-browser-lock.d.ts +69 -0
  54. package/dist/scout-browser-lock.js +69 -0
  55. package/dist/scout-browser.d.ts +60 -1
  56. package/dist/scout-browser.js +200 -4
  57. package/dist/scout.d.ts +9 -4
  58. package/dist/scout.js +14 -4
  59. package/dist/serve.js +12 -1
  60. package/dist/server/http.d.ts +2 -0
  61. package/dist/server/render-results.d.ts +3 -0
  62. package/dist/server/render-results.js +1 -0
  63. package/dist/server/render-tasks.js +5 -1
  64. package/dist/server/route-table.js +2 -0
  65. package/dist/server/settings.js +64 -0
  66. package/dist/server/tasks.js +2 -0
  67. package/dist/store.d.ts +59 -2
  68. package/dist/store.js +166 -4
  69. package/dist/subagent-tools.d.ts +1 -0
  70. package/dist/subagent-tools.js +7 -5
  71. package/dist/surface.js +2 -1
  72. package/dist/tool-broker.d.ts +2 -1
  73. package/dist/tool-broker.js +2 -2
  74. package/dist/tool-launcher.js +10 -16
  75. package/package.json +2 -1
  76. package/scripts/provider-canary.mjs +5 -6
  77. package/dist/egress-bridge.d.ts +0 -17
  78. package/dist/egress-bridge.js +0 -111
@@ -1,57 +1,38 @@
1
1
  /**
2
- * The agent fence: what no build, plan, scout or repair agent may read or
3
- * write, whatever its permission mode. Agents run as the operator's own OS
4
- * user, so without it an agent could read the approver's remembered login
5
- * (`up-login.txt`), runner and coordinator tokens, provider keys, project
6
- * tool secrets, other runs' evidence, or the live database — and use them
7
- * to approve its own work. The fence closes that at the operating system,
8
- * not in the model's instructions:
2
+ * The agent fence: what no build, plan, scout or repair agent may read or write, whatever its permission mode. Agents
3
+ * run as the operator's own OS user, so without it an agent could read the approver's remembered login
4
+ * (`up-login.txt`), runner and coordinator tokens, provider keys, project tool secrets, other runs' evidence, or the
5
+ * live database — and use them to approve its own work. The fence closes that at the operating system, not in the
6
+ * model's instructions.
9
7
  *
10
- * - Codex (every platform): a permission profile that extends its own
11
- * `:workspace` sandbox (Auto keeps it exactly: workspace writes, no
12
- * network) or widens it to write anywhere with network on (Full access,
13
- * which used to mean no sandbox at all), and denies every fenced path.
14
- * - Claude and Gemini on macOS: the whole agent process runs inside a
15
- * Seatbelt profile that allows everything except the fenced paths.
16
- * - Claude and Gemini on Linux (v88): the agent runs under bubblewrap, in its
17
- * own mount namespace where every fenced folder is an empty one and every
18
- * fenced file is /dev/null. Used when `bwrap` is installed and works here.
19
- * - Claude everywhere also gets Read/Edit deny rules for its own file tools.
8
+ * The sandbox itself is Anthropic's sandbox-runtime (sandbox-runtime.ts): every macOS Seatbelt profile and every Linux
9
+ * bubblewrap + seccomp invocation is generated by its code, with its baseline, its mandatory write denies (shell
10
+ * profiles, .git/hooks, .vscode, .mcp.json and the rest) and its limits. This module decides only the few static rules
11
+ * Toolroll passes it (srtTermsOf): Toolroll's own state and database denied to read and write; the build's own
12
+ * checkout writable; other tasks' checkouts read, never written; the project's read-deny list; under policy v1 (an
13
+ * approval sealed before 0.9.70) the home configuration denied by name; a sign-ins check's exact files read again; the
14
+ * run's proxy as its only way out when its network is confined.
20
15
  *
21
- * Checked against codex-cli 0.156 and Claude Code 2.1.281 with a canary
22
- * file read by an obfuscated script: "Operation not permitted".
16
+ * Codex (every platform) fences its own agent's commands with its own sandbox: a permission profile with the same
17
+ * denies (codexFenceArgv). Claude also gets Read/Edit deny rules for its own file tools.
23
18
  *
24
- * A build's fence also holds its task boundary (`taskBoundary`): home
25
- * configuration and the repositories beside its own, so a build works in
26
- * its task's folders and nowhere else it could do harm.
27
- *
28
- * Beside what it denies, a fence grants a little back (`FenceGrants`): other
29
- * tasks' git metadata is read but never written and their checkouts can be
30
- * looked up, so `git gc` and `git worktree prune` see live siblings and keep
31
- * them; the shared dependencies this checkout links are read and run, never
32
- * written. Every plane checkout pool is denied whole, this build's own checkout given back
33
- * (`CheckoutPool`), so another project's worktree is out of reach (Codex covers projects present at launch).
34
- * On macOS the agent also can't read another process's arguments
35
- * and environment (`kern.procargs`) or inspect a process outside its own
36
- * sandbox, so a tool server's or Toolroll's own secrets don't leak through
37
- * a process listing, and every other run's launch folder in the system temp
38
- * folder is denied, even one made after this launch began. Nor can it get
39
- * another process's task port (a debugger's way into its memory), or
40
- * rename or remove a folder of the user's above a fenced path or the temp
41
- * folder, which would reach the fenced path under a new name.
19
+ * A build's fence also holds its task boundary (`taskBoundary`): under v1 the home configuration and the repositories
20
+ * beside its own; under v2 only the primary checkout write-denied.
42
21
  */
43
22
  import { INSTANCE_DIR } from "./console-instance.js";
44
23
  import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
45
24
  import { spawnSync } from "node:child_process";
46
25
  import { createHash } from "node:crypto";
47
26
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
27
+ import { createRequire } from "node:module";
28
+ import { fileURLToPath } from "node:url";
48
29
  import { homedir, tmpdir } from "node:os";
49
30
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
50
31
  import { readMonitoring } from "./monitoring-settings.js";
51
32
  import { existingFolders } from "./names.js";
52
33
  import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
53
- /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
54
- export const BRIDGE_DIR = "bridge-secrets";
34
+ import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
35
+ import { getDefaultWritePaths } from "@anthropic-ai/sandbox-runtime";
55
36
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
56
37
  const SENSITIVE_NAME = /(^up-login\.txt$|token|secret|login|password|credential|vapid|keys?\b|\.pem$|\.key$|^backups$|^evidence$|^remote$|^sign-in\.json$|^monitoring\.json$)/i;
57
38
  function real(path) {
@@ -134,40 +115,21 @@ export function agentFence(options) {
134
115
  const fence = new Set();
135
116
  /** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
136
117
  let runFolder = null;
137
- /** The bridges' folder, likewise. */
138
- let bridgeFolder = null;
139
118
  const home = real(options.home ?? homedir());
140
119
  const worktree = options.worktree === null ? null : real(options.worktree);
141
120
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
142
121
  const database = real(options.databaseFile);
143
122
  const state = dirname(database);
123
+ // By name, there or not (a journal made later is denied too wherever a fence denies by name: Seatbelt and Codex).
144
124
  for (const suffix of ["", "-wal", "-shm", "-journal"])
145
- if (existsSync(database + suffix))
146
- fence.add(database + suffix);
125
+ fence.add(database + suffix);
147
126
  // The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
148
127
  // build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
149
128
  // build starts if it isn't there yet, so a build can never be the one to create it — and the fence always has a
150
- // real folder to cover (a Linux bind needs one).
151
129
  // Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
152
130
  // points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
153
131
  // If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
154
132
  // rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
155
- // The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
156
- // from every build, so no build — however long it runs — can reach a bridge made for a later one.
157
- const bridges = join(state, BRIDGE_DIR);
158
- try {
159
- lstatSync(bridges);
160
- }
161
- catch {
162
- try {
163
- mkdirSync(bridges, { mode: 0o700 });
164
- }
165
- catch { /* fenced below either way */ }
166
- }
167
- // Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
168
- // refuses a root that isn't a private folder: egress-bridge.ts).
169
- bridgeFolder = bridges;
170
- fence.add(bridges);
171
133
  runFolder = join(state, INSTANCE_DIR);
172
134
  const present = () => { try {
173
135
  lstatSync(runFolder);
@@ -215,24 +177,20 @@ export function agentFence(options) {
215
177
  // v104: the folder the audit stream writes to holds the whole instance's history.
216
178
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
217
179
  const stream = readMonitoring(dirname(real(options.databaseFile))).folder?.path;
218
- if (stream !== undefined && existsSync(stream))
180
+ if (stream !== undefined)
219
181
  fence.add(real(stream));
220
182
  }
221
183
  for (const path of options.extra ?? [])
222
- if (existsSync(path))
223
- fence.add(real(path));
184
+ fence.add(real(path));
224
185
  // Secret, state, database, explicit-extra and home-config denies are never exemptible.
225
- if (options.runFolders !== undefined)
226
- for (const path of otherRunFolders(options.runFolders))
227
- fence.add(path);
228
186
  for (const path of options.boundary?.protectedShell ?? [])
229
187
  fence.add(real(path));
230
- const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
188
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]));
231
189
  for (const path of options.boundary?.shell ?? [])
232
190
  fence.add(path);
233
191
  const binding = options.binding;
234
192
  const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
235
- return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
193
+ return [...new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]))].filter(path => {
236
194
  if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
237
195
  return true;
238
196
  console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
@@ -275,6 +233,10 @@ export function taskBoundary(options) {
275
233
  const needed = [worktree, main, gitdir].filter((path) => path !== null);
276
234
  const own = new Set(PROVIDER_HOME[options.provider] ?? []);
277
235
  const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
236
+ // Policy v2 (docs/plans/sandbox-parity.md §1.2): the home folder, the repositories beside this one and the project's
237
+ // primary checkout are read as Anthropic's sandbox runtime reads them; only writes to the primary checkout stay denied.
238
+ if ((options.policy ?? 1) === 2)
239
+ return v2Boundary(worktree, main, gitdir);
278
240
  const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
279
241
  const protectedShell = [...shell];
280
242
  const repository = (path) => existsSync(join(path, ".git"));
@@ -316,11 +278,43 @@ export function taskBoundary(options) {
316
278
  shell.add(path);
317
279
  }
318
280
  }
319
- // Git needs this worktree's metadata and the shared objects/refs, but
320
- // must not prune or rewrite the metadata of another task's checkout. It
321
- // still reads that metadata (gc keeps what a sibling's HEAD and index
322
- // reach) and looks up the checkout it names: a checkout it can't see is
323
- // one `git worktree prune` deletes the entry of.
281
+ const { readOnly, lookup } = siblingGitData(main, gitdir);
282
+ const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
283
+ // By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
284
+ // link while a sign-ins check reads through it). The sandbox runtime takes them as read and write denies by name.
285
+ const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
286
+ return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
287
+ }
288
+ /** A v2 boundary: nothing of the home folder, the repositories beside this one or the primary checkout is denied to
289
+ * read; the primary checkout (all but its `.git`, which this worktree's Git needs) is never written; other tasks' Git
290
+ * metadata is read, never written, as under v1. */
291
+ function v2Boundary(worktree, main, gitdir) {
292
+ const noWrite = new Set();
293
+ if (main !== null && main !== worktree) {
294
+ let entries = [];
295
+ try {
296
+ entries = readdirSync(main);
297
+ }
298
+ catch {
299
+ entries = [];
300
+ }
301
+ for (const entry of entries) {
302
+ const path = join(main, entry);
303
+ if (entry !== ".git" && path !== worktree && !inside(worktree, path))
304
+ noWrite.add(path);
305
+ }
306
+ }
307
+ const { readOnly, lookup } = siblingGitData(main, gitdir);
308
+ for (const path of noWrite)
309
+ readOnly.add(path);
310
+ return { protectedShell: [], shell: [], fileTools: [], readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames: [] };
311
+ }
312
+ /**
313
+ * Git needs this worktree's metadata and the shared objects/refs, but must not prune or rewrite the metadata of another
314
+ * task's checkout. It still reads that metadata (gc keeps what a sibling's HEAD and index reach) and looks up the
315
+ * checkout it names: a checkout it can't see is one `git worktree prune` deletes the entry of.
316
+ */
317
+ function siblingGitData(main, gitdir) {
324
318
  const readOnly = new Set();
325
319
  const lookup = new Set();
326
320
  if (gitdir !== null && main !== null) {
@@ -364,16 +358,23 @@ export function taskBoundary(options) {
364
358
  lookup.add(join(checkout, ".git"));
365
359
  }
366
360
  }
367
- const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
368
- return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
361
+ return { readOnly, lookup };
369
362
  }
370
- /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
371
- * network bridge (egress-bridge.ts). */
363
+ /** The prefixes of every launch's private folders: its tools' config and a Codex build's own home (and a check's). */
372
364
  export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
373
- export function runFolders(own, root = tmpdir()) {
374
- return { root: real(root), own: own === null ? null : real(own) };
365
+ export function runFolders(own, root) {
366
+ // The system's own roots (not one a caller names): on a Mac, checks' launch folders are under the short one too.
367
+ const also = root === undefined && process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? [CHECK_LAUNCH_ROOT_MACOS] : [];
368
+ return { root: real(root ?? tmpdir()), own: own === null ? null : real(own), ...(also.length === 0 ? {} : { also }) };
375
369
  }
376
- /** Every other launch's private folder there now. */
370
+ /** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
371
+ * path (about 104 bytes). */
372
+ export const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
373
+ /** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder. */
374
+ export const checkLaunchRoot = () => process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? CHECK_LAUNCH_ROOT_MACOS : tmpdir();
375
+ /** Every other launch's private folder in the system temp folder now, each only as the real folder it is (a link planted
376
+ * there is never followed, nor fenced in its target's place) and this user's own. A shared root (`also`, /private/tmp on
377
+ * a Mac) is never listed: anyone may plant entries there, so it is fenced by name pattern alone. */
377
378
  function otherRunFolders(folders) {
378
379
  let entries = [];
379
380
  try {
@@ -382,8 +383,21 @@ function otherRunFolders(folders) {
382
383
  catch {
383
384
  entries = [];
384
385
  }
385
- return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
386
+ const uid = process.getuid?.();
387
+ return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => {
388
+ if (path === folders.own)
389
+ return false;
390
+ try {
391
+ const held = lstatSync(path);
392
+ return held.isDirectory() && !held.isSymbolicLink() && (uid === undefined || held.uid === uid);
393
+ }
394
+ catch {
395
+ return false;
396
+ }
397
+ });
386
398
  }
399
+ /** Every root launch folders are made under (the system temp folder, and on a Mac the short shared one). */
400
+ export const runRoots = (folders) => [folders.root, ...(folders.also ?? [])];
387
401
  export { isWritesMode, WRITES_DEFAULT } from "./writes-mode.js";
388
402
  /** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
389
403
  * (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
@@ -539,17 +553,6 @@ export function buildCacheEnv(folder) {
539
553
  DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
540
554
  };
541
555
  }
542
- /**
543
- * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
544
- * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
545
- * (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
546
- * confined; the run's proxy resolves names for it.
547
- */
548
- export const DELEGATION_SERVICES = [
549
- "com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
550
- "com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
551
- "com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
552
- ];
553
556
  export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
554
557
  /**
555
558
  * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
@@ -618,6 +621,7 @@ export function fenceGrants(options) {
618
621
  }
619
622
  const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
620
623
  return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
624
+ ...(options.boundary?.homeNames === undefined || options.boundary.homeNames.length === 0 ? {} : { homeNames: [...options.boundary.homeNames] }),
621
625
  ...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
622
626
  }
623
627
  /** The setup a shared copy was installed by (its ready note), or null. */
@@ -664,12 +668,42 @@ const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
664
668
  * `codexPool`, which denies them around the build's own checkout, written back.
665
669
  */
666
670
  export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
671
+ const access = codexFilesystem(fence, fullAccess, grants);
672
+ const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
673
+ return [
674
+ "-c", 'default_permissions="standing-orders"',
675
+ "-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
676
+ "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
677
+ // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
678
+ // network is open (and Full access asked for it), and are offline otherwise.
679
+ ...(codexNetworkOn(fullAccess, grants) ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
680
+ ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
681
+ ];
682
+ }
683
+ const codexNetworkOn = (fullAccess, grants) => fullAccess && (grants.network === undefined || grants.network.mode === "open");
684
+ /**
685
+ * The same permission profile as codexFenceArgv, as the config overrides a Codex app-server thread takes
686
+ * (`thread/start` and `thread/resume` `config`; checked against codex-cli 0.156: the thread reports
687
+ * `activePermissionProfile` standing-orders, and its commands are refused the denied paths). Approval policy is the
688
+ * caller's own (an interactive session asks).
689
+ */
690
+ export function codexFenceConfig(fence, fullAccess, grants = NO_GRANTS) {
691
+ return {
692
+ default_permissions: "standing-orders",
693
+ "permissions.standing-orders.extends": grants.writes === undefined ? ":workspace" : ":read-only",
694
+ "permissions.standing-orders.filesystem": Object.fromEntries(codexFilesystem(fence, fullAccess, grants)),
695
+ ...(codexNetworkOn(fullAccess, grants) ? { "permissions.standing-orders.network.enabled": true } : {}),
696
+ };
697
+ }
698
+ /** Each path of a Codex permission profile and its access, the strongest last (a deny outranks a grant). */
699
+ function codexFilesystem(fence, fullAccess, grants) {
667
700
  const { deny, readOnly } = flattened(fence, grants);
668
701
  // Codex's harness reads its own launch files outside the command sandbox.
669
702
  // Deny the namespace, including folders created after this policy was built.
670
703
  if (grants.runFolders !== undefined) {
671
- for (const prefix of RUN_FOLDER_PREFIXES) {
672
- deny.push(join(grants.runFolders.root, `${prefix}*`), join(grants.runFolders.root, `${prefix}*`, "**"));
704
+ for (const root of runRoots(grants.runFolders)) {
705
+ for (const prefix of RUN_FOLDER_PREFIXES)
706
+ deny.push(join(root, `${prefix}*`), join(root, `${prefix}*`, "**"));
673
707
  }
674
708
  }
675
709
  // Launch folders can already be in the base fence when invoke appends them.
@@ -697,27 +731,39 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
697
731
  for (const path of held.read)
698
732
  access.set(path, "read");
699
733
  for (const path of held.deny)
700
- access.set(path, "deny");
734
+ access.set(path, pool.readable === true && !(pool.sealed ?? []).some(one => path === one || inside(path, one)) ? "read" : "deny");
735
+ // A sealed folder (flows' scratch) is denied whole, but on the way to this launch's own folder (Codex can't deny an
736
+ // ancestor and still reach inside it: its other entries are denied one by one, by codexPool, from the same split).
737
+ for (const sealed of pool.sealed ?? [])
738
+ if (!(pool.own !== null && (pool.own === sealed || inside(pool.own, sealed))))
739
+ access.set(sealed, "deny");
701
740
  for (const path of held.write)
702
741
  access.set(path, "write");
703
742
  }
704
- // Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
705
- // stays unreplaceable, not only its target.
743
+ // What no launch writes (homeWriteDenies): read-only, where Codex writes at all (Full access, or an allowlist).
744
+ // Never inside a denied path: Codex lets the more specific path win, so a read there would bring it back.
745
+ const deniedPaths = deny.filter(one => !one.includes("*")).map(real);
746
+ for (const path of grants.writeDenies ?? []) {
747
+ const leads = resolvedPath(path);
748
+ const denied = (one) => access.get(one) === "deny" || deniedPaths.some(den => one === den || inside(one, den));
749
+ // A name that leads into a denied path (a linked ~/.ssh into a read-denied folder) is denied by its own name too,
750
+ // never read back.
751
+ if (denied(leads)) {
752
+ access.set(path, "deny");
753
+ continue;
754
+ }
755
+ if (denied(path))
756
+ continue;
757
+ access.set(path, "read");
758
+ }
759
+ // Denies are keyed where they resolve; the console's run folder is also denied by its
760
+ // own pathname, so a link there stays unreplaceable, not only its target.
706
761
  for (const path of deny) {
707
762
  access.set(real(path), "deny");
708
763
  if (basename(path) === INSTANCE_DIR)
709
764
  access.set(path, "deny");
710
765
  }
711
- const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
712
- return [
713
- "-c", 'default_permissions="standing-orders"',
714
- "-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
715
- "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
716
- // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
717
- // network is open (and Full access asked for it), and are offline otherwise.
718
- ...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
719
- ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
720
- ];
766
+ return access;
721
767
  }
722
768
  /**
723
769
  * Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
@@ -814,117 +860,7 @@ export function claudeFenceSettings(fence, readOnly = []) {
814
860
  const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
815
861
  return JSON.stringify({ permissions: { deny: rules } });
816
862
  }
817
- const seatbelt = (path) => `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
818
- const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
819
- /**
820
- * The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
821
- * user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
822
- * back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
823
- */
824
- export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
825
- const out = new Set();
826
- const owned = (path) => { try {
827
- return uid !== undefined && statSync(path).uid === uid;
828
- }
829
- catch {
830
- return false;
831
- } };
832
- const climb = (from) => {
833
- for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
834
- if (owned(folder))
835
- out.add(folder);
836
- };
837
- for (const path of paths)
838
- climb(dirname(path));
839
- for (const root of roots)
840
- climb(root);
841
- return [...out].sort();
842
- }
843
- /**
844
- * A Seatbelt profile that allows everything except reading or writing the
845
- * fenced paths, less what `grants` gives back (a later Seatbelt rule
846
- * outranks an earlier one). Whatever the paths, the process can't read
847
- * another's arguments and environment (`kern.procargs`, which `ps -E` and
848
- * KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
849
- * `process-info*` limited by target alone doesn't stop the sysctl. It can't
850
- * take another process's task port either (`task_for_pid`, what lldb attaches
851
- * with; a task name port too), so a tool server's or Toolroll's own memory
852
- * and environment stay out of reach. Last, every folder of the user's above
853
- * a fenced or read-only path and above the temp folder (and the temp folder
854
- * itself) can't be renamed or removed, though what is inside them stays
855
- * writable as before. The checkout pools are denied first, and the build's own
856
- * checkout given back right after, so every later deny still holds inside it.
857
- */
858
- export function macosFenceProfile(fence, grants = NO_GRANTS) {
859
- const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
860
- const folders = grants.runFolders;
861
- const pool = grants.pool;
862
- const above = ownedAncestors([...fence, ...grants.readOnly, ...pool?.deny ?? []], [folders?.root ?? real(tmpdir())]);
863
- return [
864
- "(version 1)",
865
- "(allow default)",
866
- // A workspace build writes only its allowlist (and the terminal devices); every deny and read-only rule after this
867
- // still holds inside it.
868
- ...(grants.writes === undefined ? [] : [
869
- "(deny file-write*)",
870
- `(allow file-write* (subpath "/dev")${grants.writes.subpaths.length === 0 ? "" : ` ${paths("subpath", grants.writes.subpaths)}`}${grants.writes.prefixes.map(one => ` (regex #"^${seatbeltRegex(one.path)}${one.suffix}")`).join("")})`,
871
- // A granted folder itself can't be renamed, removed or replaced (by a link to anywhere, say): only what is in it.
872
- ...(grants.writes.subpaths.length === 0 ? [] : [`(deny file-write* ${paths("literal", grants.writes.subpaths)})`]),
873
- ]),
874
- ...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
875
- ...(pool?.own == null ? [] : [
876
- `(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
877
- `(deny file-write* ${paths("literal", [pool.own])})`,
878
- ]),
879
- ...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
880
- ...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
881
- ...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
882
- ...(folders === undefined ? [] : [`(deny file-read* file-write* (regex #"^${seatbeltRegex(folders.root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`]),
883
- ...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
884
- // Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
885
- ...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
886
- // Each readable copy's own folder can be listed, never read: bundlers such as esbuild find node_modules by listing
887
- // its parent, which a lookup alone refuses (the Linux and Codex fences list it as read-only already).
888
- ...(grants.readable.length === 0 ? [] : [`(allow file-read-data ${paths("literal", [...new Set(grants.readable.map(one => dirname(one)))])})`]),
889
- ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
890
- '(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
891
- "(deny process-info-pidinfo)",
892
- "(allow process-info-pidinfo (target same-sandbox))",
893
- "(deny mach-priv-task-port)",
894
- "(deny mach-task-name)",
895
- ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
896
- ...macosNetworkRules(grants.network),
897
- // A workspace build reaches no unix socket but its own (a container engine's, an agent's, a database's would act
898
- // outside its limits for it), whatever its network: a confined network has its own rule for this already. Its open
899
- // network still names hosts: the system's DNS service answers on a unix socket, and without it nothing resolves.
900
- ...(grants.writes === undefined || grants.network?.mode === "allowlist" ? [] : [
901
- "(deny network-outbound (remote unix-socket))",
902
- `(allow network-outbound (remote unix-socket (path-literal ${seatbelt(MACOS_DNS_SOCKET)})))`,
903
- ...(grants.writes.subpaths.length === 0 ? [] : [`(allow network-outbound ${grants.writes.subpaths.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
904
- ]),
905
- ].join("\n");
906
- }
907
- /**
908
- * The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
909
- * allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
910
- * allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
911
- * or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
912
- * connection.
913
- */
914
- export function macosNetworkRules(network) {
915
- if (network === undefined || network.mode === "open")
916
- return [];
917
- const local = (port) => `(remote ip "localhost:${port}")`;
918
- return [
919
- "(deny network-outbound)",
920
- `(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
921
- ...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
922
- `(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
923
- ];
924
- }
925
- export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
926
- /** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
927
- export const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
863
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
928
864
  /** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
929
865
  const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
930
866
  let seatbeltWorks = null;
@@ -950,217 +886,351 @@ export function macosFenceAvailable(platform = process.platform, probe) {
950
886
  if (probe !== undefined)
951
887
  return works(probe);
952
888
  seatbeltWorks ??= works(() => {
953
- const ran = spawnSync(SANDBOX_EXEC, ["-p", "(version 1)(allow default)", "/usr/bin/true"], { timeout: 5_000, encoding: "utf8" });
889
+ const ran = spawnSync(SANDBOX_EXEC, ["-p", SEATBELT_PROBE, "/usr/bin/true"], { timeout: 5_000, encoding: "utf8" });
954
890
  return { status: ran.status, stderr: ran.stderr ?? "" };
955
891
  });
956
892
  return seatbeltWorks;
957
893
  }
958
- /** The spawn that runs `file args` inside the macOS fence (same process: sandbox-exec replaces itself with the agent). */
959
- export function macosFenced(file, args, fence, grants = NO_GRANTS) {
960
- return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence, grants), file, ...args] };
961
- }
962
- /** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
963
- let bubblewrapWorks = null;
964
- export function linuxFenceAvailable(platform = process.platform, probe = () => spawnSync("bwrap", ["--dev-bind", "/", "/", "--", "true"], { timeout: 5_000, stdio: "ignore" }).status === 0) {
894
+ /** Whether the sandbox runtime can sandbox here on Linux (checked once): bubblewrap, socat, its seccomp helper and
895
+ * ripgrep installed, and unprivileged user namespaces allowed. A machine that can't is no Linux fence at all. */
896
+ let linuxWorks = null;
897
+ export function linuxFenceAvailable(platform = process.platform, probe = () => sandboxRuntimeProblem("linux") === null) {
965
898
  if (platform !== "linux")
966
899
  return false;
967
- if (bubblewrapWorks === null) {
900
+ if (linuxWorks === null) {
968
901
  try {
969
- bubblewrapWorks = probe();
902
+ linuxWorks = probe();
970
903
  }
971
904
  catch {
972
- bubblewrapWorks = false;
905
+ linuxWorks = false;
973
906
  }
974
907
  }
975
- return bubblewrapWorks;
908
+ return linuxWorks;
909
+ }
910
+ /** Thrown when no sandbox can be made for a launch: the spawn road refuses it. */
911
+ export class LinuxFenceUnavailable extends Error {
976
912
  }
977
- /** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
978
- const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
979
- const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
980
913
  /**
981
- * The grants bubblewrap may bind back over its masks: each existing read-only or readable path, never one that is a
982
- * fenced path itself, a read-only one inside a fenced path (only the shared install this run links is read inside
983
- * one), another launch's private folder, or one with a secret-looking name below its fenced folder. Bubblewrap can't
984
- * mask by pattern, so a grant a pattern would deny is dropped instead (fail closed).
914
+ * The runtime's config for a launch (sandbox-runtime.ts SrtTerms), from its fence: every fenced path denied to read and
915
+ * write (with the exact paths read again inside them), the pools' other checkouts write-denied (and, under v1, read-
916
+ * denied) around the build's own, the flows' scratch folders sealed, the launch's writes (its allowlist, or anywhere
917
+ * outside the denies), its mach services and its network.
985
918
  */
986
- function linuxGrants(fence, grants) {
987
- const folders = grants.runFolders;
988
- const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
989
- && RUN_FOLDER_PREFIXES.some(prefix => relative(folders.root, path).split(sep)[0].startsWith(prefix));
990
- const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
991
- const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
992
- && !(readOnlyGrant && fence.some(one => inside(path, one)));
993
- return [...new Set([...grants.readOnly.filter(path => ok(path, true)), ...grants.readable.filter(path => ok(path, false))])];
994
- }
995
- /** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, the checkout pools masked
996
- * and the build's own checkout bound back read-write over them, then every fenced path masked whole (an empty
997
- * folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
998
- * bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
999
- export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
1000
- const network = grants.network?.mode === "allowlist" ? grants.network : null;
1001
- if (network !== null && network.bridge === undefined)
1002
- throw new Error("A confined network on Linux needs its bridge");
1003
- const mask = (path) => {
1004
- let folder = true;
1005
- try {
1006
- folder = statSync(path).isDirectory();
1007
- }
1008
- catch {
1009
- folder = true;
1010
- }
1011
- return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
1012
- };
1013
- const pool = grants.pool?.deny ?? [];
1014
- const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
1015
- const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
1016
- // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
1017
- // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
1018
- const isolated = network === null ? [] : [
1019
- "--unshare-net", "--unshare-pid", "--proc", "/proc",
1020
- ...linuxHostSockets(undefined, undefined, undefined, grants.writes !== undefined).flatMap(mask),
1021
- "--ro-bind", network.bridge.folder, network.bridge.folder,
1022
- ];
1023
- // A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
1024
- // all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
1025
- // A workspace build (arc 2) sees the whole filesystem read-only, a /dev of its own and a /tmp of its own, with only
1026
- // its allowlist bound back writable; its own checkout's Git data is in that list.
1027
- const writes = grants.writes;
1028
- const root = writes === undefined ? ["--dev-bind", "/", "/"] : ["--ro-bind", "/", "/", "--dev", "/dev"];
1029
- const writable = writes === undefined ? [] : [...writes.subpaths, ...writes.prefixes.map(one => one.path)].filter(path => existsSync(path)).flatMap(path => ["--bind", path, path]);
1030
- // What a private /tmp gives back is read-only for a workspace build (its writable part is bound again after): the
1031
- // checkout's whole shared Git data among it must not come back writable.
1032
- // A confined network brings a /tmp of its own (arc 1); a workspace build with an open network sees the shared /tmp
1033
- // read-only like the rest of the root, its own folders there bound back writable.
1034
- const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network?.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => [writes === undefined ? "--bind" : "--ro-bind", path, path])];
1035
- // A workspace build never reaches the host's own services either (a container engine would write anywhere for it),
1036
- // whatever its network: confined networks mask them below already.
1037
- const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
1038
- const command = network === null ? [file, ...args]
1039
- : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
1040
- return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...services, ...isolated, "--die-with-parent", "--", ...command] };
1041
- }
1042
- /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
1043
- * engines), wherever they exist here. */
1044
- export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp", followLinks = false, env = process.env) {
1045
- // The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
1046
- const system = [
1047
- ...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
1048
- "/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
1049
- ];
1050
- const masks = new Set();
1051
- for (const path of system) {
1052
- try {
1053
- masks.add(realpathSync(path));
1054
- }
1055
- catch { /* not here */ }
919
+ export function srtTermsOf(fence, grants = NO_GRANTS) {
920
+ const denyRead = new Set(fence.map(real));
921
+ // A readable grant's own folder is read too: bundlers (esbuild) find node_modules by listing it. The runtime can't
922
+ // grant a listing without the folder's files, so the shared copy's folder is read whole (its ready note among them).
923
+ const allowRead = new Set(grants.readable.flatMap(path => [real(path), real(dirname(path))]));
924
+ const denyWrite = new Set([...fence, ...grants.readOnly].map(real));
925
+ for (const path of grants.writeDenies ?? [])
926
+ denyWrite.add(path);
927
+ for (const name of grants.homeNames ?? []) {
928
+ denyRead.add(name);
929
+ denyWrite.add(name);
1056
930
  }
1057
- // The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
1058
- // through a link (a planted link would turn a mask onto any folder at all). Container engines
1059
- // run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
1060
- // Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
1061
- // socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
1062
- // Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
1063
- // a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
1064
- const owned = (path) => {
1065
- try {
1066
- const held = lstatSync(path);
1067
- return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
1068
- }
1069
- catch {
1070
- return false;
1071
- }
1072
- };
1073
- for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name))) {
1074
- if (owned(path))
1075
- masks.add(path);
1076
- // A workspace build can't plant a link in the home folder, so a link there is the operator's own: its target is
1077
- // masked too (an engine moved elsewhere).
1078
- else if (followLinks) {
1079
- try {
1080
- if (lstatSync(path).isSymbolicLink())
1081
- masks.add(realpathSync(path));
1082
- }
1083
- catch { /* dangling */ }
1084
- }
1085
- }
1086
- // An engine Toolroll's own environment points at, wherever it is (never the build's to choose): always masked. So is
1087
- // the SSH agent it names (it signs as you), which a shared /tmp would otherwise leave in reach.
1088
- for (const name of ["DOCKER_HOST", "CONTAINER_HOST"]) {
1089
- const socket = /^unix:\/\/(\/.+)$/.exec(env[name] ?? "")?.[1];
1090
- if (socket !== undefined) {
1091
- try {
1092
- masks.add(realpathSync(socket));
931
+ for (const file of grants.signIns?.read ?? [])
932
+ allowRead.add(file);
933
+ // Every other launch's private folder unread by pattern (one made after this launch too), this launch's own read
934
+ // again. Writes are denied to those there at launch, under every root: the runtime can't write back inside a write
935
+ // deny, so a pattern would take this launch's own folder too (one made after launch is writable under `anywhere`).
936
+ const folders = grants.runFolders;
937
+ if (folders !== undefined) {
938
+ for (const root of runRoots(folders)) {
939
+ for (const prefix of RUN_FOLDER_PREFIXES)
940
+ denyRead.add(join(root, `${prefix}*`));
941
+ // And by name, each there right before the launch (this runs at the spawn): on Linux the runtime gives a pattern
942
+ // no mask where a path it allows lies beside its matches. Written: only where the launch writes anywhere (under a
943
+ // write allowlist they are out of its writes already).
944
+ for (const path of otherRunFolders({ root, own: folders.own })) {
945
+ denyRead.add(path);
946
+ if (grants.writes === undefined)
947
+ denyWrite.add(path);
1093
948
  }
1094
- catch { /* not there */ }
1095
949
  }
950
+ if (folders.own !== null)
951
+ allowRead.add(folders.own);
1096
952
  }
1097
- const agent = env["SSH_AUTH_SOCK"];
1098
- if (agent !== undefined && isAbsolute(agent)) {
1099
- try {
1100
- const socket = realpathSync(agent);
1101
- masks.add(socket);
1102
- // The folder an agent makes for its socket (ssh-XXXX in /tmp) too, so a socket it makes next is out of reach.
1103
- if (inside(socket, real(tmp)) && dirname(socket) !== real(tmp) && !lstatSync(dirname(socket)).isSymbolicLink())
1104
- masks.add(dirname(socket));
953
+ const pool = grants.pool;
954
+ if (pool !== undefined) {
955
+ // Under v1 the pools are unread too, the build's own checkout read again; under v2 they are read (owner Q-C).
956
+ if (pool.readable !== true)
957
+ for (const root of pool.deny)
958
+ denyRead.add(root);
959
+ // The build's own checkout is read again inside whatever is denied around it (the pools, a flow's sealed scratch).
960
+ if (pool.own !== null)
961
+ allowRead.add(pool.own);
962
+ // Never written but the build's own checkout: the runtime can't write back inside a write deny, so each other
963
+ // entry on the way is denied instead (codexPool's split, as Codex takes it).
964
+ for (const path of codexPool(pool).deny)
965
+ denyWrite.add(path);
966
+ for (const sealed of pool.sealed ?? []) {
967
+ denyRead.add(sealed);
968
+ if (!(pool.own !== null && (pool.own === sealed || inside(pool.own, sealed))))
969
+ denyWrite.add(sealed);
1105
970
  }
1106
- catch { /* not there */ }
1107
971
  }
1108
- // A build that shares /tmp (a workspace build with an open network) reaches none of the sockets there either: every
1109
- // socket two levels down (a multiplexer's, an agent's, a display's, an editor's), and the folder that holds it, taken
1110
- // as it is (never through a link). Toolroll's own launch folders are fenced by name already.
1111
- if (followLinks) {
1112
- const socketIn = (folder, depth) => {
1113
- let found = false;
1114
- let entries = [];
1115
- try {
1116
- entries = readdirSync(folder);
1117
- }
1118
- catch {
1119
- return false;
1120
- }
1121
- for (const entry of entries) {
1122
- const path = join(folder, entry);
1123
- if (RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix)))
1124
- continue;
1125
- let held;
1126
- try {
1127
- held = lstatSync(path);
1128
- }
1129
- catch {
1130
- continue;
1131
- }
1132
- if (held.isSocket()) {
1133
- masks.add(path);
1134
- found = true;
1135
- }
1136
- else if (held.isDirectory() && !held.isSymbolicLink() && depth > 0 && socketIn(path, depth - 1)) {
1137
- masks.add(path);
1138
- found = true;
1139
- }
1140
- }
1141
- return found;
1142
- };
1143
- socketIn(tmp, 1);
1144
- }
1145
- return [...masks].sort();
972
+ const readConfig = { denyOnly: [...denyRead].sort(), allowWithinDeny: [...allowRead].sort() };
973
+ const writes = grants.writes;
974
+ // A sign-ins check also writes Codex's session and log folders and its own Claude transcripts (never inside a
975
+ // write deny: under policy v1 the home configuration stays unwritten whole).
976
+ const signInWrites = [...grants.signIns?.writes ?? [], ...(grants.signIns?.transcripts ?? []).map(folder => join(folder, "*.jsonl"))];
977
+ // Writing anywhere: everything but the denies. The launch's own checkout and folder are named too, so a backend that
978
+ // binds a read deny's re-allowed paths back (bubblewrap) binds those back writable.
979
+ const allowOnly = writes === undefined ? ["/", ...(pool?.own == null ? [] : [real(pool.own)]), ...(folders?.own == null ? [] : [folders.own])]
980
+ : [...writes.subpaths, ...writes.prefixes.map(one => `${one.path}*`), ...signInWrites, ...getDefaultWritePaths({ denyRead: readConfig.denyOnly, allowRead: readConfig.allowWithinDeny })];
981
+ const network = grants.network;
982
+ return {
983
+ readConfig,
984
+ writeConfig: { allowOnly: [...new Set(allowOnly)].sort(), denyWithinAllow: [...denyWrite].sort() },
985
+ allowMachLookup: [...grants.machLookup ?? [], ...grants.signIns?.mach ?? []],
986
+ network: network === undefined || network.mode === "open" ? { kind: "open" } : {
987
+ kind: "proxy", httpPort: network.proxyPort, ...(network.brokerPorts[0] === undefined ? {} : { socksPort: network.brokerPorts[0] }),
988
+ ...(network.token === undefined ? {} : { token: network.token }), localServices: network.loopback === "any", unixSockets: [...network.socketRoots],
989
+ },
990
+ };
991
+ }
992
+ /** The spawn that runs `file args` from `cwd` inside the sandbox runtime's sandbox (sandbox-runtime.ts srtSpawn), and the
993
+ * environment it needs beside the caller's. */
994
+ export function sandboxedSpawn(file, args, fence, grants = NO_GRANTS, cwd, temp) {
995
+ return srtSpawn(file, args, srtTermsOf(fence, grants), cwd ?? process.cwd(), temp);
1146
996
  }
1147
997
  /**
1148
998
  * What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
1149
999
  * (fenceSummaryMarkdown; a test holds them equal).
1150
1000
  */
1151
1001
  export const FENCE_SUMMARY = [
1152
- {
1153
- agent: "**Codex / OpenRouter** (any OS)",
1154
- auto: "Codex's sandbox with the fence: writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
1155
- full: "the same writes, with the fence; its commands reach the network only when the project's network is open",
1156
- },
1157
1002
  {
1158
1003
  agent: "**Claude, Gemini, ACP agents**",
1159
- auto: "macOS: inside a Seatbelt sandbox that denies the fence and writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files. Linux: the same in bubblewrap, with a /tmp of its own. Neither (Windows, or Linux without bubblewrap): the build doesn't start, unless an instance operator allows unconfined agents",
1004
+ auto: "Anthropic's sandbox-runtime (Seatbelt on macOS; bubblewrap and its seccomp filter on Linux, which also needs socat and ripgrep): reads your home folder except the folders the project lists, never Toolroll's folders; other tasks' work read, never written; writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files (anywhere outside the fence, under a project set to write anywhere), never the runtime's protected names in the checkout (Git hooks and config, shell profiles, `.mcp.json`, editor settings); no Apple Events. Neither (Windows, or a Linux missing one of those): the build doesn't start, unless an instance operator allows unconfined agents",
1160
1005
  full: "same",
1161
1006
  },
1007
+ {
1008
+ agent: "**Codex / OpenRouter** (any OS)",
1009
+ auto: "Codex's own sandbox, given the same fence: reads your home folder except the folders the project lists, never Toolroll's folders; other tasks' work read, never written; writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
1010
+ full: "the same reads and writes, with the fence; its commands reach the network only when the project's network is open",
1011
+ },
1162
1012
  ];
1163
1013
  /** FENCE_SUMMARY as README's markdown table. */
1164
1014
  export function fenceSummaryMarkdown() {
1165
1015
  return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
1166
1016
  }
1017
+ /** The sign-in files a `sign-ins` check reads, by fixed name under the person's home (never from the environment, so no
1018
+ * caller's CLAUDE_CONFIG_DIR or GH_CONFIG_DIR points a grant at .ssh): Claude's account and settings (and, on Linux,
1019
+ * its credentials file; on a Mac they are in the keychain), gh's hosts and config, and Codex's config and sign-in. */
1020
+ const SIGN_IN_FILES = (platform) => [
1021
+ ".claude.json", join(".claude", "settings.json"), ...(platform === "linux" ? [join(".claude", ".credentials.json")] : []),
1022
+ join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"), join(".codex", "config.toml"), join(".codex", "auth.json"),
1023
+ ];
1024
+ /**
1025
+ * A `sign-ins` check's grants (sandbox parity C10, srt-style): each sign-in file by its name, read by the check itself
1026
+ * through its sandbox. Nothing privileged reads or copies one: the sandbox resolves the name in its own view, so a link
1027
+ * planted there into Toolroll's state leads to a denied path (Seatbelt checks the resolved file; bubblewrap resolves
1028
+ * inside its mount namespace, where the state is masked). On a Mac, the checkout's own Claude transcript folder (made
1029
+ * when absent, only as the real folder it is), whose session files alone are written; and Codex's session and log
1030
+ * folders (when ~/.codex is there), never its config.
1031
+ */
1032
+ export function signInGrants(home, checkout, platform = process.platform) {
1033
+ const base = real(home);
1034
+ // Read by name, but only a name that is itself now: every folder from the home folder down a real one, the file a
1035
+ // regular file with one name (never a link, never a hard link to something else). The sandbox resolves it again.
1036
+ const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => signInIsItself(base, path));
1037
+ const transcript = claudeProjectFolder(base, real(checkout));
1038
+ // A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
1039
+ const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
1040
+ const codex = join(base, ".codex");
1041
+ const writes = grantFolder(codex, false) === null ? [] : ["sessions", "log"].map(name => grantFolder(join(codex, name))).filter((path) => path !== null);
1042
+ return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder], writes, mach: platform === "darwin" ? ["com.apple.trustd.agent"] : [] };
1043
+ }
1044
+ /** Whether a sign-in file is itself, now: every folder from the (real) home folder down a real folder, the file a
1045
+ * regular file with one name (never a link, never a hard link to something else). Checked when granted and again right
1046
+ * before the launch (invoke.ts); the sandbox resolves the name a third time. */
1047
+ export function signInIsItself(base, path) {
1048
+ let at = base;
1049
+ const parts = relative(base, path).split(sep);
1050
+ if (parts.length === 0 || parts[0] === ".." || isAbsolute(relative(base, path)))
1051
+ return false;
1052
+ for (const [index, part] of parts.entries()) {
1053
+ at = join(at, part);
1054
+ try {
1055
+ const held = lstatSync(at);
1056
+ if (held.isSymbolicLink() || (index < parts.length - 1 ? !held.isDirectory() : !(held.isFile() && held.nlink === 1)))
1057
+ return false;
1058
+ }
1059
+ catch {
1060
+ return false;
1061
+ }
1062
+ }
1063
+ return true;
1064
+ }
1065
+ export const FENCE_POLICY_CURRENT = 2;
1066
+ export const LEGACY_SANDBOX = { policy: 1, readDeny: [], machLookup: [] };
1067
+ /** A build's sandbox terms, exactly as its approval sealed them: an approval from before 0.9.70 is policy v1 (the home
1068
+ * configuration denied) with nothing listed. */
1069
+ export function launchSandboxOf(profile) {
1070
+ return { policy: profile.fencePolicy ?? 1, readDeny: profile.readDeny ?? [], machLookup: profile.machLookup ?? [] };
1071
+ }
1072
+ /** The one place a launch's fence is made (docs/plans/sandbox-parity.md C1): its denies, its grants, and
1073
+ * `confinement: "required"`, so the spawn road refuses it where no OS fence applies rather than running it bare. */
1074
+ export function fenceFor(kind, ctx) {
1075
+ const home = ctx.home === undefined ? {} : { home: ctx.home };
1076
+ // The project's read denies join the fence by name, there or not (the runtime denies a path that isn't there yet).
1077
+ const fence = agentFence({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, ...home, boundary: ctx.boundary, binding: ctx.binding, runFolders: ctx.runFolders, extra: [...ctx.extra ?? [], ...ctx.readDeny ?? []] });
1078
+ const found = fenceGrants({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, boundary: ctx.boundary, runFolders: ctx.runFolders, linked: ctx.linked ?? null, binding: ctx.binding, ...home, poolRoots: ctx.poolRoots });
1079
+ // Toolroll's own install, the sandbox runtime's package and the Node running them, never written by anything it
1080
+ // launches: rewriting the code that fences a build would switch the fence off.
1081
+ const made = { ...found, readOnly: [...new Set([...found.readOnly, ...toolrollInstall(ctx.worktree, ctx.home ?? homedir(), ctx.binding?.main ?? null)])].sort() };
1082
+ const written = ctx.writes ?? null;
1083
+ const grants = {
1084
+ ...made,
1085
+ ...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
1086
+ ...(ctx.network === undefined ? {} : { network: ctx.network }),
1087
+ ...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
1088
+ // Under a write allowlist these are out of reach already; where the launch writes anywhere, they are denied by name.
1089
+ writeDenies: homeWriteDenies(ctx.home ?? homedir(), ctx.binding, ctx.provider ?? null),
1090
+ ...(made.pool === undefined ? {} : { pool: { ...made.pool, ...(ctx.policy === 2 ? { readable: true } : {}),
1091
+ // Flows' scratch folders in the pool (and any the caller names) stay denied whole, at every policy version.
1092
+ sealed: sealedScratch(ctx.poolRoots, ctx.scratchRoots ?? []) } }),
1093
+ };
1094
+ return { kind, fence, fenceGrants: grants, confinement: "required" };
1095
+ }
1096
+ /** The flows' scratch folders inside the checkout pools (`flow-checks` beside the checkouts) and any a caller names,
1097
+ * each denied by name whether or not it is there yet, and where it is a link, where it leads too. */
1098
+ export function sealedScratch(poolRoots, named) {
1099
+ const out = new Set();
1100
+ for (const path of [...poolRoots.filter((root) => root !== null).map(root => join(real(root), "flow-checks")), ...named.map(one => join(real(dirname(one)), basename(one)))]) {
1101
+ out.add(path);
1102
+ try {
1103
+ if (lstatSync(path).isSymbolicLink())
1104
+ out.add(realpathSync(path));
1105
+ }
1106
+ catch { /* not there, or dangling: denied by name */ }
1107
+ }
1108
+ return [...out].sort();
1109
+ }
1110
+ /**
1111
+ * Toolroll's own install as no launch may write it: this package's folder (its compiled code, the sandbox launch
1112
+ * helper among it), the sandbox runtime's package wherever it was installed, and the Node running them with the
1113
+ * folder it lives in (its npm and global packages beside it), as the real folders they are. Never a folder that holds
1114
+ * the launch's own checkout or its repository (a build of Toolroll's own source, run from it: that repository's other
1115
+ * files are the primary checkout's, write-denied already), the home folder or one above it, and nothing the user
1116
+ * doesn't own (root's folders are out of a build's reach already).
1117
+ */
1118
+ export function toolrollInstall(worktree, home, repository = null) {
1119
+ const own = worktree === null ? null : real(worktree);
1120
+ const repo = repository === null ? null : real(repository);
1121
+ const uid = process.getuid?.();
1122
+ const ours = (path) => { try {
1123
+ return uid === undefined || statSync(path).uid === uid;
1124
+ }
1125
+ catch {
1126
+ return false;
1127
+ } };
1128
+ let runtime = [];
1129
+ try {
1130
+ runtime = [dirname(dirname(createRequire(import.meta.url).resolve("@anthropic-ai/sandbox-runtime")))];
1131
+ }
1132
+ catch {
1133
+ runtime = [];
1134
+ }
1135
+ const candidates = [resolve(dirname(fileURLToPath(import.meta.url)), ".."), ...runtime, dirname(dirname(real(process.execPath))), real(process.execPath)].map(real);
1136
+ const homeReal = real(home);
1137
+ return [...new Set(candidates)].filter(path => path !== dirname(path) && path !== homeReal && !inside(homeReal, path)
1138
+ && !(own !== null && (own === path || inside(own, path))) && !(repo !== null && (repo === path || inside(repo, path))) && ours(path)).sort();
1139
+ }
1140
+ /** Names that run code or reconfigure tools for whoever opens the project next: the sandbox runtime's DANGEROUS_FILES
1141
+ * (@anthropic-ai/sandbox-runtime sandbox-utils), and the names its mandatory denies can't hold when they are made
1142
+ * after a launch starts (`.envrc`, package-manager and Claude settings). */
1143
+ export const PROTECTED_FILES = [".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile", ".ripgreprc", ".mcp.json",
1144
+ ".envrc", ".npmrc", ".yarnrc", ".yarnrc.yml", ".claude/settings.json", ".claude/settings.local.json", ".git/config"];
1145
+ /** Folders protected whole, wherever they are (the runtime's dangerous directories, with Git's hooks). */
1146
+ export const PROTECTED_DIRS = [".vscode", ".idea", ".claude/commands", ".claude/agents", ".git/hooks"];
1147
+ /**
1148
+ * The handoff's backstop (builder.ts commit): the changed paths (relative, from Git) that are protected names at any
1149
+ * depth, in any case. The sandbox runtime denies these when a launch starts; one made after it started (a new nested
1150
+ * folder's `.envrc`, say) gets through the sandbox, and a result that holds it is refused.
1151
+ */
1152
+ export function protectedChanges(changed) {
1153
+ const files = PROTECTED_FILES.map(one => one.toLowerCase());
1154
+ return changed.filter(path => {
1155
+ const lower = `/${path.toLowerCase()}`;
1156
+ // A protected folder's own name too (a link committed as `pkg/.vscode`), not only what is in it.
1157
+ return files.some(name => lower.endsWith(`/${name}`)) || PROTECTED_DIRS.some(dir => lower.includes(`/${dir.toLowerCase()}/`) || lower.endsWith(`/${dir.toLowerCase()}`));
1158
+ });
1159
+ }
1160
+ /**
1161
+ * The handoff's backstop for links (builder.ts commit): a staged symbolic link (Git mode 120000) whose path is a
1162
+ * protected name or a folder on the way to one (`pkg/.claude`, `.vscode`, `lib/.git`), at any depth, in any case. A
1163
+ * link there would lead whoever opens the project to settings, hooks or commands kept anywhere.
1164
+ */
1165
+ export function protectedLinks(links) {
1166
+ const entries = [...PROTECTED_FILES, ...PROTECTED_DIRS].map(one => one.toLowerCase().split("/"));
1167
+ return links.filter(path => {
1168
+ const parts = path.toLowerCase().split("/").filter(one => one !== "");
1169
+ return entries.some(entry => {
1170
+ // The link's last parts are the entry's first ones (the whole entry, or a folder on the way to it).
1171
+ for (let take = 1; take <= Math.min(entry.length, parts.length); take++) {
1172
+ if (parts.slice(parts.length - take).join("/") === entry.slice(0, take).join("/"))
1173
+ return true;
1174
+ }
1175
+ return false;
1176
+ });
1177
+ });
1178
+ }
1179
+ /** The home folder's own persistence points beyond the protected names (login items, systemd user units and autostart,
1180
+ * Git's, gh's, pip's and fish's configuration, the shells' other startup files, SSH and GnuPG, and the folders on a
1181
+ * login PATH). Under `.config/` each also stands under $XDG_CONFIG_HOME. */
1182
+ export const HOME_PERSISTENCE = ["Library/LaunchAgents", ".config/systemd/user", ".config/autostart", ".config/git", ".config/gh", ".config/pip",
1183
+ ".config/fish", ".pip", ".zshenv", ".zlogin", ".bash_login", ".ssh", ".gnupg", ".local/bin", "bin"];
1184
+ /** The sign-in folders and files at home no launch writes, but an agent its own exact sign-in files (OWN_SIGN_INS). */
1185
+ const HOME_SIGN_INS = [".codex", ".claude.json", ".claude/.credentials.json"];
1186
+ /** The exact sign-in files an agent's own launch still writes (Claude's account and credentials). Codex's harness
1187
+ * refreshes its sign-in outside the sandbox, so its commands write none. Configuration and persistence names stay
1188
+ * denied for every agent. */
1189
+ const OWN_SIGN_INS = { claude: [".claude.json", ".claude/.credentials.json"] };
1190
+ /** `path` as it resolves now: through every link above it (and at it), however much of it exists. */
1191
+ function resolvedPath(path) {
1192
+ let head = path;
1193
+ const rest = [];
1194
+ while (head !== dirname(head)) {
1195
+ try {
1196
+ return join(realpathSync(head), ...rest.reverse());
1197
+ }
1198
+ catch {
1199
+ rest.push(basename(head));
1200
+ head = dirname(head);
1201
+ }
1202
+ }
1203
+ return path;
1204
+ }
1205
+ /**
1206
+ * What no launch writes, whatever its writes, as static write denies for the sandbox runtime (whose own protected names
1207
+ * are anchored at the launch's folder only) and Codex's profile: the home folder's protected names (shell profiles, Git
1208
+ * and tool configuration), its persistence points and sign-ins (but the launch's own agent's exact sign-in files), and
1209
+ * the bound repository's own Git config, hooks and submodules (a linked worktree reaches them through its `.git`
1210
+ * file). Each by its name and by where it leads now (a linked ~/.config or LaunchAgents is denied at both ends). On
1211
+ * Linux only those there at launch: the runtime holds an absent name with a stand-in nothing can read for the launch's
1212
+ * length, and the person's own git and shells then fail on their ~/.gitconfig and ~/.bashrc (measured). Under a write
1213
+ * allowlist an absent one is out of reach anyway; under `anywhere` it can be made (an accepted limit).
1214
+ */
1215
+ export function homeWriteDenies(home, binding, provider, platform = process.platform, env = process.env) {
1216
+ const base = real(home);
1217
+ const own = new Set(OWN_SIGN_INS[provider ?? ""] ?? []);
1218
+ const names = [...new Set([...PROTECTED_FILES.filter(name => !name.startsWith(".git/")), ...PROTECTED_DIRS.filter(name => !name.startsWith(".git/")), ...HOME_PERSISTENCE, ...HOME_SIGN_INS])]
1219
+ .filter(name => !own.has(name));
1220
+ const xdg = env["XDG_CONFIG_HOME"];
1221
+ const roots = (name) => [join(base, name), ...(name.startsWith(".config/") && xdg !== undefined && isAbsolute(xdg) ? [join(xdg, name.slice(".config/".length))] : [])];
1222
+ const paths = names.flatMap(name => roots(name).filter(path => platform !== "linux" || existsSync(path)));
1223
+ const git = binding === null ? [] : ["config", "hooks", "modules"].map(name => join(real(join(binding.main, ".git")), name));
1224
+ return [...new Set([...paths, ...git].flatMap(path => [path, resolvedPath(path)]))].sort();
1225
+ }
1226
+ /** A tool server's grants from its build's: never the agent's network or write allowlist (a server keeps its own state
1227
+ * where it always has), and never the build's own checkout or launch folder read back (nothing a build wrote loads in a
1228
+ * process that holds a tool's secrets). */
1229
+ export function serverGrantsOf(grants) {
1230
+ const { network: _agentOnly, writes: _agentWrites, ...rest } = grants;
1231
+ return {
1232
+ ...rest,
1233
+ ...(rest.pool === undefined ? {} : { pool: { ...rest.pool, own: null } }),
1234
+ ...(rest.runFolders === undefined ? {} : { runFolders: { ...rest.runFolders, own: null } }),
1235
+ };
1236
+ }