toolroll 0.9.62 → 0.9.63

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 (100) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +3 -0
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +3 -2
  49. package/dist/landed-cleanup.js +12 -5
  50. package/dist/lead-tools.js +11 -3
  51. package/dist/mcp-connect.d.ts +8 -7
  52. package/dist/mcp-connect.js +49 -51
  53. package/dist/oauth-run-token.d.ts +51 -0
  54. package/dist/oauth-run-token.js +127 -0
  55. package/dist/operate.js +7 -1
  56. package/dist/plan-auto.d.ts +11 -0
  57. package/dist/plan-auto.js +62 -11
  58. package/dist/process-recovery-eligibility.d.ts +1 -1
  59. package/dist/project-access.d.ts +3 -0
  60. package/dist/project-access.js +3 -0
  61. package/dist/project-delete.d.ts +3 -1
  62. package/dist/project-delete.js +10 -4
  63. package/dist/project-file.d.ts +3 -0
  64. package/dist/project-file.js +16 -0
  65. package/dist/project-tools.d.ts +99 -25
  66. package/dist/project-tools.js +394 -98
  67. package/dist/provider-home.d.ts +22 -0
  68. package/dist/provider-home.js +99 -0
  69. package/dist/provider.d.ts +17 -3
  70. package/dist/provider.js +34 -10
  71. package/dist/remote-main.d.ts +4 -0
  72. package/dist/remote-main.js +3 -3
  73. package/dist/scope.d.ts +1 -1
  74. package/dist/serve.js +11 -3
  75. package/dist/server/flows.js +27 -5
  76. package/dist/server/route-table.js +3 -0
  77. package/dist/server/runtime.d.ts +1 -1
  78. package/dist/server/settings.js +11 -3
  79. package/dist/storage-sweep-child.js +3 -1
  80. package/dist/storage-sweep-process.d.ts +12 -5
  81. package/dist/storage-sweep-process.js +23 -6
  82. package/dist/store.d.ts +38 -6
  83. package/dist/store.js +148 -15
  84. package/dist/subagent-tools.d.ts +1 -1
  85. package/dist/surface.js +6 -4
  86. package/dist/teardown-git.d.ts +14 -0
  87. package/dist/teardown-git.js +119 -0
  88. package/dist/tool-broker.d.ts +37 -0
  89. package/dist/tool-broker.js +369 -0
  90. package/dist/tool-launcher.js +5 -4
  91. package/dist/tool-secret-store.d.ts +170 -0
  92. package/dist/tool-secret-store.js +834 -0
  93. package/dist/toolroll-update.js +3 -2
  94. package/dist/tools-ui.d.ts +7 -1
  95. package/dist/tools-ui.js +17 -8
  96. package/dist/work-index.js +5 -3
  97. package/dist/worktree.d.ts +10 -4
  98. package/dist/worktree.js +70 -29
  99. package/package.json +3 -1
  100. package/scripts/provider-canary.mjs +210 -31
@@ -24,13 +24,29 @@
24
24
  * A build's fence also holds its task boundary (`taskBoundary`): home
25
25
  * configuration and the repositories beside its own, so a build works in
26
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.
27
42
  */
28
43
  import { spawnSync } from "node:child_process";
29
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
30
- import { homedir } from "node:os";
44
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
45
+ import { homedir, tmpdir } from "node:os";
31
46
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
32
47
  import { readMonitoring } from "./monitoring-settings.js";
33
48
  import { existingFolders } from "./names.js";
49
+ import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
34
50
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
35
51
  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;
