toolroll 0.9.69 → 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 (68) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +180 -115
  5. package/dist/agent-fence.js +413 -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 +7 -10
  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 +18 -8
  32. package/dist/invoke.js +98 -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 +36 -2
  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 +62 -0
  45. package/dist/sandbox-runtime.js +140 -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,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
- import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
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";
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";
34
+ import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
35
+ import { getDefaultWritePaths } from "@anthropic-ai/sandbox-runtime";
58
36
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
59
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;
60
38
  function real(path) {
@@ -137,59 +115,21 @@ export function agentFence(options) {
137
115
  const fence = new Set();
138
116
  /** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
139
117
  let runFolder = null;
140
- /** The bridges' folder, likewise. */
141
- let bridgeFolder = null;
142
- /** The sign-in copies folder, likewise. */
143
- let copiesFolder = null;
144
118
  const home = real(options.home ?? homedir());
145
119
  const worktree = options.worktree === null ? null : real(options.worktree);
146
120
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
147
121
  const database = real(options.databaseFile);
148
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).
149
124
  for (const suffix of ["", "-wal", "-shm", "-journal"])
150
- if (existsSync(database + suffix))
151
- fence.add(database + suffix);
125
+ fence.add(database + suffix);
152
126
  // The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
153
127
  // build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
154
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
155
- // real folder to cover (a Linux bind needs one).
156
129
  // Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
157
130
  // points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
158
131
  // If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
159
132
  // 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
133
  runFolder = join(state, INSTANCE_DIR);
194
134
  const present = () => { try {
195
135
  lstatSync(runFolder);
@@ -237,24 +177,20 @@ export function agentFence(options) {
237
177
  // v104: the folder the audit stream writes to holds the whole instance's history.
238
178
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
239
179
  const stream = readMonitoring(dirname(real(options.databaseFile))).folder?.path;
240
- if (stream !== undefined && existsSync(stream))
180
+ if (stream !== undefined)
241
181
  fence.add(real(stream));
242
182
  }
243
183
  for (const path of options.extra ?? [])
244
- if (existsSync(path))
245
- fence.add(real(path));
184
+ fence.add(real(path));
246
185
  // 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
186
  for (const path of options.boundary?.protectedShell ?? [])
251
187
  fence.add(real(path));
252
- const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]));
188
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]));
253
189
  for (const path of options.boundary?.shell ?? [])
254
190
  fence.add(path);
255
191
  const binding = options.binding;
256
192
  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 => {
193
+ return [...new Set([...fence].flatMap(path => path === runFolder ? [path, real(path)] : [real(path)]))].filter(path => {
258
194
  if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
259
195
  return true;
260
196
  console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
@@ -297,6 +233,10 @@ export function taskBoundary(options) {
297
233
  const needed = [worktree, main, gitdir].filter((path) => path !== null);
298
234
  const own = new Set(PROVIDER_HOME[options.provider] ?? []);
299
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);
300
240
  const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
301
241
  const protectedShell = [...shell];
302
242
  const repository = (path) => existsSync(join(path, ".git"));
@@ -338,11 +278,43 @@ export function taskBoundary(options) {
338
278
  shell.add(path);
339
279
  }
340
280
  }
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.
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) {
346
318
  const readOnly = new Set();
347
319
  const lookup = new Set();
348
320
  if (gitdir !== null && main !== null) {
@@ -386,15 +358,9 @@ export function taskBoundary(options) {
386
358
  lookup.add(join(checkout, ".git"));
387
359
  }
388
360
  }
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 };
361
+ return { readOnly, lookup };
395
362
  }
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). */
363
+ /** The prefixes of every launch's private folders: its tools' config and a Codex build's own home (and a check's). */
398
364
  export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
399
365
  export function runFolders(own, root) {
400
366
  // The system's own roots (not one a caller names): on a Mac, checks' launch folders are under the short one too.
@@ -402,9 +368,9 @@ export function runFolders(own, root) {
402
368
  return { root: real(root ?? tmpdir()), own: own === null ? null : real(own), ...(also.length === 0 ? {} : { also }) };
403
369
  }
404
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
405
- * path (about 104 bytes); every other launch folder there is denied by pattern, as under the system temp folder. */
371
+ * path (about 104 bytes). */
406
372
  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). */
373
+ /** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder. */
408
374
  export const checkLaunchRoot = () => process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? CHECK_LAUNCH_ROOT_MACOS : tmpdir();
409
375
  /** Every other launch's private folder in the system temp folder now, each only as the real folder it is (a link planted
410
376
  * 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 +553,6 @@ export function buildCacheEnv(folder) {
587
553
  DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
588
554
  };
589
555
  }
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
556
  export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
602
557
  /**
603
558
  * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
@@ -713,6 +668,35 @@ const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
713
668
  * `codexPool`, which denies them around the build's own checkout, written back.
714
669
  */
715
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) {
716
700
  const { deny, readOnly } = flattened(fence, grants);
717
701
  // Codex's harness reads its own launch files outside the command sandbox.
718
702
  // Deny the namespace, including folders created after this policy was built.
@@ -747,27 +731,39 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
747
731
  for (const path of held.read)
748
732
  access.set(path, "read");
749
733
  for (const path of held.deny)
750
- 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");
751
740
  for (const path of held.write)
752
741
  access.set(path, "write");
753
742
  }
