toolroll 0.9.69 → 0.9.71

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 (68) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +184 -115
  5. package/dist/agent-fence.js +418 -535
  6. package/dist/agentconfig.js +6 -1
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  8. package/dist/builder.js +24 -4
  9. package/dist/check-access.d.ts +6 -9
  10. package/dist/check-access.js +8 -11
  11. package/dist/coding-provider.d.ts +14 -0
  12. package/dist/coding-provider.js +33 -1
  13. package/dist/coding-workspace.d.ts +15 -0
  14. package/dist/coding-workspace.js +9 -2
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/doctor.d.ts +7 -0
  18. package/dist/doctor.js +4 -0
  19. package/dist/egress.d.ts +2 -0
  20. package/dist/egress.js +13 -4
  21. package/dist/exec.d.ts +17 -3
  22. package/dist/exec.js +54 -17
  23. package/dist/flow-actions.d.ts +1 -0
  24. package/dist/flow-actions.js +3 -2
  25. package/dist/flow-code.d.ts +31 -2
  26. package/dist/flow-code.js +57 -5
  27. package/dist/flow-steps.d.ts +4 -0
  28. package/dist/flow-steps.js +9 -5
  29. package/dist/flow-triggers.d.ts +4 -0
  30. package/dist/flow-triggers.js +8 -3
  31. package/dist/invoke.d.ts +25 -8
  32. package/dist/invoke.js +115 -115
  33. package/dist/operate.js +46 -0
  34. package/dist/project-delete-ui.d.ts +7 -6
  35. package/dist/project-delete-ui.js +37 -3
  36. package/dist/project-sandbox.d.ts +59 -0
  37. package/dist/project-sandbox.js +83 -0
  38. package/dist/project-tools.d.ts +29 -5
  39. package/dist/project-tools.js +43 -7
  40. package/dist/release.d.ts +1 -1
  41. package/dist/release.js +1 -0
  42. package/dist/sandbox-launch.d.ts +1 -0
  43. package/dist/sandbox-launch.js +148 -0
  44. package/dist/sandbox-runtime.d.ts +109 -0
  45. package/dist/sandbox-runtime.js +281 -0
  46. package/dist/scope.d.ts +28 -0
  47. package/dist/scope.js +25 -5
  48. package/dist/scout-browser-lock.d.ts +69 -0
  49. package/dist/scout-browser-lock.js +69 -0
  50. package/dist/scout-browser.d.ts +60 -1
  51. package/dist/scout-browser.js +200 -4
  52. package/dist/scout.d.ts +9 -4
  53. package/dist/scout.js +14 -4
  54. package/dist/serve.js +3 -1
  55. package/dist/server/render-tasks.js +4 -1
  56. package/dist/server/route-table.js +1 -0
  57. package/dist/server/settings.js +27 -0
  58. package/dist/store.d.ts +31 -1
  59. package/dist/store.js +83 -4
  60. package/dist/subagent-tools.d.ts +1 -0
  61. package/dist/subagent-tools.js +7 -5
  62. package/dist/tool-broker.d.ts +2 -1
  63. package/dist/tool-broker.js +2 -2
  64. package/dist/tool-launcher.js +10 -16
  65. package/package.json +2 -1
  66. package/scripts/provider-canary.mjs +5 -6
  67. package/dist/egress-bridge.d.ts +0 -17
  68. package/dist/egress-bridge.js +0 -111
@@ -1,60 +1,40 @@
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. On macOS every launch also gets a pseudo-terminal of its
15
+ * own and no other terminal, and only a build's agent (and a check with sign-ins) reaches the keychain
16
+ * (sandbox-runtime.ts seatbeltRulesFor, static rules).
20
17
  *
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".
18
+ * Codex (every platform) fences its own agent's commands with its own sandbox: a permission profile with the same
19
+ * denies (codexFenceArgv). Claude also gets Read/Edit deny rules for its own file tools.
23
20
  *
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.
21
+ * A build's fence also holds its task boundary (`taskBoundary`): under v1 the home configuration and the repositories
22
+ * beside its own; under v2 only the primary checkout write-denied.
42
23
  */
43
24
  import { INSTANCE_DIR } from "./console-instance.js";
44
25
  import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
45
26
  import { spawnSync } from "node:child_process";
46
27
  import { createHash } from "node:crypto";
47
- import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
28
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
29
+ import { createRequire } from "node:module";
30
+ import { fileURLToPath } from "node:url";
48
31
  import { homedir, tmpdir } from "node:os";
49
32
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
50
33
  import { readMonitoring } from "./monitoring-settings.js";
51
34
  import { existingFolders } from "./names.js";
52
35
  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";
55
- /** Where a Linux sign-ins check's copies of its sign-in files live while it runs (check-access.ts): one folder per launch,
56
- * `c-<pid>-…`, beside the database; fenced from every build by name, like the bridges. */
57
- export const SIGN_IN_COPIES_DIR = "sign-in-copies";
36
+ import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
37
+ import { getDefaultWritePaths } from "@anthropic-ai/sandbox-runtime";
58
38
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
59
39
  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;
60
40
  function real(path) {
@@ -137,59 +117,21 @@ export function agentFence(options) {
137
117
  const fence = new Set();
138
118
  /** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
139
119
  let runFolder = null;
140
- /** The bridges' folder, likewise. */
141
- let bridgeFolder = null;
142
- /** The sign-in copies folder, likewise. */
143
- let copiesFolder = null;
144
120
  const home = real(options.home ?? homedir());
145
121
  const worktree = options.worktree === null ? null : real(options.worktree);
146
122
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
147
123
  const database = real(options.databaseFile);
148
124
  const state = dirname(database);
125
+ // By name, there or not (a journal made later is denied too wherever a fence denies by name: Seatbelt and Codex).
149
126
  for (const suffix of ["", "-wal", "-shm", "-journal"])
150
- if (existsSync(database + suffix))
151
- fence.add(database + suffix);
127
+ fence.add(database + suffix);
152
128
  // The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
153
129
  // build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
154
130
  // 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
155
- // real folder to cover (a Linux bind needs one).
156
131
  // Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
157
132
  // points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
158
133
  // If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
159
134
  // rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
160
- // The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
161
- // from every build, so no build — however long it runs — can reach a bridge made for a later one.
162
- const bridges = join(state, BRIDGE_DIR);
163
- try {
164
- lstatSync(bridges);
165
- }
166
- catch {
167
- try {
168
- mkdirSync(bridges, { mode: 0o700 });
169
- }
170
- catch { /* fenced below either way */ }
171
- }
172
- // Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
173
- // refuses a root that isn't a private folder: egress-bridge.ts).
174
- bridgeFolder = bridges;
175
- fence.add(bridges);
176
- // The sign-in copies (check-access.ts): made before any build starts and fenced from every build, by its own name as
177
- // well as where it leads (as the bridges and the run folder are), whether or not the state folder is Toolroll's own.
178
- // A Linux check binds its own copy from outside its sandbox, so nothing here is granted back. Never swept: a copy a
179
- // launch that died left behind stays in this fenced, private folder (walking a folder a build might swap is the
180
- // greater risk); a root that isn't Toolroll's private folder is never used (signInCopiesRoot).
181
- const copies = join(state, SIGN_IN_COPIES_DIR);
182
- try {
183
- lstatSync(copies);
184
- }
185
- catch {
186
- try {
187
- mkdirSync(copies, { mode: 0o700 });
188
- }
189
- catch { /* fenced below either way */ }
190
- }
191
- copiesFolder = copies;
192
- fence.add(copies);
193
135
  runFolder = join(state, INSTANCE_DIR);
194
136
  const present = () => { try {
195
137
  lstatSync(runFolder);
@@ -237,24 +179,20 @@ export function agentFence(options) {
237
179
  // v104: the folder the audit stream writes to holds the whole instance's history.
238
180
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
239
181
  const stream = readMonitoring(dirname(real(options.databaseFile))).folder?.path;
240
- if (stream !== undefined && existsSync(stream))
182
+ if (stream !== undefined)
241
183
  fence.add(real(stream));
242
184
  }
243
185
  for (const path of options.extra ?? [])
244
- if (existsSync(path))
245
- fence.add(real(path));
186
+ fence.add(real(path));
246
187
  // Secret, state, database, explicit-extra and home-config denies are never exemptible.
247
- if (options.runFolders !== undefined)
248
- for (const path of otherRunFolders(options.runFolders))
249
- fence.add(path);
250
188
  for (const path of options.boundary?.protectedShell ?? [])
251
189
  fence.add(real(path));
252
- const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]));
190
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]));
253
191
  for (const path of options.boundary?.shell ?? [])