36
52
  function real(path) {
@@ -45,6 +61,62 @@ function inside(child, parent) {
45
61
  const rel = relative(parent, child);
46
62
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
47
63
  }
64
+ export class CheckoutBindingError extends Error {
65
+ }
66
+ export const HARDENED_GIT_ARGS = ["--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"];
67
+ const GIT_ENV_NAMES = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "LANG", "LC_ALL", "CODEX_HOME"];
68
+ export function checkoutGitEnv(base = process.env) {
69
+ return Object.fromEntries(GIT_ENV_NAMES.flatMap(name => base[name] === undefined ? [] : [[name, base[name]]]));
70
+ }
71
+ export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
72
+ const main = real(repository);
73
+ const checkout = real(worktree);
74
+ const fail = () => new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
75
+ const registry = join(main, ".git", "worktrees");
76
+ try {
77
+ // The registry may legitimately contain the destination of an attacker link.
78
+ // Check the recorded name before accepting that destination as this run's checkout.
79
+ if (realpathSync(worktree) !== join(realpathSync(dirname(worktree)), basename(worktree)))
80
+ throw fail();
81
+ for (const root of poolRoots) {
82
+ if (root === null)
83
+ continue;
84
+ const components = [];
85
+ for (let component = resolve(worktree); component !== dirname(component); component = dirname(component)) {
86
+ components.push(component);
87
+ // Permit system aliases above the trusted pool (e.g. /var -> /private/var),
88
+ // but no redirect at or below it, including older checkout names.
89
+ if (real(component) !== real(root) && component !== resolve(root))
90
+ continue;
91
+ for (const one of components)
92
+ if (lstatSync(one).isSymbolicLink())
93
+ throw fail();
94
+ break;
95
+ }
96
+ }
97
+ if (!lstatSync(join(main, ".git")).isDirectory() || !lstatSync(registry).isDirectory())
98
+ throw fail();
99
+ // Query the trusted checkout, never the candidate's configurable Git pointer.
100
+ const listed = spawnSync("git", [...HARDENED_GIT_ARGS, "worktree", "list", "--porcelain", "-z"], { cwd: main, env: checkoutGitEnv(), encoding: "utf8" });
101
+ if (listed.status !== 0 || !listed.stdout.split("\0").includes(`worktree ${checkout}`))
102
+ throw fail();
103
+ for (const entry of readdirSync(registry)) {
104
+ const path = join(registry, entry);
105
+ if (!lstatSync(path).isDirectory() || !inside(real(path), real(registry)))
106
+ continue;
107
+ if (real(readFileSync(join(path, "gitdir"), "utf8").trim()) !== join(checkout, ".git"))
108
+ continue;
109
+ const gitdir = real(path);
110
+ if (worktreeGitDir(checkout) !== gitdir || real(join(gitdir, readFileSync(join(gitdir, "commondir"), "utf8").trim())) !== real(join(main, ".git")))
111
+ throw fail();
112
+ return { main, gitdir };
113
+ }
114
+ }
115
+ catch {
116
+ throw fail();
117
+ }
118
+ throw fail();
119
+ }
48
120
  /**
49
121
  * Every path an agent must not reach: the Toolroll state folder
50
122
  * beside the database (all of it, except the folder that holds this run's
@@ -71,7 +143,7 @@ export function agentFence(options) {
71
143
  // The entries this run's worktree needs stay reachable: the one it lives
72
144
  // under, and the repository its git metadata belongs to.
73
145
  const keep = new Set();
74
- for (const needed of [worktree, worktree === null ? null : mainRepositoryOf(worktree)]) {
146
+ for (const needed of [worktree, options.binding?.main ?? null]) {
75
147
  if (needed !== null && inside(needed, state))
76
148
  keep.add(relative(state, needed).split(sep)[0]);
77
149
  }
@@ -101,10 +173,23 @@ export function agentFence(options) {
101
173
  for (const path of options.extra ?? [])
102
174
  if (existsSync(path))
103
175
  fence.add(real(path));
176
+ // Secret, state, database, explicit-extra and home-config denies are never exemptible.
177
+ if (options.runFolders !== undefined)
178
+ for (const path of otherRunFolders(options.runFolders))
179
+ fence.add(path);
180
+ for (const path of options.boundary?.protectedShell ?? [])
181
+ fence.add(real(path));
182
+ const protectedPaths = new Set([...fence].map(real));
104
183
  for (const path of options.boundary?.shell ?? [])
105
184
  fence.add(path);
106
- // Never fence the run's own worktree or anything above it.
107
- return [...fence].filter(path => worktree === null || (path !== worktree && !inside(worktree, path))).sort();
185
+ const binding = options.binding;
186
+ const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
187
+ return [...new Set([...fence].map(real))].filter(path => {
188
+ if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
189
+ return true;
190
+ console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
191
+ return false;
192
+ }).sort();
108
193
  }
109
194
  /**
110
195
  * Credentials and agent configuration in the home folder. A build is the
@@ -129,10 +214,13 @@ const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.jso
129
214
  export function taskBoundary(options) {
130
215
  const home = real(options.home ?? homedir());
131
216
  const worktree = real(options.worktree);
132
- const main = mainRepositoryOf(worktree);
217
+ const main = options.binding?.main ?? null;
218
+ const gitdir = options.binding?.gitdir ?? null;
219
+ const needed = [worktree, main, gitdir].filter((path) => path !== null);
133
220
  const own = new Set(PROVIDER_HOME[options.provider] ?? []);
134
221
  const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
135
222
  const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
223
+ const protectedShell = [...shell];
136
224
  const repository = (path) => existsSync(join(path, ".git"));
137
225
  for (const parent of new Set([dirname(worktree), ...(main === null ? [] : [dirname(main)])])) {
138
226
  // Home contains toolchain clones such as .nvm and .oh-my-zsh. The
@@ -149,8 +237,11 @@ export function taskBoundary(options) {
149
237
  for (const entry of entries) {
150
238
  if (entry.startsWith("."))
151
239
  continue;
152
- const path = join(parent, entry);
153
- if (path === worktree || path === main || inside(worktree, path) || !repository(path))
240
+ const candidate = join(parent, entry);
241
+ if (!repository(candidate))
242
+ continue;
243
+ const path = real(candidate);
244
+ if (needed.some(target => target === path || inside(target, path)))
154
245
  continue;
155
246
  shell.add(path);
156
247
  }
@@ -170,8 +261,12 @@ export function taskBoundary(options) {
170
261
  }
171
262
  }
172
263
  // Git needs this worktree's metadata and the shared objects/refs, but
173
- // must not prune or rewrite the metadata of another task's checkout.
174
- const gitdir = worktreeGitDir(worktree);
264
+ // must not prune or rewrite the metadata of another task's checkout. It
265
+ // still reads that metadata (gc keeps what a sibling's HEAD and index
266
+ // reach) and looks up the checkout it names: a checkout it can't see is
267
+ // one `git worktree prune` deletes the entry of.
268
+ const readOnly = new Set();
269
+ const lookup = new Set();
175
270
  if (gitdir !== null && main !== null) {
176
271
  const siblings = dirname(gitdir);
177
272
  let entries = [];
@@ -182,13 +277,148 @@ export function taskBoundary(options) {
182
277
  entries = [];
183
278
  }
184
279
  for (const entry of entries) {
185
- const path = real(join(siblings, entry));
186
- if (path !== gitdir)
187
- shell.add(path);
280
+ // Only a plain folder, by the name git gave it: a link a build planted here (to ~/.toolroll, say) is never
281
+ // followed into a grant, which a sandbox that lets the more specific path win would honour over the fence.
282
+ const path = join(siblings, entry);
283
+ if (path === gitdir)
284
+ continue;
285
+ try {
286
+ if (!lstatSync(path).isDirectory())
287
+ continue;
288
+ }
289
+ catch {
290
+ continue;
291
+ }
292
+ readOnly.add(path);
293
+ let pointer;
294
+ try {
295
+ pointer = readFileSync(join(path, "gitdir"), "utf8").trim();
296
+ }
297
+ catch {
298
+ pointer = undefined;
299
+ }
300
+ if (pointer === undefined || pointer === "" || !isAbsolute(pointer))
301
+ continue;
302
+ const checkout = dirname(real(pointer));
303
+ // The entry's pointer is build-writable: look up only a checkout whose own .git names this entry back, as git
304
+ // links a real one, so a planted pointer can't lift a fenced path's metadata.
305
+ if (worktreeGitDir(checkout) !== path)
306
+ continue;
307
+ lookup.add(checkout);
308
+ lookup.add(join(checkout, ".git"));
188
309
  }
189
310
  }
190
311
  const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
191
- return { shell: [...shell].sort(), fileTools: fileTools.sort() };
312
+ return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
313
+ }
314
+ /** The prefixes of every launch's private folders: its tools' config and a Codex build's own home. */
315
+ export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-"];
316
+ export function runFolders(own, root = tmpdir()) {
317
+ return { root: real(root), own: own === null ? null : real(own) };
318
+ }
319
+ /** Every other launch's private folder there now. */
320
+ function otherRunFolders(folders) {
321
+ let entries = [];
322
+ try {
323
+ entries = readdirSync(folders.root);
324
+ }
325
+ catch {
326
+ entries = [];
327
+ }
328
+ return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
329
+ }
330
+ export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
331
+ /**
332
+ * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
333
+ * the recorded checkout was made in), never from the checkout. A root that holds this checkout's own Git data (the
334
+ * binding's gitdir or the repository's .git) is never denied whole: it is split around that data instead, entry by
335
+ * entry, so it is denied as far as it can be (fail closed by denying more, never by refusing). A root inside the
336
+ * checkout, the home folder, a folder above it or / is left alone: the first is the build's own work, the others the
337
+ * whole machine.
338
+ */
339
+ export function checkoutPool(options, list = path => readdirSync(path)) {
340
+ const own = options.worktree === null ? null : real(options.worktree);
341
+ const home = real(options.home ?? homedir());
342
+ const binding = options.binding ?? null;
343
+ const needed = binding === null ? [] : [realpathSync(binding.gitdir), realpathSync(join(binding.main, ".git"))];
344
+ const deny = new Set();
345
+ const visited = new Set();
346
+ const split = (input) => {
347
+ const path = realpathSync(input);
348
+ if (visited.has(path))
349
+ return;
350
+ visited.add(path);
351
+ if (!needed.some(target => target === path || inside(target, path))) {
352
+ deny.add(path);
353
+ return;
354
+ }
355
+ if (needed.includes(path))
356
+ return;
357
+ const entries = list(path); // An unreadable pool must refuse preparation, never silently widen access.
358
+ for (const entry of entries)
359
+ split(join(path, entry));
360
+ };
361
+ for (const root of options.roots) {
362
+ if (root === null || !existsSync(root))
363
+ continue;
364
+ const path = real(root);
365
+ if (path === dirname(path) || path === home || inside(home, path) || (own !== null && (path === own || inside(path, own))))
366
+ continue;
367
+ split(path);
368
+ }
369
+ const holding = own === null ? [] : [...deny].filter(path => inside(own, path));
370
+ const lookup = new Set([...visited].filter(path => needed.some(target => inside(target, path))));
371
+ for (const path of holding)
372
+ for (let folder = dirname(own); folder === path || inside(folder, path); folder = dirname(folder))
373
+ lookup.add(folder);
374
+ return { deny: [...deny].sort(), own: holding.length === 0 ? null : own, lookup: [...lookup].sort(), ...(needed.length === 0 ? {} : { gitPaths: needed }) };
375
+ }
376
+ /** The grants for one build: its task boundary's git rules, the shared dependencies its checkout links, and the
377
+ * checkout pools (`poolRoots`, from the plane's records) denied around its own checkout. */
378
+ export function fenceGrants(options) {
379
+ const readable = [];
380
+ const lookup = [...options.boundary?.lookup ?? []];
381
+ if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:" && options.worktree !== null && options.linked != null) {
382
+ // The link note names the copy as the plane wrote it; the sandbox sees real paths. The note is in the build's own
383
+ // checkout, so it is only a hint: the copy must be a ready one the plane made for this run's project under the
384
+ // setup it stamped on this checkout, or nothing is granted.
385
+ const written = join(dirname(options.databaseFile), DEPS_FOLDER);
386
+ const deps = join(dirname(real(options.databaseFile)), DEPS_FOLDER);
387
+ const key = linkedKey(options.worktree, written) ?? linkedKey(options.worktree, deps);
388
+ const linked = options.linked;
389
+ const copy = key === null || !/^[0-9a-f]{24}$/.test(key) ? undefined : sharedCopies(deps).find(one => one.key === key);
390
+ const ours = copy !== undefined && !copy.retired && copy.repo === linked.repo && linked.setupDigest !== null && readySetup(deps, key) === linked.setupDigest && readyCopy(deps, key) !== null;
391
+ if (ours && key !== null && existsSync(join(deps, key, "node_modules"))) {
392
+ lookup.push(deps, join(deps, key));
393
+ readable.push(join(deps, key, "node_modules"));
394
+ }
395
+ }
396
+ const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
397
+ return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
398
+ ...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
399
+ }
400
+ /** The setup a shared copy was installed by (its ready note), or null. */
401
+ function readySetup(deps, key) {
402
+ try {
403
+ const setup = JSON.parse(readFileSync(join(deps, key, "ready.json"), "utf8")).setup;
404
+ return typeof setup === "string" ? setup : null;
405
+ }
406
+ catch {
407
+ return null;
408
+ }
409
+ }
410
+ /**
411
+ * Git settings every fenced build runs with (GIT_CONFIG_COUNT, after any the
412
+ * caller already set): `git gc` never expires a reflog, so it never rewrites
413
+ * another task's reflog, which the fence would refuse mid-collection.
414
+ */
415
+ export function fencedGitEnv(base = process.env) {
416
+ const count = Number.parseInt(base["GIT_CONFIG_COUNT"] ?? "0", 10);
417
+ const start = Number.isInteger(count) && count > 0 ? count : 0;
418
+ const settings = [["gc.reflogExpire", "never"], ["gc.reflogExpireUnreachable", "never"]];
419
+ const env = { GIT_CONFIG_COUNT: String(start + settings.length) };
420
+ settings.forEach(([key, value], index) => { env[`GIT_CONFIG_KEY_${start + index}`] = key; env[`GIT_CONFIG_VALUE_${start + index}`] = value; });
421
+ return env;
192
422
  }