754
- // Denies are keyed where they resolve; the console's run folder and the sign-in copies folder are also denied by their
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
755
760
  // own pathname, so a link there stays unreplaceable, not only its target.
756
761
  for (const path of deny) {
757
762
  access.set(real(path), "deny");
758
- if (basename(path) === INSTANCE_DIR || basename(path) === SIGN_IN_COPIES_DIR)
763
+ if (basename(path) === INSTANCE_DIR)
759
764
  access.set(path, "deny");
760
765
  }
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
- ];
766
+ return access;
771
767
  }
772
768
  /**
773
769
  * Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
@@ -864,140 +860,7 @@ export function claudeFenceSettings(fence, readOnly = []) {
864
860
  const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
865
861
  return JSON.stringify({ permissions: { deny: rules } });
866
862
  }
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";
863
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
1001
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. */
1002
865
  const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
1003
866
  let seatbeltWorks = null;
@@ -1023,223 +886,129 @@ export function macosFenceAvailable(platform = process.platform, probe) {
1023
886
  if (probe !== undefined)
1024
887
  return works(probe);
1025
888
  seatbeltWorks ??= works(() => {
1026
- 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" });
1027
890
  return { status: ran.status, stderr: ran.stderr ?? "" };
1028
891
  });
1029
892
  return seatbeltWorks;
1030
893
  }
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) {
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) {
1038
898
  if (platform !== "linux")
1039
899
  return false;
1040
- if (bubblewrapWorks === null) {
900
+ if (linuxWorks === null) {
1041
901
  try {
1042
- bubblewrapWorks = probe();
902
+ linuxWorks = probe();
1043
903
  }
1044
904
  catch {
1045
- bubblewrapWorks = false;
905
+ linuxWorks = false;
1046
906
  }
1047
907
  }
1048
- 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 {
1049
912
  }
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
913
  /**
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).
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.
1058
918
  */
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 */ }
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);
1137
930
  }
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));
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);
1174
948
  }
1175
- catch { /* not there */ }
1176
949
  }
950
+ if (folders.own !== null)
951
+ allowRead.add(folders.own);
1177
952
  }
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));
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);
1186
970
  }
1187
- catch { /* not there */ }
1188
971
  }
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();
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);
1227
996
  }
1228
997
  /**
1229
998
  * What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
1230
999
  * (fenceSummaryMarkdown; a test holds them equal).
1231
1000
  */
1232
1001
  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
1002
  {
1239
1003
  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",
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",
1241
1005
  full: "same",
1242
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
+ },
1243
1012
  ];
1244
1013
  /** FENCE_SUMMARY as README's markdown table. */
1245
1014
  export function fenceSummaryMarkdown() {
@@ -1247,112 +1016,221 @@ export function fenceSummaryMarkdown() {
1247
1016
  }
1248
1017
  /** The sign-in files a `sign-ins` check reads, by fixed name under the person's home (never from the environment, so no
1249
1018
  * 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. */
1019
+ * its credentials file; on a Mac they are in the keychain), gh's hosts and config, and Codex's config and sign-in. */
1252
1020
  const SIGN_IN_FILES = (platform) => [
1253
1021
  ".claude.json", join(".claude", "settings.json"), ...(platform === "linux" ? [join(".claude", ".credentials.json")] : []),
1254
- join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"),
1022
+ join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"), join(".codex", "config.toml"), join(".codex", "auth.json"),
1255
1023
  ];
1256
1024
  /**
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.
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.
1261
1031
  */
1262
1032
  export function signInGrants(home, checkout, platform = process.platform) {
1263
1033
  const base = real(home);
1264
- const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => {
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);
1265
1054
  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;
1055
+ const held = lstatSync(at);
1056
+ if (held.isSymbolicLink() || (index < parts.length - 1 ? !held.isDirectory() : !(held.isFile() && held.nlink === 1)))
1057
+ return false;
1269
1058
  }
1270
1059
  catch {
1271
1060
  return false;
1272
1061
  }
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
1062
  }
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;
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 ?? []) } }),
1311
1093
  };
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);
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);
1318
1102
  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);
1103
+ if (lstatSync(path).isSymbolicLink())
1104
+ out.add(realpathSync(path));
1336
1105
  }
1106
+ catch { /* not there, or dangling: denied by name */ }
1337
1107
  }
1338
- catch {
1339
- return null;
1340
- }
1108
+ return [...out].sort();
1341
1109
  }
1342
1110
  /**
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.
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).
1346
1117
  */
1347
- export function signInCopiesRoot(state, uid = process.getuid?.()) {
1348
- const root = join(state, SIGN_IN_COPIES_DIR);
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 = [];
1349
1129
  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;
1130
+ runtime = [dirname(dirname(createRequire(import.meta.url).resolve("@anthropic-ai/sandbox-runtime")))];
1354
1131
  }
1355
1132
  catch {
1356
- return null;
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
+ }
1357
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
+ };
1358
1236
  }