254
192
  fence.add(path);
255
193
  const binding = options.binding;
256
194
  const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
257
- return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]))].filter(path => {
195
+ return [...new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]))].filter(path => {
258
196
  if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
259
197
  return true;
260
198
  console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
@@ -297,6 +235,10 @@ export function taskBoundary(options) {
297
235
  const needed = [worktree, main, gitdir].filter((path) => path !== null);
298
236
  const own = new Set(PROVIDER_HOME[options.provider] ?? []);
299
237
  const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
238
+ // Policy v2 (docs/plans/sandbox-parity.md §1.2): the home folder, the repositories beside this one and the project's
239
+ // primary checkout are read as Anthropic's sandbox runtime reads them; only writes to the primary checkout stay denied.
240
+ if ((options.policy ?? 1) === 2)
241
+ return v2Boundary(worktree, main, gitdir);
300
242
  const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
301
243
  const protectedShell = [...shell];
302
244
  const repository = (path) => existsSync(join(path, ".git"));
@@ -338,11 +280,43 @@ export function taskBoundary(options) {
338
280
  shell.add(path);
339
281
  }
340
282
  }
341
- // Git needs this worktree's metadata and the shared objects/refs, but
342
- // must not prune or rewrite the metadata of another task's checkout. It
343
- // still reads that metadata (gc keeps what a sibling's HEAD and index
344
- // reach) and looks up the checkout it names: a checkout it can't see is
345
- // one `git worktree prune` deletes the entry of.
283
+ const { readOnly, lookup } = siblingGitData(main, gitdir);
284
+ const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
285
+ // By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
286
+ // link while a sign-ins check reads through it). The sandbox runtime takes them as read and write denies by name.
287
+ const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
288
+ return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
289
+ }
290
+ /** A v2 boundary: nothing of the home folder, the repositories beside this one or the primary checkout is denied to
291
+ * read; the primary checkout (all but its `.git`, which this worktree's Git needs) is never written; other tasks' Git
292
+ * metadata is read, never written, as under v1. */
293
+ function v2Boundary(worktree, main, gitdir) {
294
+ const noWrite = new Set();
295
+ if (main !== null && main !== worktree) {
296
+ let entries = [];
297
+ try {
298
+ entries = readdirSync(main);
299
+ }
300
+ catch {
301
+ entries = [];
302
+ }
303
+ for (const entry of entries) {
304
+ const path = join(main, entry);
305
+ if (entry !== ".git" && path !== worktree && !inside(worktree, path))
306
+ noWrite.add(path);
307
+ }
308
+ }
309
+ const { readOnly, lookup } = siblingGitData(main, gitdir);
310
+ for (const path of noWrite)
311
+ readOnly.add(path);
312
+ return { protectedShell: [], shell: [], fileTools: [], readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames: [] };
313
+ }
314
+ /**
315
+ * Git needs this worktree's metadata and the shared objects/refs, but must not prune or rewrite the metadata of another
316
+ * task's checkout. It still reads that metadata (gc keeps what a sibling's HEAD and index reach) and looks up the
317
+ * checkout it names: a checkout it can't see is one `git worktree prune` deletes the entry of.
318
+ */
319
+ function siblingGitData(main, gitdir) {
346
320
  const readOnly = new Set();
347
321
  const lookup = new Set();
348
322
  if (gitdir !== null && main !== null) {
@@ -386,15 +360,9 @@ export function taskBoundary(options) {
386
360
  lookup.add(join(checkout, ".git"));
387
361
  }
388
362
  }
389
- const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
390
- // By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
391
- // link while a sign-ins check reads through it). Only Seatbelt uses these (macosFenceProfile); bubblewrap can only
392
- // mask what exists, and Codex's own sandbox takes existing paths.
393
- const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
394
- return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
363
+ return { readOnly, lookup };
395
364
  }
396
- /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
397
- * network bridge (egress-bridge.ts). */
365
+ /** The prefixes of every launch's private folders: its tools' config and a Codex build's own home (and a check's). */
398
366
  export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
399
367
  export function runFolders(own, root) {
400
368
  // The system's own roots (not one a caller names): on a Mac, checks' launch folders are under the short one too.
@@ -402,9 +370,9 @@ export function runFolders(own, root) {
402
370
  return { root: real(root ?? tmpdir()), own: own === null ? null : real(own), ...(also.length === 0 ? {} : { also }) };
403
371
  }
404
372
  /** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
405
- * path (about 104 bytes); every other launch folder there is denied by pattern, as under the system temp folder. */
373
+ * path (about 104 bytes). */
406
374
  export const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
407
- /** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder (unchanged). */
375
+ /** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder. */
408
376
  export const checkLaunchRoot = () => process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? CHECK_LAUNCH_ROOT_MACOS : tmpdir();
409
377
  /** Every other launch's private folder in the system temp folder now, each only as the real folder it is (a link planted
410
378
  * there is never followed, nor fenced in its target's place) and this user's own. A shared root (`also`, /private/tmp on
@@ -587,17 +555,6 @@ export function buildCacheEnv(folder) {
587
555
  DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
588
556
  };
589
557
  }
590
- /**
591
- * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
592
- * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
593
- * (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
594
- * confined; the run's proxy resolves names for it.
595
- */
596
- export const DELEGATION_SERVICES = [
597
- "com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
598
- "com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
599
- "com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
600
- ];
601
558
  export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
602
559
  /**
603
560
  * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
@@ -713,6 +670,35 @@ const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
713
670
  * `codexPool`, which denies them around the build's own checkout, written back.
714
671
  */
715
672
  export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
673
+ const access = codexFilesystem(fence, fullAccess, grants);
674
+ const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
675
+ return [
676
+ "-c", 'default_permissions="standing-orders"',
677
+ "-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
678
+ "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
679
+ // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
680
+ // network is open (and Full access asked for it), and are offline otherwise.
681
+ ...(codexNetworkOn(fullAccess, grants) ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
682
+ ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
683
+ ];
684
+ }
685
+ const codexNetworkOn = (fullAccess, grants) => fullAccess && (grants.network === undefined || grants.network.mode === "open");
686
+ /**
687
+ * The same permission profile as codexFenceArgv, as the config overrides a Codex app-server thread takes
688
+ * (`thread/start` and `thread/resume` `config`; checked against codex-cli 0.156: the thread reports
689
+ * `activePermissionProfile` standing-orders, and its commands are refused the denied paths). Approval policy is the
690
+ * caller's own (an interactive session asks).
691
+ */
692
+ export function codexFenceConfig(fence, fullAccess, grants = NO_GRANTS) {
693
+ return {
694
+ default_permissions: "standing-orders",
695
+ "permissions.standing-orders.extends": grants.writes === undefined ? ":workspace" : ":read-only",
696
+ "permissions.standing-orders.filesystem": Object.fromEntries(codexFilesystem(fence, fullAccess, grants)),
697
+ ...(codexNetworkOn(fullAccess, grants) ? { "permissions.standing-orders.network.enabled": true } : {}),
698
+ };
699
+ }
700
+ /** Each path of a Codex permission profile and its access, the strongest last (a deny outranks a grant). */
701
+ function codexFilesystem(fence, fullAccess, grants) {
716
702
  const { deny, readOnly } = flattened(fence, grants);
717
703
  // Codex's harness reads its own launch files outside the command sandbox.
718
704
  // Deny the namespace, including folders created after this policy was built.
@@ -747,27 +733,39 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
747
733
  for (const path of held.read)
748
734
  access.set(path, "read");
749
735
  for (const path of held.deny)
750
- access.set(path, "deny");
736
+ access.set(path, pool.readable === true && !(pool.sealed ?? []).some(one => path === one || inside(path, one)) ? "read" : "deny");
737
+ // A sealed folder (flows' scratch) is denied whole, but on the way to this launch's own folder (Codex can't deny an
738
+ // ancestor and still reach inside it: its other entries are denied one by one, by codexPool, from the same split).
739
+ for (const sealed of pool.sealed ?? [])
740
+ if (!(pool.own !== null && (pool.own === sealed || inside(pool.own, sealed))))
741
+ access.set(sealed, "deny");
751
742
  for (const path of held.write)
752
743
  access.set(path, "write");
753
744
  }
754
- // Denies are keyed where they resolve; the console's run folder and the sign-in copies folder are also denied by their
745
+ // What no launch writes (homeWriteDenies): read-only, where Codex writes at all (Full access, or an allowlist).
746
+ // Never inside a denied path: Codex lets the more specific path win, so a read there would bring it back.
747
+ const deniedPaths = deny.filter(one => !one.includes("*")).map(real);
748
+ for (const path of grants.writeDenies ?? []) {
749
+ const leads = resolvedPath(path);
750
+ const denied = (one) => access.get(one) === "deny" || deniedPaths.some(den => one === den || inside(one, den));
751
+ // A name that leads into a denied path (a linked ~/.ssh into a read-denied folder) is denied by its own name too,
752
+ // never read back.
753
+ if (denied(leads)) {
754
+ access.set(path, "deny");
755
+ continue;
756
+ }
757
+ if (denied(path))
758
+ continue;
759
+ access.set(path, "read");
760
+ }
761
+ // Denies are keyed where they resolve; the console's run folder is also denied by its
755
762
  // own pathname, so a link there stays unreplaceable, not only its target.
756
763
  for (const path of deny) {
757
764
  access.set(real(path), "deny");
758
- if (basename(path) === INSTANCE_DIR || basename(path) === SIGN_IN_COPIES_DIR)
765
+ if (basename(path) === INSTANCE_DIR)
759
766
  access.set(path, "deny");
760
767
  }
761
- const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
762
- return [
763
- "-c", 'default_permissions="standing-orders"',
764
- "-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
765
- "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
766
- // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
767
- // network is open (and Full access asked for it), and are offline otherwise.
768
- ...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
769
- ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
770
- ];
768
+ return access;
771
769
  }
772
770
  /**
773
771
  * Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
@@ -864,140 +862,7 @@ export function claudeFenceSettings(fence, readOnly = []) {
864
862
  const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
865
863
  return JSON.stringify({ permissions: { deny: rules } });
866
864
  }
867
- const seatbelt = (path) => `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
868
- const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
869
- /**
870
- * The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
871
- * user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
872
- * back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
873
- */
874
- export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
875
- const out = new Set();
876
- const owned = (path) => { try {
877
- return uid !== undefined && statSync(path).uid === uid;
878
- }
879
- catch {
880
- return false;
881
- } };
882
- const climb = (from) => {
883
- for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
884
- if (owned(folder))
885
- out.add(folder);
886
- };
887
- for (const path of paths)
888
- climb(dirname(path));
889
- for (const root of roots)
890
- climb(root);
891
- return [...out].sort();
892
- }
893
- /**
894
- * A Seatbelt profile that allows everything except reading or writing the
895
- * fenced paths, less what `grants` gives back (a later Seatbelt rule
896
- * outranks an earlier one). Whatever the paths, the process can't read
897
- * another's arguments and environment (`kern.procargs`, which `ps -E` and
898
- * KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
899
- * `process-info*` limited by target alone doesn't stop the sysctl. It can't
900
- * take another process's task port either (`task_for_pid`, what lldb attaches
901
- * with; a task name port too), so a tool server's or Toolroll's own memory
902
- * and environment stay out of reach. Last, every folder of the user's above
903
- * a fenced or read-only path and above the temp folder (and the temp folder
904
- * itself) can't be renamed or removed, though what is inside them stays
905
- * writable as before. The checkout pools are denied first, and the build's own
906
- * checkout given back right after, so every later deny still holds inside it.
907
- */
908
- export function macosFenceProfile(fence, grants = NO_GRANTS) {
909
- const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
910
- const folders = grants.runFolders;
911
- const pool = grants.pool;
912
- const homeNames = grants.homeNames ?? [];
913
- const above = ownedAncestors([...fence, ...grants.readOnly, ...homeNames, ...pool?.deny ?? []], folders === undefined ? [real(tmpdir())] : runRoots(folders));
914
- // A home name's folder that isn't there yet (~/.config for ~/.config/gh): made by the build, it could be a link to
915
- // anywhere and carry the name out of its deny, so nothing may make it while the build runs.
916
- const missingAbove = [...new Set(homeNames.flatMap(name => {
917
- const out = [];
918
- for (let folder = dirname(name); folder !== dirname(folder) && !existsSync(folder); folder = dirname(folder))
919
- out.push(folder);
920
- return out;
921
- }))].sort();
922
- return [
923
- "(version 1)",
924
- "(allow default)",
925
- // A workspace build writes only its allowlist (and the terminal devices); every deny and read-only rule after this
926
- // still holds inside it.
927
- ...(grants.writes === undefined ? [] : [
928
- "(deny file-write*)",
929
- `(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("")})`,
930
- // A granted folder itself can't be renamed, removed or replaced (by a link to anywhere, say): only what is in it.
931
- ...(grants.writes.subpaths.length === 0 ? [] : [`(deny file-write* ${paths("literal", grants.writes.subpaths)})`]),
932
- ]),
933
- ...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
934
- ...(pool?.own == null ? [] : [
935
- `(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
936
- `(deny file-write* ${paths("literal", [pool.own])})`,
937
- ]),
938
- ...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
939
- ...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
940
- // The home configuration by name, there or not: nothing may create, read, replace or write one (the sign-ins grants
941
- // after this give back only their exact files).
942
- ...(homeNames.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", homeNames)})`]),
943
- ...(missingAbove.length === 0 ? [] : [`(deny file-write* ${paths("literal", missingAbove)})`]),
944
- ...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
945
- ...(folders === undefined ? [] : runRoots(folders).map(root => `(deny file-read* file-write* (regex #"^${seatbeltRegex(root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`)),
946
- ...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
947
- // Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
948
- ...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
949
- // A sign-ins check (check-access.ts): its exact sign-in files read, its own transcript folder written; nothing else
950
- // of the home configuration comes back.
951
- ...(grants.signIns === undefined || grants.signIns.read.length === 0 ? [] : [`(allow file-read* ${paths("literal", grants.signIns.read)})`]),
952
- // Its own transcript folder: only the session files directly in it (a session can be resumed). Nothing else there —
953
- // never memory/ or any folder Claude would load into a later real session — comes back.
954
- ...(grants.signIns === undefined || grants.signIns.transcripts.length === 0 ? [] : [
955
- `(allow file-read-data file-read-metadata ${paths("literal", grants.signIns.transcripts)})`,
956
- `(allow file-read* file-write* ${grants.signIns.transcripts.map(folder => `(regex #"^${seatbeltRegex(folder)}/[^/]+\\.jsonl$")`).join(" ")})`,
957
- ]),
958
- // Each readable copy's own folder can be listed, never read: bundlers such as esbuild find node_modules by listing
959
- // its parent, which a lookup alone refuses (the Linux and Codex fences list it as read-only already).
960
- ...(grants.readable.length === 0 ? [] : [`(allow file-read-data ${paths("literal", [...new Set(grants.readable.map(one => dirname(one)))])})`]),
961
- // Its own launch folder: what is in it, never the folder itself (it can't be renamed, removed or replaced by a link).
962
- ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`, `(deny file-write* ${paths("literal", [folders.own])})`]),
963
- '(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
964
- "(deny process-info-pidinfo)",
965
- "(allow process-info-pidinfo (target same-sandbox))",
966
- "(deny mach-priv-task-port)",
967
- "(deny mach-task-name)",
968
- ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
969
- ...macosNetworkRules(grants.network),
970
- // A workspace build reaches no unix socket but its own (a container engine's, an agent's, a database's would act
971
- // outside its limits for it), whatever its network: a confined network has its own rule for this already. Its open
972
- // network still names hosts: the system's DNS service answers on a unix socket, and without it nothing resolves.
973
- ...(grants.writes === undefined || grants.network?.mode === "allowlist" ? [] : [
974
- "(deny network-outbound (remote unix-socket))",
975
- `(allow network-outbound (remote unix-socket (path-literal ${seatbelt(MACOS_DNS_SOCKET)})))`,
976
- ...(grants.writes.subpaths.length === 0 ? [] : [`(allow network-outbound ${grants.writes.subpaths.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
977
- ]),
978
- ].join("\n");
979
- }
980
- /**
981
- * The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
982
- * allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
983
- * allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
984
- * or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
985
- * connection.
986
- */
987
- export function macosNetworkRules(network) {
988
- if (network === undefined || network.mode === "open")
989
- return [];
990
- const local = (port) => `(remote ip "localhost:${port}")`;
991
- return [
992
- "(deny network-outbound)",
993
- `(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
994
- ...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
995
- `(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
996
- ];
997
- }
998
- export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
999
- /** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
1000
- export const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
865
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
1001
866
  /** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
1002
867
  const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
1003
868
  let seatbeltWorks = null;
@@ -1023,223 +888,130 @@ export function macosFenceAvailable(platform = process.platform, probe) {
1023
888
  if (probe !== undefined)
1024
889
  return works(probe);
1025
890
  seatbeltWorks ??= works(() => {
1026
- const ran = spawnSync(SANDBOX_EXEC, ["-p", "(version 1)(allow default)", "/usr/bin/true"], { timeout: 5_000, encoding: "utf8" });
891
+ const ran = spawnSync(SANDBOX_EXEC, ["-p", SEATBELT_PROBE, "/usr/bin/true"], { timeout: 5_000, encoding: "utf8" });
1027
892
  return { status: ran.status, stderr: ran.stderr ?? "" };
1028
893
  });
1029
894
  return seatbeltWorks;
1030
895
  }
1031
- /** The spawn that runs `file args` inside the macOS fence (same process: sandbox-exec replaces itself with the agent). */
1032
- export function macosFenced(file, args, fence, grants = NO_GRANTS) {
1033
- return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence, grants), file, ...args] };
1034
- }
1035
- /** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
1036
- let bubblewrapWorks = null;
1037
- export function linuxFenceAvailable(platform = process.platform, probe = () => spawnSync("bwrap", ["--dev-bind", "/", "/", "--", "true"], { timeout: 5_000, stdio: "ignore" }).status === 0) {
896
+ /** Whether the sandbox runtime can sandbox here on Linux (checked once): bubblewrap, socat, its seccomp helper and
897
+ * ripgrep installed, and unprivileged user namespaces allowed. A machine that can't is no Linux fence at all. */
898
+ let linuxWorks = null;
899
+ export function linuxFenceAvailable(platform = process.platform, probe = () => sandboxRuntimeProblem("linux") === null) {
1038
900
  if (platform !== "linux")
1039
901
  return false;
1040
- if (bubblewrapWorks === null) {
902
+ if (linuxWorks === null) {
1041
903
  try {
1042
- bubblewrapWorks = probe();
904
+ linuxWorks = probe();
1043
905
  }
1044
906
  catch {
1045
- bubblewrapWorks = false;
907
+ linuxWorks = false;
1046
908
  }
1047
909
  }
1048
- return bubblewrapWorks;
910
+ return linuxWorks;
911
+ }
912
+ /** Thrown when no sandbox can be made for a launch: the spawn road refuses it. */
913
+ export class LinuxFenceUnavailable extends Error {
1049
914
  }
1050
- /** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
1051
- const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
1052
- const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
1053
915
  /**
1054
- * The grants bubblewrap may bind back over its masks: each existing read-only or readable path, never one that is a
1055
- * fenced path itself, a read-only one inside a fenced path (only the shared install this run links is read inside
1056
- * one), another launch's private folder, or one with a secret-looking name below its fenced folder. Bubblewrap can't
1057
- * mask by pattern, so a grant a pattern would deny is dropped instead (fail closed).
916
+ * The runtime's config for a launch (sandbox-runtime.ts SrtTerms), from its fence: every fenced path denied to read and
917
+ * write (with the exact paths read again inside them), the pools' other checkouts write-denied (and, under v1, read-
918
+ * denied) around the build's own, the flows' scratch folders sealed, the launch's writes (its allowlist, or anywhere
919
+ * outside the denies), its mach services and its network.
1058
920
  */
1059
- function linuxGrants(fence, grants) {
1060
- const folders = grants.runFolders;
1061
- const launchFolder = (path) => folders !== undefined && path !== folders.own && !(folders.own != null && inside(path, folders.own))
1062
- && runRoots(folders).some(root => inside(path, root) && RUN_FOLDER_PREFIXES.some(prefix => relative(root, path).split(sep)[0].startsWith(prefix)));
1063
- const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
1064
- const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
1065
- && !(readOnlyGrant && fence.some(one => inside(path, one)));
1066
- return [...new Set([...grants.readOnly.filter(path => ok(path, true)), ...grants.readable.filter(path => ok(path, false))])];
1067
- }
1068
- /** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, the checkout pools masked
1069
- * and the build's own checkout bound back read-write over them, then every fenced path masked whole (an empty
1070
- * folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
1071
- * bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
1072
- export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
1073
- const network = grants.network?.mode === "allowlist" ? grants.network : null;
1074
- if (network !== null && network.bridge === undefined)
1075
- throw new Error("A confined network on Linux needs its bridge");
1076
- const mask = (path) => {
1077
- let folder = true;
1078
- try {
1079
- folder = statSync(path).isDirectory();
1080
- }
1081
- catch {
1082
- folder = true;
1083
- }
1084
- return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
1085
- };
1086
- const pool = grants.pool?.deny ?? [];
1087
- const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
1088
- const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
1089
- // A sign-ins check's exact files, bound back over the masks (validated when granted: signInGrants).
1090
- const signIns = grants.signIns === undefined ? [] : [
1091
- // Each from the launch's own copy (made from the validated, opened file), never the path itself: a link swapped in
1092
- // after validation is never followed by the bind. A file with no copy isn't granted.
1093
- ...grants.signIns.read.flatMap(path => { const copy = grants.signIns.copies?.[path]; return copy !== undefined && existsSync(copy) ? ["--ro-bind", copy, path] : []; }),
1094
- // Bubblewrap can't bind by pattern: a session transcript folder is never given back writable on Linux (a check
1095
- // there can't resume a Claude session; it can't plant anything a later session loads either).
1096
- ];
1097
- // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
1098
- // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
1099
- const isolated = network === null ? [] : [
1100
- "--unshare-net", "--unshare-pid", "--proc", "/proc",
1101
- ...linuxHostSockets(undefined, undefined, undefined, grants.writes !== undefined).flatMap(mask),
1102
- "--ro-bind", network.bridge.folder, network.bridge.folder,
1103
- ];
1104
- // A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
1105
- // all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
1106
- // A workspace build (arc 2) sees the whole filesystem read-only, a /dev of its own and a /tmp of its own, with only
1107
- // its allowlist bound back writable; its own checkout's Git data is in that list.
1108
- const writes = grants.writes;
1109
- const root = writes === undefined ? ["--dev-bind", "/", "/"] : ["--ro-bind", "/", "/", "--dev", "/dev"];
1110
- const writable = writes === undefined ? [] : [...writes.subpaths, ...writes.prefixes.map(one => one.path)].filter(path => existsSync(path)).flatMap(path => ["--bind", path, path]);
1111
- // What a private /tmp gives back is read-only for a workspace build (its writable part is bound again after): the
1112
- // checkout's whole shared Git data among it must not come back writable.
1113
- // A confined network brings a /tmp of its own (arc 1); a workspace build with an open network sees the shared /tmp
1114
- // read-only like the rest of the root, its own folders there bound back writable.
1115
- const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network?.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => [writes === undefined ? "--bind" : "--ro-bind", path, path])];
1116
- // A workspace build never reaches the host's own services either (a container engine would write anywhere for it),
1117
- // whatever its network: confined networks mask them below already.
1118
- const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
1119
- const command = network === null ? [file, ...args]
1120
- : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
1121
- return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...signIns, ...services, ...isolated, "--die-with-parent", "--", ...command] };
1122
- }
1123
- /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
1124
- * engines), wherever they exist here. */
1125
- export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp", followLinks = false, env = process.env) {
1126
- // The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
1127
- const system = [
1128
- ...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
1129
- "/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
1130
- ];
1131
- const masks = new Set();
1132
- for (const path of system) {
1133
- try {
1134
- masks.add(realpathSync(path));
1135
- }
1136
- catch { /* not here */ }
921
+ export function srtTermsOf(fence, grants = NO_GRANTS) {
922
+ const denyRead = new Set(fence.map(real));
923
+ // A readable grant's own folder is read too: bundlers (esbuild) find node_modules by listing it. The runtime can't
924
+ // grant a listing without the folder's files, so the shared copy's folder is read whole (its ready note among them).
925
+ const allowRead = new Set(grants.readable.flatMap(path => [real(path), real(dirname(path))]));
926
+ const denyWrite = new Set([...fence, ...grants.readOnly].map(real));
927
+ for (const path of grants.writeDenies ?? [])
928
+ denyWrite.add(path);
929
+ for (const name of grants.homeNames ?? []) {
930
+ denyRead.add(name);
931
+ denyWrite.add(name);
1137
932
  }
1138
- // The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
1139
- // through a link (a planted link would turn a mask onto any folder at all). Container engines
1140
- // run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
1141
- // Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
1142
- // socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
1143
- // Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
1144
- // a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
1145
- const owned = (path) => {
1146
- try {
1147
- const held = lstatSync(path);
1148
- return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
1149
- }
1150
- catch {
1151
- return false;
1152
- }
1153
- };
1154
- for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name))) {
1155
- if (owned(path))
1156
- masks.add(path);
1157
- // A workspace build can't plant a link in the home folder, so a link there is the operator's own: its target is
1158
- // masked too (an engine moved elsewhere).
1159
- else if (followLinks) {
1160
- try {
1161
- if (lstatSync(path).isSymbolicLink())
1162
- masks.add(realpathSync(path));
1163
- }
1164
- catch { /* dangling */ }
1165
- }
1166
- }
1167
- // An engine Toolroll's own environment points at, wherever it is (never the build's to choose): always masked. So is
1168
- // the SSH agent it names (it signs as you), which a shared /tmp would otherwise leave in reach.
1169
- for (const name of ["DOCKER_HOST", "CONTAINER_HOST"]) {
1170
- const socket = /^unix:\/\/(\/.+)$/.exec(env[name] ?? "")?.[1];
1171
- if (socket !== undefined) {
1172
- try {
1173
- masks.add(realpathSync(socket));
933
+ for (const file of grants.signIns?.read ?? [])
934
+ allowRead.add(file);
935
+ // Every other launch's private folder unread by pattern (one made after this launch too), this launch's own read
936
+ // again. Writes are denied to those there at launch, under every root: the runtime can't write back inside a write
937
+ // deny, so a pattern would take this launch's own folder too (one made after launch is writable under `anywhere`).
938
+ const folders = grants.runFolders;
939
+ if (folders !== undefined) {
940
+ for (const root of runRoots(folders)) {
941
+ for (const prefix of RUN_FOLDER_PREFIXES)
942
+ denyRead.add(join(root, `${prefix}*`));
943
+ // And by name, each there right before the launch (this runs at the spawn): on Linux the runtime gives a pattern
944
+ // no mask where a path it allows lies beside its matches. Written: only where the launch writes anywhere (under a
945
+ // write allowlist they are out of its writes already).
946
+ for (const path of otherRunFolders({ root, own: folders.own })) {
947
+ denyRead.add(path);
948
+ if (grants.writes === undefined)
949
+ denyWrite.add(path);
1174
950
  }
1175
- catch { /* not there */ }
1176
951
  }
952
+ if (folders.own !== null)
953
+ allowRead.add(folders.own);
1177
954
  }
1178
- const agent = env["SSH_AUTH_SOCK"];
1179
- if (agent !== undefined && isAbsolute(agent)) {
1180
- try {
1181
- const socket = realpathSync(agent);
1182
- masks.add(socket);
1183
- // The folder an agent makes for its socket (ssh-XXXX in /tmp) too, so a socket it makes next is out of reach.
1184
- if (inside(socket, real(tmp)) && dirname(socket) !== real(tmp) && !lstatSync(dirname(socket)).isSymbolicLink())
1185
- masks.add(dirname(socket));
955
+ const pool = grants.pool;
956
+ if (pool !== undefined) {
957
+ // Under v1 the pools are unread too, the build's own checkout read again; under v2 they are read (owner Q-C).
958
+ if (pool.readable !== true)
959
+ for (const root of pool.deny)
960
+ denyRead.add(root);
961
+ // The build's own checkout is read again inside whatever is denied around it (the pools, a flow's sealed scratch).
962
+ if (pool.own !== null)
963
+ allowRead.add(pool.own);
964
+ // Never written but the build's own checkout: the runtime can't write back inside a write deny, so each other
965
+ // entry on the way is denied instead (codexPool's split, as Codex takes it).
966
+ for (const path of codexPool(pool).deny)
967
+ denyWrite.add(path);
968
+ for (const sealed of pool.sealed ?? []) {
969
+ denyRead.add(sealed);
970
+ if (!(pool.own !== null && (pool.own === sealed || inside(pool.own, sealed))))
971
+ denyWrite.add(sealed);
1186
972
  }
1187
- catch { /* not there */ }
1188
973
  }
1189
- // A build that shares /tmp (a workspace build with an open network) reaches none of the sockets there either: every
1190
- // socket two levels down (a multiplexer's, an agent's, a display's, an editor's), and the folder that holds it, taken
1191
- // as it is (never through a link). Toolroll's own launch folders are fenced by name already.
1192
- if (followLinks) {
1193
- const socketIn = (folder, depth) => {
1194
- let found = false;
1195
- let entries = [];
1196
- try {
1197
- entries = readdirSync(folder);
1198
- }
1199
- catch {
1200
- return false;
1201
- }
1202
- for (const entry of entries) {
1203
- const path = join(folder, entry);
1204
- if (RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix)))
1205
- continue;
1206
- let held;
1207
- try {
1208
- held = lstatSync(path);
1209
- }
1210
- catch {
1211
- continue;
1212
- }
1213
- if (held.isSocket()) {
1214
- masks.add(path);
1215
- found = true;
1216
- }
1217
- else if (held.isDirectory() && !held.isSymbolicLink() && depth > 0 && socketIn(path, depth - 1)) {
1218
- masks.add(path);
1219
- found = true;
1220
- }
1221
- }
1222
- return found;
1223
- };
1224
- socketIn(tmp, 1);
1225
- }
1226
- return [...masks].sort();
974
+ const readConfig = { denyOnly: [...denyRead].sort(), allowWithinDeny: [...allowRead].sort() };
975
+ const writes = grants.writes;
976
+ // A sign-ins check also writes Codex's session and log folders and its own Claude transcripts (never inside a
977
+ // write deny: under policy v1 the home configuration stays unwritten whole).
978
+ const signInWrites = [...grants.signIns?.writes ?? [], ...(grants.signIns?.transcripts ?? []).map(folder => join(folder, "*.jsonl"))];
979
+ // Writing anywhere: everything but the denies. The launch's own checkout and folder are named too, so a backend that
980
+ // binds a read deny's re-allowed paths back (bubblewrap) binds those back writable.
981
+ const allowOnly = writes === undefined ? ["/", ...(pool?.own == null ? [] : [real(pool.own)]), ...(folders?.own == null ? [] : [folders.own])]
982
+ : [...writes.subpaths, ...writes.prefixes.map(one => `${one.path}*`), ...signInWrites, ...getDefaultWritePaths({ denyRead: readConfig.denyOnly, allowRead: readConfig.allowWithinDeny })];
983
+ const network = grants.network;
984
+ return {
985
+ readConfig,
986
+ writeConfig: { allowOnly: [...new Set(allowOnly)].sort(), denyWithinAllow: [...denyWrite].sort() },
987
+ allowMachLookup: [...grants.machLookup ?? [], ...grants.signIns?.mach ?? []],
988
+ ...(grants.keychain === true ? { keychain: true } : {}),
989
+ network: network === undefined || network.mode === "open" ? { kind: "open" } : {
990
+ kind: "proxy", httpPort: network.proxyPort, ...(network.brokerPorts[0] === undefined ? {} : { socksPort: network.brokerPorts[0] }),
991
+ ...(network.token === undefined ? {} : { token: network.token }), localServices: network.loopback === "any", unixSockets: [...network.socketRoots],
992
+ },
993
+ };
994
+ }
995
+ /** The spawn that runs `file args` from `cwd` inside the sandbox runtime's sandbox (sandbox-runtime.ts srtSpawn), and the
996
+ * environment it needs beside the caller's. */
997
+ export function sandboxedSpawn(file, args, fence, grants = NO_GRANTS, cwd, temp) {
998
+ return srtSpawn(file, args, srtTermsOf(fence, grants), cwd ?? process.cwd(), temp);
1227
999
  }
1228
1000
  /**
1229
1001
  * What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
1230
1002
  * (fenceSummaryMarkdown; a test holds them equal).
1231
1003
  */
1232
1004
  export const FENCE_SUMMARY = [
1233
- {
1234
- agent: "**Codex / OpenRouter** (any OS)",
1235
- 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",
1236
- full: "the same writes, with the fence; its commands reach the network only when the project's network is open",
1237
- },
1238
1005
  {
1239
1006
  agent: "**Claude, Gemini, ACP agents**",
1240
- 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",
1007
+ 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",
1241
1008
  full: "same",
1242
1009
  },
1010
+ {
1011
+ agent: "**Codex / OpenRouter** (any OS)",
1012
+ 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",
1013
+ full: "the same reads and writes, with the fence; its commands reach the network only when the project's network is open",
1014
+ },
1243
1015
  ];
1244
1016
  /** FENCE_SUMMARY as README's markdown table. */
1245
1017
  export function fenceSummaryMarkdown() {
@@ -1247,112 +1019,223 @@ export function fenceSummaryMarkdown() {
1247
1019
  }
1248
1020
  /** The sign-in files a `sign-ins` check reads, by fixed name under the person's home (never from the environment, so no
1249
1021
  * caller's CLAUDE_CONFIG_DIR or GH_CONFIG_DIR points a grant at .ssh): Claude's account and settings (and, on Linux,
1250
- * its credentials file; on a Mac they are in the keychain), and gh's hosts and config. Codex reads copies in a folder
1251
- * of the launch's own (invoke.ts), so nothing of ~/.codex is granted. */
1022
+ * its credentials file; on a Mac they are in the keychain), gh's hosts and config, and Codex's config and sign-in. */
1252
1023
  const SIGN_IN_FILES = (platform) => [
1253
1024
  ".claude.json", join(".claude", "settings.json"), ...(platform === "linux" ? [join(".claude", ".credentials.json")] : []),
1254
- join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"),
1025
+ join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"), join(".codex", "config.toml"), join(".codex", "auth.json"),
1255
1026
  ];
1256
1027
  /**
1257
- * A `sign-ins` check's grants: each sign-in file only as the real regular file it is (never a link, nor reached
1258
- * through one), and on a Mac the checkout's own Claude transcript folder (made when absent, only as the real folder it
1259
- * is), whose session files alone are written so a session the check starts can be resumed. Nothing else under
1260
- * ~/.claude, ~/.codex or ~/.config is granted.
1028
+ * A `sign-ins` check's grants (sandbox parity C10, srt-style): each sign-in file by its name, read by the check itself
1029
+ * through its sandbox. Nothing privileged reads or copies one: the sandbox resolves the name in its own view, so a link
1030
+ * planted there into Toolroll's state leads to a denied path (Seatbelt checks the resolved file; bubblewrap resolves
1031
+ * inside its mount namespace, where the state is masked). On a Mac, the checkout's own Claude transcript folder (made
1032
+ * when absent, only as the real folder it is), whose session files alone are written; and Codex's session and log
1033
+ * folders (when ~/.codex is there), never its config.
1261
1034
  */
1262
1035
  export function signInGrants(home, checkout, platform = process.platform) {
1263
1036
  const base = real(home);
1264
- const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => {
1037
+ // Read by name, but only a name that is itself now: every folder from the home folder down a real one, the file a
1038
+ // regular file with one name (never a link, never a hard link to something else). The sandbox resolves it again.
1039
+ const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => signInIsItself(base, path));
1040
+ const transcript = claudeProjectFolder(base, real(checkout));
1041
+ // A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
1042
+ const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
1043
+ const codex = join(base, ".codex");
1044
+ const writes = grantFolder(codex, false) === null ? [] : ["sessions", "log"].map(name => grantFolder(join(codex, name))).filter((path) => path !== null);
1045
+ return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder], writes, mach: platform === "darwin" ? ["com.apple.trustd.agent"] : [] };
1046
+ }
1047
+ /** Whether a sign-in file is itself, now: every folder from the (real) home folder down a real folder, the file a
1048
+ * regular file with one name (never a link, never a hard link to something else). Checked when granted and again right
1049
+ * before the launch (invoke.ts); the sandbox resolves the name a third time. */
1050
+ export function signInIsItself(base, path) {
1051
+ let at = base;
1052
+ const parts = relative(base, path).split(sep);
1053
+ if (parts.length === 0 || parts[0] === ".." || isAbsolute(relative(base, path)))
1054
+ return false;
1055
+ for (const [index, part] of parts.entries()) {
1056
+ at = join(at, part);
1265
1057
  try {
1266
- const held = lstatSync(path);
1267
- // One name only: a hard link made to another file (Toolroll's database, say) would be read through this grant.
1268
- return held.isFile() && !held.isSymbolicLink() && held.nlink === 1 && realpathSync(path) === path;
1058
+ const held = lstatSync(at);
1059
+ if (held.isSymbolicLink() || (index < parts.length - 1 ? !held.isDirectory() : !(held.isFile() && held.nlink === 1)))
1060
+ return false;
1269
1061
  }
1270
1062
  catch {
1271
1063
  return false;
1272
1064
  }
1273
- });
1274
- const transcript = claudeProjectFolder(base, real(checkout));
1275
- // A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
1276
- const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
1277
- return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder] };
1278
- }
1279
- /**
1280
- * A sign-in file's bytes, read only as the real regular file it is: its folder reached through no link (its real path
1281
- * is its own), and the file itself opened without following a link and checked as a regular file through what was
1282
- * opened, so a link swapped in at the file's own name is never read. Null for anything else.
1283
- *
1284
- * Its folders are still resolved by name (Node has no openat), so a folder above it swapped for a link between the
1285
- * checks and the open would be followed. That is detected after the fact: every folder from / down to the file's own
1286
- * is looked at (no link, the same device and inode) before the open and again after it, the file's real path is
1287
- * checked again, and the name must still be the very file that was opened. Any difference reads nothing.
1288
- *
1289
- * Residual risk: a swap made and undone entirely between the two looks isn't seen. On a Mac every build's and check's
1290
- * fence denies the home configuration names (~/.codex among them) by name, present or not, so no build or sandboxed
1291
- * check can make that swap; only a check the operator set to Not sandboxed, or the person themselves, still could.
1292
- * `afterOpen` is for tests: it runs between the open and the second look.
1293
- */
1294
- export function readGrantedFile(path, seams = {}) {
1295
- const folder = dirname(path);
1296
- const chain = [];
1297
- for (let at = folder;; at = dirname(at)) {
1298
- chain.push(at);
1299
- if (at === dirname(at))
1300
- break;
1301
1065
  }
1302
- const look = () => {
1303
- const held = [];
1304
- for (const at of chain) {
1305
- const one = lstatSync(at);
1306
- if (one.isSymbolicLink() || !one.isDirectory())
1307
- return null;
1308
- held.push({ dev: one.dev, ino: one.ino });
1309
- }
1310
- return held;
1066
+ return true;
1067
+ }
1068
+ export const FENCE_POLICY_CURRENT = 2;
1069
+ export const LEGACY_SANDBOX = { policy: 1, readDeny: [], machLookup: [] };
1070
+ /** A build's sandbox terms, exactly as its approval sealed them: an approval from before 0.9.70 is policy v1 (the home
1071
+ * configuration denied) with nothing listed. */
1072
+ export function launchSandboxOf(profile) {
1073
+ return { policy: profile.fencePolicy ?? 1, readDeny: profile.readDeny ?? [], machLookup: profile.machLookup ?? [] };
1074
+ }
1075
+ /** The one place a launch's fence is made (docs/plans/sandbox-parity.md C1): its denies, its grants, and
1076
+ * `confinement: "required"`, so the spawn road refuses it where no OS fence applies rather than running it bare. */
1077
+ export function fenceFor(kind, ctx) {
1078
+ const home = ctx.home === undefined ? {} : { home: ctx.home };
1079
+ // The project's read denies join the fence by name, there or not (the runtime denies a path that isn't there yet).
1080
+ const fence = agentFence({ databaseFile: ctx.databaseFile, worktree: ctx.worktree, ...home, boundary: ctx.boundary, binding: ctx.binding, runFolders: ctx.runFolders, extra: [...ctx.extra ?? [], ...ctx.readDeny ?? []] });
1081
+ 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 });
1082
+ // Toolroll's own install, the sandbox runtime's package and the Node running them, never written by anything it
1083
+ // launches: rewriting the code that fences a build would switch the fence off.
1084
+ const made = { ...found, readOnly: [...new Set([...found.readOnly, ...toolrollInstall(ctx.worktree, ctx.home ?? homedir(), ctx.binding?.main ?? null)])].sort() };
1085
+ const written = ctx.writes ?? null;
1086
+ const grants = {
1087
+ ...made,
1088
+ ...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
1089
+ ...(ctx.network === undefined ? {} : { network: ctx.network }),
1090
+ ...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
1091
+ // A build's agent signs in from the keychain (Claude Code does); nothing else it launches reaches it.
1092
+ ...(kind === "build" ? { keychain: true } : {}),
1093
+ // Under a write allowlist these are out of reach already; where the launch writes anywhere, they are denied by name.
1094
+ writeDenies: homeWriteDenies(ctx.home ?? homedir(), ctx.binding, ctx.provider ?? null),
1095
+ ...(made.pool === undefined ? {} : { pool: { ...made.pool, ...(ctx.policy === 2 ? { readable: true } : {}),
1096
+ // Flows' scratch folders in the pool (and any the caller names) stay denied whole, at every policy version.
1097
+ sealed: sealedScratch(ctx.poolRoots, ctx.scratchRoots ?? []) } }),
1311
1098
  };