193
423
  /** The repository a git worktree belongs to (its `.git` file names the main repository's git folder). */
194
424
  function worktreeGitDir(worktree) {
@@ -200,24 +430,50 @@ function worktreeGitDir(worktree) {
200
430
  return null;
201
431
  }
202
432
  }
203
- function mainRepositoryOf(worktree) {
204
- const gitdir = worktreeGitDir(worktree);
205
- if (gitdir === null)
206
- return null;
207
- // <repo>/.git/worktrees/<name> → <repo>
208
- const at = gitdir.lastIndexOf(`${sep}.git${sep}worktrees${sep}`);
209
- return at < 0 ? null : gitdir.slice(0, at);
210
- }
211
433
  const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
212
434
  /**
213
435
  * Codex's own sandbox, fenced. Auto: the `:workspace` profile Codex's
214
436
  * `--sandbox workspace-write` already gives (workspace and temp writes, no
215
437
  * network). Full access: the same profile widened to write anywhere with
216
438
  * network on, instead of no sandbox at all. Either way the fenced paths are
217
- * denied, which in Codex outranks any write or read grant.
439
+ * denied, which in Codex outranks any write or read grant. The checkout pools
440
+ * are never `flattened` (which leaves what is made after launch readable): see
441
+ * `codexPool`, which denies them around the build's own checkout, written back.
218
442
  */
219
- export function codexFenceArgv(fence, fullAccess) {
220
- const filesystem = [...(fullAccess ? ['"/"="write"'] : []), ...fence.map(path => `${toml(path)}="deny"`)];
443
+ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
444
+ const { deny, readOnly } = flattened(fence, grants);
445
+ // Codex's harness reads its own launch files outside the command sandbox.
446
+ // Deny the namespace, including folders created after this policy was built.
447
+ if (grants.runFolders !== undefined) {
448
+ for (const prefix of RUN_FOLDER_PREFIXES) {
449
+ deny.push(join(grants.runFolders.root, `${prefix}*`), join(grants.runFolders.root, `${prefix}*`, "**"));
450
+ }
451
+ }
452
+ // Launch folders can already be in the base fence when invoke appends them.
453
+ // TOML forbids duplicate keys, even with identical values. Normalize aliases
454
+ // at this boundary and apply stronger access restrictions last.
455
+ const access = new Map();
456
+ const pool = grants.pool;
457
+ // Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
458
+ const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
459
+ && !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
460
+ if (fullAccess)
461
+ access.set("/", "write");
462
+ for (const path of readOnly)
463
+ if (!pooled(real(path)))
464
+ access.set(real(path), "read");
465
+ if (pool !== undefined) {
466
+ const held = codexPool(pool);
467
+ for (const path of held.read)
468
+ access.set(path, "read");
469
+ for (const path of held.deny)
470
+ access.set(path, "deny");
471
+ for (const path of held.write)
472
+ access.set(path, "write");
473
+ }
474
+ for (const path of deny)
475
+ access.set(real(path), "deny");
476
+ const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
221
477
  return [
222
478
  "-c", 'default_permissions="standing-orders"',
223
479
  "-c", 'permissions.standing-orders.extends=":workspace"',
@@ -225,15 +481,170 @@ export function codexFenceArgv(fence, fullAccess) {
225
481
  ...(fullAccess ? ["-c", "permissions.standing-orders.network.enabled=true", "-c", 'approval_policy="never"'] : []),
226
482
  ];
227
483
  }
228
- /** Claude's own file tools refuse the fenced paths in every mode (its shell is fenced by the macOS sandbox). */
229
- export function claudeFenceSettings(fence) {
230
- const rules = fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]);
484
+ /**
485
+ * Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
486
+ * siblings individually, from the trusted pool roots and a fresh listing, never through
487
+ * flattened or name patterns. A project added after launch is covered on the next launch;
488
+ * Seatbelt and bubblewrap retain the stronger whole-pool rule for later-created projects.
489
+ */
490
+ export function codexPool(pool, list = path => readdirSync(path)) {
491
+ const deny = new Set();
492
+ const read = new Set(pool.lookup);
493
+ const own = pool.own;
494
+ const needed = [...pool.gitPaths ?? [], ...(own === null ? [] : [own])].map(path => realpathSync(path));
495
+ const visited = new Set();
496
+ const split = (input) => {
497
+ let path;
498
+ try {
499
+ path = realpathSync(input);
500
+ }
501
+ catch {
502
+ deny.add(resolve(input));
503
+ return;
504
+ }
505
+ if (visited.has(path))
506
+ return;
507
+ visited.add(path);
508
+ if (needed.some(target => path === target || inside(path, target)))
509
+ return;
510
+ if (!needed.some(target => inside(target, path))) {
511
+ deny.add(path);
512
+ return;
513
+ }
514
+ // Only ancestors needed for traversal stay readable, never writable in Full access.
515
+ read.add(path);
516
+ for (const entry of list(path))
517
+ split(join(path, entry));
518
+ };
519
+ for (const root of pool.deny)
520
+ split(root);
521
+ return { read: [...read].sort(), deny: [...deny].sort(), write: own === null ? [] : [own] };
522
+ }
523
+ /** Secret-looking names denied by pattern inside a folder `flattened` makes read-only, so one written there after
524
+ * launch is denied too (the database and its journals, tokens, login and other text files). */
525
+ export const SPLIT_SECRET_PATTERNS = ["orders.db*", "*token*", "up-login*", "*.txt", "*secret*", "*password*", "*credential*", "*.pem", "*.key"];
526
+ /**
527
+ * A fence as plain denied and read-only paths, for a sandbox that can't
528
+ * grant inside a denied folder (Codex: a deny outranks any grant): a denied
529
+ * folder holding something readable becomes read-only, with each of its
530
+ * entries not on the way to what is readable denied instead. Fails closed: a
531
+ * folder that can't be listed stays denied whole and its grant is dropped.
532
+ * Residual: an entry made after launch in a read-only folder is denied only
533
+ * when its name matches SPLIT_SECRET_PATTERNS; any other new entry there is
534
+ * readable (never writable) until the next launch. The macOS fence has no
535
+ * such gap: it denies the folder and grants back only the readable path.
536
+ */
537
+ export function flattened(fence, grants = NO_GRANTS, list = path => readdirSync(path)) {
538
+ const deny = [];
539
+ const readOnly = new Set(grants.readOnly);
540
+ const split = (path) => {
541
+ const within = grants.readable.filter(one => inside(one, path));
542
+ if (within.length === 0) {
543
+ deny.push(path);
544
+ return;
545
+ }
546
+ let entries;
547
+ try {
548
+ entries = list(path);
549
+ }
550
+ catch (error) {
551
+ deny.push(path);
552
+ process.stderr.write(`toolroll: fence: couldn't list ${path} (${error instanceof Error ? error.message : String(error)}); it stays denied and the shared dependencies inside it aren't granted\n`);
553
+ return;
554
+ }
555
+ readOnly.add(path);
556
+ for (const pattern of SPLIT_SECRET_PATTERNS)
557
+ deny.push(join(path, pattern));
558
+ for (const entry of entries) {
559
+ const child = join(path, entry);
560
+ if (grants.readable.includes(child))
561
+ readOnly.add(child);
562
+ else
563
+ split(child);
564
+ }
565
+ };
566
+ for (const path of fence)
567
+ split(path);
568
+ // Fail closed: a grant equal to or inside a denied path is dropped, so no read grant can outrank a deny (Codex lets
569
+ // the more specific path win). Patterns stay as Codex patterns; they can't contain a grant here.
570
+ const denied = deny.filter(path => !path.includes("*"));
571
+ return { deny, readOnly: [...readOnly].filter(path => !denied.some(one => path === one || inside(path, one))) };
572
+ }
573
+ /** Claude's own file tools refuse the fenced paths in every mode (its shell is fenced by the macOS sandbox), and
574
+ * never edit a read-only one. */
575
+ export function claudeFenceSettings(fence, readOnly = []) {
576
+ const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
231
577
  return JSON.stringify({ permissions: { deny: rules } });
232
578
  }