1312
- const same = (a, b) => a.length === b.length && a.every((one, index) => one.dev === b[index].dev && one.ino === b[index].ino);
1313
- try {
1314
- const before = look();
1315
- if (before === null || realpathSync(folder) !== folder)
1316
- return null;
1317
- const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1099
+ return { kind, fence, fenceGrants: grants, confinement: "required" };
1100
+ }
1101
+ /** The flows' scratch folders inside the checkout pools (`flow-checks` beside the checkouts) and any a caller names,
1102
+ * each denied by name whether or not it is there yet, and where it is a link, where it leads too. */
1103
+ export function sealedScratch(poolRoots, named) {
1104
+ const out = new Set();
1105
+ 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)))]) {
1106
+ out.add(path);
1318
1107
  try {
1319
- const held = fstatSync(fd);
1320
- if (!held.isFile() || held.nlink !== 1)
1321
- return null;
1322
- seams.afterOpen?.();
1323
- const after = look();
1324
- if (after === null || !same(before, after) || realpathSync(folder) !== folder)
1325
- return null;
1326
- const named = lstatSync(path);
1327
- if (named.isSymbolicLink() || named.dev !== held.dev || named.ino !== held.ino)
1328
- return null;
1329
- const bytes = readFileSync(fd);
1330
- // And once more after the read: what was read must still be what the checked name holds.
1331
- const last = look();
1332
- return last !== null && same(before, last) ? bytes : null;
1333
- }
1334
- finally {
1335
- closeSync(fd);
1108
+ if (lstatSync(path).isSymbolicLink())
1109
+ out.add(realpathSync(path));
1336
1110
  }
1111
+ catch { /* not there, or dangling: denied by name */ }
1337
1112
  }