233
579
  const seatbelt = (path) => `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
234
- /** A Seatbelt profile that allows everything except reading or writing the fenced paths. */
235
- export function macosFenceProfile(fence) {
236
- return ["(version 1)", "(allow default)", `(deny file-read* file-write* ${fence.map(path => `(subpath ${seatbelt(path)})`).join(" ")})`].join("\n");
580
+ const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
581
+ /**
582
+ * The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
583
+ * user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
584
+ * back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
585
+ */
586
+ export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
587
+ const out = new Set();
588
+ const owned = (path) => { try {
589
+ return uid !== undefined && statSync(path).uid === uid;
590
+ }
591
+ catch {
592
+ return false;
593
+ } };
594
+ const climb = (from) => {
595
+ for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
596
+ if (owned(folder))
597
+ out.add(folder);
598
+ };
599
+ for (const path of paths)
600
+ climb(dirname(path));
601
+ for (const root of roots)
602
+ climb(root);
603
+ return [...out].sort();
604
+ }
605
+ /**
606
+ * A Seatbelt profile that allows everything except reading or writing the
607
+ * fenced paths, less what `grants` gives back (a later Seatbelt rule
608
+ * outranks an earlier one). Whatever the paths, the process can't read
609
+ * another's arguments and environment (`kern.procargs`, which `ps -E` and
610
+ * KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
611
+ * `process-info*` limited by target alone doesn't stop the sysctl. It can't
612
+ * take another process's task port either (`task_for_pid`, what lldb attaches
613
+ * with; a task name port too), so a tool server's or Toolroll's own memory
614
+ * and environment stay out of reach. Last, every folder of the user's above
615
+ * a fenced or read-only path and above the temp folder (and the temp folder
616
+ * itself) can't be renamed or removed, though what is inside them stays
617
+ * writable as before. The checkout pools are denied first, and the build's own
618
+ * checkout given back right after, so every later deny still holds inside it.
619
+ */
620
+ export function macosFenceProfile(fence, grants = NO_GRANTS) {
621
+ const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
622
+ const folders = grants.runFolders;
623
+ const pool = grants.pool;
624
+ const above = ownedAncestors([...fence, ...grants.readOnly, ...pool?.deny ?? []], [folders?.root ?? real(tmpdir())]);
625
+ return [
626
+ "(version 1)",
627
+ "(allow default)",
628
+ ...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
629
+ ...(pool?.own == null ? [] : [
630
+ `(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
631
+ `(deny file-write* ${paths("literal", [pool.own])})`,
632
+ ]),
633
+ ...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
634
+ ...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
635
+ ...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
636
+ ...(folders === undefined ? [] : [`(deny file-read* file-write* (regex #"^${seatbeltRegex(folders.root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`]),
637
+ ...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
638
+ // Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
639
+ ...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
640
+ ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
641
+ '(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
642
+ "(deny process-info-pidinfo)",
643
+ "(allow process-info-pidinfo (target same-sandbox))",
644
+ "(deny mach-priv-task-port)",
645
+ "(deny mach-task-name)",
646
+ ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
647
+ ].join("\n");
237
648
  }
238
649
  export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
239
650
  /** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
@@ -267,8 +678,8 @@ export function macosFenceAvailable(platform = process.platform, probe) {
267
678
  return seatbeltWorks;
268
679
  }
269
680
  /** The spawn that runs `file args` inside the macOS fence (same process: sandbox-exec replaces itself with the agent). */
270
- export function macosFenced(file, args, fence) {
271
- return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence), file, ...args] };
681
+ export function macosFenced(file, args, fence, grants = NO_GRANTS) {
682
+ return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence, grants), file, ...args] };
272
683
  }
273
684
  /** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
274
685
  let bubblewrapWorks = null;
@@ -285,9 +696,30 @@ export function linuxFenceAvailable(platform = process.platform, probe = () => s
285
696
  }
286
697
  return bubblewrapWorks;
287
698
  }
288
- /** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, except each fenced path masked. */
289
- export function linuxFenced(file, args, fence) {
290
- const masks = fence.flatMap(path => {
699
+ /** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
700
+ const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
701
+ const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
702
+ /**
703
+ * The grants bubblewrap may bind back over its masks: each existing read-only or readable path, never one that is a
704
+ * fenced path itself, a read-only one inside a fenced path (only the shared install this run links is read inside
705
+ * one), another launch's private folder, or one with a secret-looking name below its fenced folder. Bubblewrap can't
706
+ * mask by pattern, so a grant a pattern would deny is dropped instead (fail closed).
707
+ */
708
+ function linuxGrants(fence, grants) {
709
+ const folders = grants.runFolders;
710
+ const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
711
+ && RUN_FOLDER_PREFIXES.some(prefix => relative(folders.root, path).split(sep)[0].startsWith(prefix));
712
+ const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
713
+ const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
714
+ && !(readOnlyGrant && fence.some(one => inside(path, one)));
715
+ return [...new Set([...grants.readOnly.filter(path => ok(path, true)), ...grants.readable.filter(path => ok(path, false))])];
716
+ }
717
+ /** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, the checkout pools masked
718
+ * and the build's own checkout bound back read-write over them, then every fenced path masked whole (an empty
719
+ * folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
720
+ * bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
721
+ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
722
+ const mask = (path) => {
291
723
  let folder = true;
292
724
  try {
293
725
  folder = statSync(path).isDirectory();
@@ -296,6 +728,9 @@ export function linuxFenced(file, args, fence) {
296
728
  folder = true;
297
729
  }
298
730
  return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
299
- });
300
- return { file: "bwrap", args: ["--dev-bind", "/", "/", ...masks, "--die-with-parent", "--", file, ...args] };
731
+ };
732
+ const pool = grants.pool?.deny ?? [];
733
+ const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
734
+ const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
735
+ return { file: "bwrap", args: ["--dev-bind", "/", "/", ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, "--die-with-parent", "--", file, ...args] };
301
736
  }