1338
- catch {
1339
- return null;
1340
- }
1113
+ return [...out].sort();
1341
1114
  }
1342
1115
  /**
1343
- * The sign-in copies folder in a state folder (check-access.ts; Linux checks only, and sign-ins are off for now), or null:
1344
- * only the real folder Toolroll made there, owned by this user, mode 0700, reached through no link. Anything else (a
1345
- * link planted at that name, someone else's folder, one others may open) is never used: nothing is copied into it.
1116
+ * Toolroll's own install as no launch may write it: this package's folder (its compiled code, the sandbox launch
1117
+ * helper among it), the sandbox runtime's package wherever it was installed, and the Node running them with the
1118
+ * folder it lives in (its npm and global packages beside it), as the real folders they are. Never a folder that holds
1119
+ * the launch's own checkout or its repository (a build of Toolroll's own source, run from it: that repository's other
1120
+ * files are the primary checkout's, write-denied already), the home folder or one above it, and nothing the user
1121
+ * doesn't own (root's folders are out of a build's reach already).
1346
1122
  */
1347
- export function signInCopiesRoot(state, uid = process.getuid?.()) {
1348
- const root = join(state, SIGN_IN_COPIES_DIR);
1123
+ export function toolrollInstall(worktree, home, repository = null) {
1124
+ const own = worktree === null ? null : real(worktree);
1125
+ const repo = repository === null ? null : real(repository);
1126
+ const uid = process.getuid?.();
1127
+ const ours = (path) => { try {
1128
+ return uid === undefined || statSync(path).uid === uid;
1129
+ }
1130
+ catch {
1131
+ return false;
1132
+ } };
1133
+ let runtime = [];
1349
1134
  try {
1350
- const held = lstatSync(root);
1351
- if (held.isSymbolicLink() || !held.isDirectory() || (uid !== undefined && held.uid !== uid) || (held.mode & 0o777) !== 0o700)
1352
- return null;
1353
- return grantFolder(root, false) === root ? root : null;
1135
+ runtime = [dirname(dirname(createRequire(import.meta.url).resolve("@anthropic-ai/sandbox-runtime")))];
1354
1136
  }
1355
1137
  catch {
1356
- return null;
1138
+ runtime = [];
1139
+ }
1140
+ const candidates = [resolve(dirname(fileURLToPath(import.meta.url)), ".."), ...runtime, dirname(dirname(real(process.execPath))), real(process.execPath)].map(real);
1141
+ const homeReal = real(home);
1142
+ return [...new Set(candidates)].filter(path => path !== dirname(path) && path !== homeReal && !inside(homeReal, path)
1143
+ && !(own !== null && (own === path || inside(own, path))) && !(repo !== null && (repo === path || inside(repo, path))) && ours(path)).sort();
1144
+ }
1145
+ /** Names that run code or reconfigure tools for whoever opens the project next: the sandbox runtime's DANGEROUS_FILES
1146
+ * (@anthropic-ai/sandbox-runtime sandbox-utils), and the names its mandatory denies can't hold when they are made
1147
+ * after a launch starts (`.envrc`, package-manager and Claude settings). */
1148
+ export const PROTECTED_FILES = [".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile", ".ripgreprc", ".mcp.json",
1149
+ ".envrc", ".npmrc", ".yarnrc", ".yarnrc.yml", ".claude/settings.json", ".claude/settings.local.json", ".git/config"];
1150
+ /** Folders protected whole, wherever they are (the runtime's dangerous directories, with Git's hooks). */
1151
+ export const PROTECTED_DIRS = [".vscode", ".idea", ".claude/commands", ".claude/agents", ".git/hooks"];
1152
+ /**
1153
+ * The handoff's backstop (builder.ts commit): the changed paths (relative, from Git) that are protected names at any
1154
+ * depth, in any case. The sandbox runtime denies these when a launch starts; one made after it started (a new nested
1155
+ * folder's `.envrc`, say) gets through the sandbox, and a result that holds it is refused.
1156
+ */
1157
+ export function protectedChanges(changed) {
1158
+ const files = PROTECTED_FILES.map(one => one.toLowerCase());
1159
+ return changed.filter(path => {
1160
+ const lower = `/${path.toLowerCase()}`;
1161
+ // A protected folder's own name too (a link committed as `pkg/.vscode`), not only what is in it.
1162
+ return files.some(name => lower.endsWith(`/${name}`)) || PROTECTED_DIRS.some(dir => lower.includes(`/${dir.toLowerCase()}/`) || lower.endsWith(`/${dir.toLowerCase()}`));
1163
+ });
1164
+ }
1165
+ /**
1166
+ * The handoff's backstop for links (builder.ts commit): a staged symbolic link (Git mode 120000) whose path is a
1167
+ * protected name or a folder on the way to one (`pkg/.claude`, `.vscode`, `lib/.git`), at any depth, in any case. A
1168
+ * link there would lead whoever opens the project to settings, hooks or commands kept anywhere.
1169
+ */
1170
+ export function protectedLinks(links) {
1171
+ const entries = [...PROTECTED_FILES, ...PROTECTED_DIRS].map(one => one.toLowerCase().split("/"));
1172
+ return links.filter(path => {
1173
+ const parts = path.toLowerCase().split("/").filter(one => one !== "");
1174
+ return entries.some(entry => {
1175
+ // The link's last parts are the entry's first ones (the whole entry, or a folder on the way to it).
1176
+ for (let take = 1; take <= Math.min(entry.length, parts.length); take++) {
1177
+ if (parts.slice(parts.length - take).join("/") === entry.slice(0, take).join("/"))
1178
+ return true;
1179
+ }
1180
+ return false;
1181
+ });
1182
+ });
1183
+ }
1184
+ /** The home folder's own persistence points beyond the protected names (login items, systemd user units and autostart,
1185
+ * Git's, gh's, pip's and fish's configuration, the shells' other startup files, SSH and GnuPG, and the folders on a
1186
+ * login PATH). Under `.config/` each also stands under $XDG_CONFIG_HOME. */
1187
+ export const HOME_PERSISTENCE = ["Library/LaunchAgents", ".config/systemd/user", ".config/autostart", ".config/git", ".config/gh", ".config/pip",
1188
+ ".config/fish", ".pip", ".zshenv", ".zlogin", ".bash_login", ".ssh", ".gnupg", ".local/bin", "bin"];
1189
+ /** The sign-in folders and files at home no launch writes, but an agent its own exact sign-in files (OWN_SIGN_INS). */
1190
+ const HOME_SIGN_INS = [".codex", ".claude.json", ".claude/.credentials.json"];
1191
+ /** The exact sign-in files an agent's own launch still writes (Claude's account and credentials). Codex's harness
1192
+ * refreshes its sign-in outside the sandbox, so its commands write none. Configuration and persistence names stay
1193
+ * denied for every agent. */
1194
+ const OWN_SIGN_INS = { claude: [".claude.json", ".claude/.credentials.json"] };
1195
+ /** `path` as it resolves now: through every link above it (and at it), however much of it exists. */
1196
+ function resolvedPath(path) {
1197
+ let head = path;
1198
+ const rest = [];
1199
+ while (head !== dirname(head)) {
1200
+ try {
1201
+ return join(realpathSync(head), ...rest.reverse());
1202
+ }
1203
+ catch {
1204
+ rest.push(basename(head));
1205
+ head = dirname(head);
1206
+ }
1357
1207
  }
1208
+ return path;
1209
+ }
1210
+ /**
1211
+ * What no launch writes, whatever its writes, as static write denies for the sandbox runtime (whose own protected names
1212
+ * are anchored at the launch's folder only) and Codex's profile: the home folder's protected names (shell profiles, Git
1213
+ * and tool configuration), its persistence points and sign-ins (but the launch's own agent's exact sign-in files), and
1214
+ * the bound repository's own Git config, hooks and submodules (a linked worktree reaches them through its `.git`
1215
+ * file). Each by its name and by where it leads now (a linked ~/.config or LaunchAgents is denied at both ends). On
1216
+ * Linux only those there at launch: the runtime holds an absent name with a stand-in nothing can read for the launch's
1217
+ * length, and the person's own git and shells then fail on their ~/.gitconfig and ~/.bashrc (measured). Under a write
1218
+ * allowlist an absent one is out of reach anyway; under `anywhere` it can be made (an accepted limit).
1219
+ */
1220
+ export function homeWriteDenies(home, binding, provider, platform = process.platform, env = process.env) {
1221
+ const base = real(home);
1222
+ const own = new Set(OWN_SIGN_INS[provider ?? ""] ?? []);
1223
+ const names = [...new Set([...PROTECTED_FILES.filter(name => !name.startsWith(".git/")), ...PROTECTED_DIRS.filter(name => !name.startsWith(".git/")), ...HOME_PERSISTENCE, ...HOME_SIGN_INS])]
1224
+ .filter(name => !own.has(name));
1225
+ const xdg = env["XDG_CONFIG_HOME"];
1226
+ const roots = (name) => [join(base, name), ...(name.startsWith(".config/") && xdg !== undefined && isAbsolute(xdg) ? [join(xdg, name.slice(".config/".length))] : [])];
1227
+ const paths = names.flatMap(name => roots(name).filter(path => platform !== "linux" || existsSync(path)));
1228
+ const git = binding === null ? [] : ["config", "hooks", "modules"].map(name => join(real(join(binding.main, ".git")), name));
1229
+ return [...new Set([...paths, ...git].flatMap(path => [path, resolvedPath(path)]))].sort();
1230
+ }
1231
+ /** A tool server's grants from its build's: never the agent's network or write allowlist (a server keeps its own state
1232
+ * where it always has), and never the build's own checkout or launch folder read back (nothing a build wrote loads in a
1233
+ * process that holds a tool's secrets). */
1234
+ export function serverGrantsOf(grants) {
1235
+ const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, ...rest } = grants;
1236
+ return {
1237
+ ...rest,
1238
+ ...(rest.pool === undefined ? {} : { pool: { ...rest.pool, own: null } }),
1239
+ ...(rest.runFolders === undefined ? {} : { runFolders: { ...rest.runFolders, own: null } }),
1240
+ };
1358
1241
  }