toolroll 0.9.62 → 0.9.65

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 (345) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +66 -8
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +203 -10
  16. package/dist/agent-fence.js +623 -41
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +263 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +199 -17
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/check-levels.js +3 -0
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +20 -1
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +5 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +24 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/cli.d.ts +8 -0
  71. package/dist/contracts/cli.js +3 -1
  72. package/dist/contracts/console-api.d.ts +30 -4
  73. package/dist/contracts/console-api.js +13 -4
  74. package/dist/contracts/flow-card.d.ts +4 -0
  75. package/dist/contracts/flow-card.js +3 -0
  76. package/dist/contracts/flow.d.ts +46 -0
  77. package/dist/contracts/flow.js +11 -2
  78. package/dist/contracts/integration-metadata.d.ts +2 -1
  79. package/dist/contracts/integration-metadata.js +2 -1
  80. package/dist/contracts/lead-tools.d.ts +92 -0
  81. package/dist/contracts/lead-tools.js +13 -0
  82. package/dist/contracts/provider.d.ts +11 -1
  83. package/dist/contracts/provider.js +1 -1
  84. package/dist/contracts/route.d.ts +76 -1
  85. package/dist/contracts/route.js +1 -1
  86. package/dist/contracts/store-json.d.ts +2 -0
  87. package/dist/control-setup.d.ts +1 -1
  88. package/dist/control-ui.js +2 -2
  89. package/dist/converse.d.ts +16 -0
  90. package/dist/converse.js +32 -4
  91. package/dist/demo-chat.js +4 -4
  92. package/dist/demo.js +2 -2
  93. package/dist/desktop-update-gate.js +6 -0
  94. package/dist/discord-chat.js +4 -1
  95. package/dist/dispatch.js +7 -4
  96. package/dist/doctor.d.ts +53 -0
  97. package/dist/doctor.js +427 -0
  98. package/dist/egress-bridge.d.ts +17 -0
  99. package/dist/egress-bridge.js +111 -0
  100. package/dist/egress.d.ts +119 -0
  101. package/dist/egress.js +423 -0
  102. package/dist/evidence-pack.d.ts +1 -1
  103. package/dist/evidence-pack.js +2 -2
  104. package/dist/exec.d.ts +81 -0
  105. package/dist/exec.js +368 -9
  106. package/dist/export-ui.js +2 -2
  107. package/dist/flow-code.d.ts +2 -2
  108. package/dist/flow-code.js +4 -15
  109. package/dist/flow-engine.js +35 -11
  110. package/dist/flow-gallery-ui.js +2 -2
  111. package/dist/flow-gallery.js +27 -8
  112. package/dist/flow-share.js +16 -5
  113. package/dist/flow-triggers.d.ts +84 -11
  114. package/dist/flow-triggers.js +554 -87
  115. package/dist/flow-unattended-words.d.ts +28 -0
  116. package/dist/flow-unattended-words.js +50 -0
  117. package/dist/flow-unattended.d.ts +227 -0
  118. package/dist/flow-unattended.js +776 -0
  119. package/dist/flows-cli.d.ts +28 -1
  120. package/dist/flows-cli.js +63 -12
  121. package/dist/flows-ui.d.ts +2 -3
  122. package/dist/flows-ui.js +56 -5
  123. package/dist/flows.d.ts +4 -0
  124. package/dist/flows.js +9 -1
  125. package/dist/google-mail.d.ts +1 -0
  126. package/dist/google-mail.js +61 -18
  127. package/dist/html.d.ts +9 -0
  128. package/dist/html.js +11 -0
  129. package/dist/integrations-ui.d.ts +2 -1
  130. package/dist/integrations-ui.js +6 -3
  131. package/dist/integrations.d.ts +17 -3
  132. package/dist/integrations.js +38 -18
  133. package/dist/invoke.d.ts +17 -0
  134. package/dist/invoke.js +510 -182
  135. package/dist/keys.d.ts +18 -14
  136. package/dist/keys.js +5 -0
  137. package/dist/landed-cleanup.d.ts +3 -2
  138. package/dist/landed-cleanup.js +12 -5
  139. package/dist/lead-capabilities.js +3 -2
  140. package/dist/lead-contract.js +2 -0
  141. package/dist/lead-doors.d.ts +2 -0
  142. package/dist/lead-doors.js +7 -0
  143. package/dist/lead-follow.d.ts +2 -0
  144. package/dist/lead-follow.js +58 -2
  145. package/dist/lead-progress.js +1 -0
  146. package/dist/lead-queue-runtime.d.ts +46 -0
  147. package/dist/lead-queue-runtime.js +191 -0
  148. package/dist/lead-queue.d.ts +104 -0
  149. package/dist/lead-queue.js +187 -0
  150. package/dist/lead-sweep.d.ts +67 -0
  151. package/dist/lead-sweep.js +99 -0
  152. package/dist/lead-tools.d.ts +22 -0
  153. package/dist/lead-tools.js +163 -4
  154. package/dist/lead.d.ts +13 -5
  155. package/dist/lead.js +89 -29
  156. package/dist/limits-ui.d.ts +8 -1
  157. package/dist/limits-ui.js +26 -0
  158. package/dist/live-bus.d.ts +8 -0
  159. package/dist/live-bus.js +40 -4
  160. package/dist/live-detail.d.ts +27 -0
  161. package/dist/live-detail.js +57 -0
  162. package/dist/live-notes.d.ts +20 -0
  163. package/dist/live-notes.js +47 -0
  164. package/dist/live-records.d.ts +140 -0
  165. package/dist/live-records.js +935 -0
  166. package/dist/live-terminal.d.ts +258 -0
  167. package/dist/live-terminal.js +649 -0
  168. package/dist/live.d.ts +38 -27
  169. package/dist/live.js +142 -124
  170. package/dist/mcp-connect.d.ts +8 -7
  171. package/dist/mcp-connect.js +49 -51
  172. package/dist/mcp-oauth.d.ts +6 -3
  173. package/dist/mcp-oauth.js +8 -4
  174. package/dist/memory-pass.js +2 -2
  175. package/dist/mobile-viewport.d.ts +5 -1
  176. package/dist/mobile-viewport.js +18 -2
  177. package/dist/model-catalog.js +4 -0
  178. package/dist/monitoring-ui.js +2 -2
  179. package/dist/needs-you.d.ts +1 -1
  180. package/dist/needs-you.js +11 -4
  181. package/dist/oauth-run-token.d.ts +51 -0
  182. package/dist/oauth-run-token.js +127 -0
  183. package/dist/operate.d.ts +2 -0
  184. package/dist/operate.js +282 -18
  185. package/dist/passwordless.d.ts +23 -0
  186. package/dist/passwordless.js +26 -0
  187. package/dist/peek-cli.d.ts +12 -4
  188. package/dist/peek-cli.js +19 -11
  189. package/dist/phase-routing.js +10 -3
  190. package/dist/plan-auto.d.ts +11 -0
  191. package/dist/plan-auto.js +62 -11
  192. package/dist/planner-source.d.ts +3 -0
  193. package/dist/planner-source.js +14 -0
  194. package/dist/planner.d.ts +2 -1
  195. package/dist/planner.js +7 -1
  196. package/dist/policy-ui.js +2 -2
  197. package/dist/policy.d.ts +1 -20
  198. package/dist/policy.js +29 -2
  199. package/dist/process-recovery-eligibility.d.ts +1 -1
  200. package/dist/project-access.d.ts +3 -0
  201. package/dist/project-access.js +3 -0
  202. package/dist/project-delete-ui.d.ts +12 -0
  203. package/dist/project-delete-ui.js +41 -9
  204. package/dist/project-delete.d.ts +3 -1
  205. package/dist/project-delete.js +12 -6
  206. package/dist/project-file.d.ts +3 -0
  207. package/dist/project-file.js +16 -0
  208. package/dist/project-memory.d.ts +3 -1
  209. package/dist/project-memory.js +57 -25
  210. package/dist/project-tools.d.ts +103 -27
  211. package/dist/project-tools.js +399 -100
  212. package/dist/provider-auth.d.ts +4 -3
  213. package/dist/provider-auth.js +5 -4
  214. package/dist/provider-connection.d.ts +2 -1
  215. package/dist/provider-connection.js +5 -0
  216. package/dist/provider-home.d.ts +22 -0
  217. package/dist/provider-home.js +99 -0
  218. package/dist/provider.d.ts +42 -5
  219. package/dist/provider.js +97 -19
  220. package/dist/pull-request-ui.js +2 -2
  221. package/dist/push.js +1 -1
  222. package/dist/quick-start.d.ts +24 -0
  223. package/dist/quick-start.js +127 -0
  224. package/dist/release.d.ts +3 -0
  225. package/dist/release.js +14 -8
  226. package/dist/remote-main.d.ts +4 -0
  227. package/dist/remote-main.js +3 -3
  228. package/dist/request-budget-ui.js +2 -2
  229. package/dist/result-review.d.ts +1 -1
  230. package/dist/result-review.js +9 -3
  231. package/dist/retention-ui.js +2 -2
  232. package/dist/retention.js +10 -1
  233. package/dist/run-efficiency.d.ts +73 -0
  234. package/dist/run-efficiency.js +228 -0
  235. package/dist/runner.d.ts +5 -1
  236. package/dist/runner.js +22 -1
  237. package/dist/scope.d.ts +171 -6
  238. package/dist/scope.js +251 -31
  239. package/dist/scout-net.d.ts +4 -1
  240. package/dist/scout-net.js +37 -12
  241. package/dist/scout.d.ts +20 -6
  242. package/dist/scout.js +27 -3
  243. package/dist/serve.js +258 -43
  244. package/dist/server/chat.d.ts +1 -1
  245. package/dist/server/chat.js +122 -31
  246. package/dist/server/chrome.d.ts +6 -1
  247. package/dist/server/chrome.js +18 -7
  248. package/dist/server/console.css +52 -38
  249. package/dist/server/flows.js +65 -13
  250. package/dist/server/guards.d.ts +1 -0
  251. package/dist/server/guards.js +23 -9
  252. package/dist/server/http.d.ts +7 -0
  253. package/dist/server/http.js +3 -2
  254. package/dist/server/live.d.ts +34 -1
  255. package/dist/server/live.js +39 -17
  256. package/dist/server/pages-assets.js +5 -0
  257. package/dist/server/pages.js +90 -13
  258. package/dist/server/people-tokens.d.ts +3 -0
  259. package/dist/server/people-tokens.js +55 -17
  260. package/dist/server/remote-hooks.js +12 -6
  261. package/dist/server/remote.d.ts +2 -1
  262. package/dist/server/remote.js +14 -5
  263. package/dist/server/render-chat.d.ts +12 -3
  264. package/dist/server/render-chat.js +47 -16
  265. package/dist/server/render-pages.d.ts +4 -1
  266. package/dist/server/render-pages.js +5 -4
  267. package/dist/server/render-people.d.ts +3 -2
  268. package/dist/server/render-people.js +3 -2
  269. package/dist/server/render-results.d.ts +18 -2
  270. package/dist/server/render-results.js +37 -28
  271. package/dist/server/render-settings.js +6 -5
  272. package/dist/server/render-tasks.d.ts +35 -10
  273. package/dist/server/render-tasks.js +110 -27
  274. package/dist/server/request-context.d.ts +2 -0
  275. package/dist/server/request-context.js +2 -1
  276. package/dist/server/route-table.d.ts +3 -0
  277. package/dist/server/route-table.js +49 -5
  278. package/dist/server/runtime.d.ts +34 -3
  279. package/dist/server/session.d.ts +25 -1
  280. package/dist/server/session.js +18 -4
  281. package/dist/server/settings.js +132 -13
  282. package/dist/server/sweeps.d.ts +4 -0
  283. package/dist/server/sweeps.js +58 -0
  284. package/dist/server/task-views.d.ts +2 -1
  285. package/dist/server/task-views.js +96 -6
  286. package/dist/server/tasks.js +320 -74
  287. package/dist/server/terminals.d.ts +31 -0
  288. package/dist/server/terminals.js +365 -0
  289. package/dist/setup-guide.js +3 -2
  290. package/dist/slack-chat.js +4 -1
  291. package/dist/spend-ui.js +3 -3
  292. package/dist/spend.js +2 -1
  293. package/dist/steer-capabilities.d.ts +32 -0
  294. package/dist/steer-capabilities.js +60 -0
  295. package/dist/steer-controls.d.ts +14 -0
  296. package/dist/steer-controls.js +36 -0
  297. package/dist/steer-status.js +2 -2
  298. package/dist/storage-sweep-child.js +3 -1
  299. package/dist/storage-sweep-process.d.ts +12 -5
  300. package/dist/storage-sweep-process.js +23 -6
  301. package/dist/storage-ui.js +4 -4
  302. package/dist/store-baseline.d.ts +1 -1
  303. package/dist/store-baseline.js +1 -1
  304. package/dist/store.d.ts +466 -19
  305. package/dist/store.js +1185 -63
  306. package/dist/subagent-desk.js +1 -1
  307. package/dist/subagent-tools.d.ts +1 -1
  308. package/dist/summary.js +3 -0
  309. package/dist/surface.js +6 -4
  310. package/dist/task-source.d.ts +58 -0
  311. package/dist/task-source.js +93 -0
  312. package/dist/team-leads.js +1 -1
  313. package/dist/teams-chat.js +4 -1
  314. package/dist/teardown-git.d.ts +14 -0
  315. package/dist/teardown-git.js +119 -0
  316. package/dist/telegram-lead.d.ts +3 -0
  317. package/dist/telegram-lead.js +44 -7
  318. package/dist/telegram.d.ts +11 -2
  319. package/dist/telegram.js +51 -17
  320. package/dist/terminal-catalog.d.ts +11 -0
  321. package/dist/terminal-catalog.js +129 -0
  322. package/dist/text-limits.d.ts +2 -0
  323. package/dist/text-limits.js +2 -0
  324. package/dist/tool-broker.d.ts +39 -0
  325. package/dist/tool-broker.js +370 -0
  326. package/dist/tool-launcher.js +5 -4
  327. package/dist/tool-secret-store.d.ts +170 -0
  328. package/dist/tool-secret-store.js +834 -0
  329. package/dist/toolroll-update-ui.js +2 -2
  330. package/dist/toolroll-update.js +14 -3
  331. package/dist/tools-ui.d.ts +7 -1
  332. package/dist/tools-ui.js +19 -10
  333. package/dist/webhook-limits.d.ts +23 -0
  334. package/dist/webhook-limits.js +30 -0
  335. package/dist/webhook-readers.d.ts +56 -0
  336. package/dist/webhook-readers.js +138 -0
  337. package/dist/webhook-signing.d.ts +27 -0
  338. package/dist/webhook-signing.js +119 -0
  339. package/dist/work-index.d.ts +29 -0
  340. package/dist/work-index.js +21 -5
  341. package/dist/workspace-revision.js +3 -1
  342. package/dist/worktree.d.ts +10 -4
  343. package/dist/worktree.js +70 -29
  344. package/package.json +13 -2
  345. package/scripts/provider-canary.mjs +210 -31
@@ -24,13 +24,33 @@
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
  */
43
+ import { INSTANCE_DIR } from "./console-instance.js";
44
+ import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
28
45
  import { spawnSync } from "node:child_process";
29
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
30
- import { homedir } from "node:os";
46
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
47
+ import { homedir, tmpdir } from "node:os";
31
48
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
32
49
  import { readMonitoring } from "./monitoring-settings.js";
33
50
  import { existingFolders } from "./names.js";
51
+ import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
52
+ /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
53
+ export const BRIDGE_DIR = "bridge-secrets";
34
54
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
35
55
  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
56
  function real(path) {
@@ -45,6 +65,62 @@ function inside(child, parent) {
45
65
  const rel = relative(parent, child);
46
66
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
47
67
  }
68
+ export class CheckoutBindingError extends Error {
69
+ }
70
+ export const HARDENED_GIT_ARGS = ["--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"];
71
+ const GIT_ENV_NAMES = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "LANG", "LC_ALL", "CODEX_HOME"];
72
+ export function checkoutGitEnv(base = process.env) {
73
+ return Object.fromEntries(GIT_ENV_NAMES.flatMap(name => base[name] === undefined ? [] : [[name, base[name]]]));
74
+ }
75
+ export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
76
+ const main = real(repository);
77
+ const checkout = real(worktree);
78
+ const fail = () => new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
79
+ const registry = join(main, ".git", "worktrees");
80
+ try {
81
+ // The registry may legitimately contain the destination of an attacker link.
82
+ // Check the recorded name before accepting that destination as this run's checkout.
83
+ if (realpathSync(worktree) !== join(realpathSync(dirname(worktree)), basename(worktree)))
84
+ throw fail();
85
+ for (const root of poolRoots) {
86
+ if (root === null)
87
+ continue;
88
+ const components = [];
89
+ for (let component = resolve(worktree); component !== dirname(component); component = dirname(component)) {
90
+ components.push(component);
91
+ // Permit system aliases above the trusted pool (e.g. /var -> /private/var),
92
+ // but no redirect at or below it, including older checkout names.
93
+ if (real(component) !== real(root) && component !== resolve(root))
94
+ continue;
95
+ for (const one of components)
96
+ if (lstatSync(one).isSymbolicLink())
97
+ throw fail();
98
+ break;
99
+ }
100
+ }
101
+ if (!lstatSync(join(main, ".git")).isDirectory() || !lstatSync(registry).isDirectory())
102
+ throw fail();
103
+ // Query the trusted checkout, never the candidate's configurable Git pointer.
104
+ const listed = spawnSync("git", [...HARDENED_GIT_ARGS, "worktree", "list", "--porcelain", "-z"], { cwd: main, env: checkoutGitEnv(), encoding: "utf8" });
105
+ if (listed.status !== 0 || !listed.stdout.split("\0").includes(`worktree ${checkout}`))
106
+ throw fail();
107
+ for (const entry of readdirSync(registry)) {
108
+ const path = join(registry, entry);
109
+ if (!lstatSync(path).isDirectory() || !inside(real(path), real(registry)))
110
+ continue;
111
+ if (real(readFileSync(join(path, "gitdir"), "utf8").trim()) !== join(checkout, ".git"))
112
+ continue;
113
+ const gitdir = real(path);
114
+ if (worktreeGitDir(checkout) !== gitdir || real(join(gitdir, readFileSync(join(gitdir, "commondir"), "utf8").trim())) !== real(join(main, ".git")))
115
+ throw fail();
116
+ return { main, gitdir };
117
+ }
118
+ }
119
+ catch {
120
+ throw fail();
121
+ }
122
+ throw fail();
123
+ }
48
124
  /**
49
125
  * Every path an agent must not reach: the Toolroll state folder
50
126
  * beside the database (all of it, except the folder that holds this run's
@@ -55,6 +131,10 @@ function inside(child, parent) {
55
131
  */
56
132
  export function agentFence(options) {
57
133
  const fence = new Set();
134
+ /** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
135
+ let runFolder = null;
136
+ /** The bridges' folder, likewise. */
137
+ let bridgeFolder = null;
58
138
  const home = real(options.home ?? homedir());
59
139
  const worktree = options.worktree === null ? null : real(options.worktree);
60
140
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
@@ -63,6 +143,45 @@ export function agentFence(options) {
63
143
  for (const suffix of ["", "-wal", "-shm", "-journal"])
64
144
  if (existsSync(database + suffix))
65
145
  fence.add(database + suffix);
146
+ // The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
147
+ // build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
148
+ // 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
149
+ // real folder to cover (a Linux bind needs one).
150
+ // Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
151
+ // points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
152
+ // If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
153
+ // rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
154
+ // The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
155
+ // from every build, so no build — however long it runs — can reach a bridge made for a later one.
156
+ const bridges = join(state, BRIDGE_DIR);
157
+ try {
158
+ lstatSync(bridges);
159
+ }
160
+ catch {
161
+ try {
162
+ mkdirSync(bridges, { mode: 0o700 });
163
+ }
164
+ catch { /* fenced below either way */ }
165
+ }
166
+ // Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
167
+ // refuses a root that isn't a private folder: egress-bridge.ts).
168
+ bridgeFolder = bridges;
169
+ fence.add(bridges);
170
+ runFolder = join(state, INSTANCE_DIR);
171
+ const present = () => { try {
172
+ lstatSync(runFolder);
173
+ return true;
174
+ }
175
+ catch {
176
+ return false;
177
+ } };
178
+ if (!present()) {
179
+ try {
180
+ mkdirSync(runFolder, { mode: 0o700 });
181
+ }
182
+ catch { /* fenced below either way */ }
183
+ }
184
+ fence.add(runFolder);
66
185
  // Only a folder that is Toolroll's own (~/.config/toolroll or an older name,
67
186
  // the desktop app's "Toolroll") is fenced whole: a database kept
68
187
  // in a shared folder (a projects folder, a test's temp folder) must not
@@ -71,7 +190,7 @@ export function agentFence(options) {
71
190
  // The entries this run's worktree needs stay reachable: the one it lives
72
191
  // under, and the repository its git metadata belongs to.
73
192
  const keep = new Set();
74
- for (const needed of [worktree, worktree === null ? null : mainRepositoryOf(worktree)]) {
193
+ for (const needed of [worktree, options.binding?.main ?? null]) {
75
194
  if (needed !== null && inside(needed, state))
76
195
  keep.add(relative(state, needed).split(sep)[0]);
77
196
  }
@@ -101,10 +220,23 @@ export function agentFence(options) {
101
220
  for (const path of options.extra ?? [])
102
221
  if (existsSync(path))
103
222
  fence.add(real(path));
223
+ // Secret, state, database, explicit-extra and home-config denies are never exemptible.
224
+ if (options.runFolders !== undefined)
225
+ for (const path of otherRunFolders(options.runFolders))
226
+ fence.add(path);
227
+ for (const path of options.boundary?.protectedShell ?? [])
228
+ fence.add(real(path));
229
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
104
230
  for (const path of options.boundary?.shell ?? [])
105
231
  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();
232
+ const binding = options.binding;
233
+ const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
234
+ return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
235
+ if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
236
+ return true;
237
+ console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
238
+ return false;
239
+ }).sort();
108
240
  }
109
241
  /**
110
242
  * Credentials and agent configuration in the home folder. A build is the
@@ -112,10 +244,18 @@ export function agentFence(options) {
112
244
  * cloud logins or another agent's sign-in, or rewrite their own Claude or
113
245
  * Codex setup for the next interactive session.
114
246
  */
115
- const HOME_CONFIGURATION = [".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json")];
247
+ const HOME_CONFIGURATION = [
248
+ ".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json"),
249
+ // Every ACP agent's own configuration and sign-in (acp-agents.ts): fenced from every build but that agent's own.
250
+ ...ACP_PROVIDER_IDS.flatMap(id => ACP_AGENTS[id].home),
251
+ ];
116
252
  /** What a provider's own CLI reads to sign in and keep its sessions. The OS fence around Claude and Gemini holds
117
253
  * their whole process, so those stay reachable there; Codex fences only its agent's commands, so it keeps none. */
118
- const PROVIDER_HOME = { claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [] };
254
+ const PROVIDER_HOME = {
255
+ claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [],
256
+ // Each ACP agent keeps its own configuration and sign-in where its row says (acp-agents.ts).
257
+ ...Object.fromEntries(ACP_PROVIDER_IDS.map(id => [id, ACP_AGENTS[id].home])),
258
+ };
119
259
  /** What Claude's own file tools refuse inside its home: the CLI reads these itself, its model never needs to. */
120
260
  const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.json"), join(".claude", "settings.json"), join(".claude", "settings.local.json"), ".npmrc"];
121
261
  /**
@@ -129,10 +269,13 @@ const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.jso
129
269
  export function taskBoundary(options) {
130
270
  const home = real(options.home ?? homedir());
131
271
  const worktree = real(options.worktree);
132
- const main = mainRepositoryOf(worktree);
272
+ const main = options.binding?.main ?? null;
273
+ const gitdir = options.binding?.gitdir ?? null;
274
+ const needed = [worktree, main, gitdir].filter((path) => path !== null);
133
275
  const own = new Set(PROVIDER_HOME[options.provider] ?? []);
134
276
  const present = (names) => names.map(name => join(home, name)).filter(path => existsSync(path)).map(real);
135
277
  const shell = new Set(present(HOME_CONFIGURATION.filter(name => !own.has(name))));
278
+ const protectedShell = [...shell];
136
279
  const repository = (path) => existsSync(join(path, ".git"));
137
280
  for (const parent of new Set([dirname(worktree), ...(main === null ? [] : [dirname(main)])])) {
138
281
  // Home contains toolchain clones such as .nvm and .oh-my-zsh. The
@@ -149,8 +292,11 @@ export function taskBoundary(options) {
149
292
  for (const entry of entries) {
150
293
  if (entry.startsWith("."))
151
294
  continue;
152
- const path = join(parent, entry);
153
- if (path === worktree || path === main || inside(worktree, path) || !repository(path))
295
+ const candidate = join(parent, entry);
296
+ if (!repository(candidate))
297
+ continue;
298
+ const path = real(candidate);
299
+ if (needed.some(target => target === path || inside(target, path)))
154
300
  continue;
155
301
  shell.add(path);
156
302
  }
@@ -170,8 +316,12 @@ export function taskBoundary(options) {
170
316
  }
171
317
  }
172
318
  // 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);
319
+ // must not prune or rewrite the metadata of another task's checkout. It
320
+ // still reads that metadata (gc keeps what a sibling's HEAD and index
321
+ // reach) and looks up the checkout it names: a checkout it can't see is
322
+ // one `git worktree prune` deletes the entry of.
323
+ const readOnly = new Set();
324
+ const lookup = new Set();
175
325
  if (gitdir !== null && main !== null) {
176
326
  const siblings = dirname(gitdir);
177
327
  let entries = [];
@@ -182,13 +332,160 @@ export function taskBoundary(options) {
182
332
  entries = [];
183
333
  }
184
334
  for (const entry of entries) {
185
- const path = real(join(siblings, entry));
186
- if (path !== gitdir)
187
- shell.add(path);
335
+ // Only a plain folder, by the name git gave it: a link a build planted here (to ~/.toolroll, say) is never
336
+ // followed into a grant, which a sandbox that lets the more specific path win would honour over the fence.
337
+ const path = join(siblings, entry);
338
+ if (path === gitdir)
339
+ continue;
340
+ try {
341
+ if (!lstatSync(path).isDirectory())
342
+ continue;
343
+ }
344
+ catch {
345
+ continue;
346
+ }
347
+ readOnly.add(path);
348
+ let pointer;
349
+ try {
350
+ pointer = readFileSync(join(path, "gitdir"), "utf8").trim();
351
+ }
352
+ catch {
353
+ pointer = undefined;
354
+ }
355
+ if (pointer === undefined || pointer === "" || !isAbsolute(pointer))
356
+ continue;
357
+ const checkout = dirname(real(pointer));
358
+ // The entry's pointer is build-writable: look up only a checkout whose own .git names this entry back, as git
359
+ // links a real one, so a planted pointer can't lift a fenced path's metadata.
360
+ if (worktreeGitDir(checkout) !== path)
361
+ continue;
362
+ lookup.add(checkout);
363
+ lookup.add(join(checkout, ".git"));
188
364
  }
189
365
  }
190
366
  const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
191
- return { shell: [...shell].sort(), fileTools: fileTools.sort() };
367
+ return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
368
+ }
369
+ /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
370
+ * network bridge (egress-bridge.ts). */
371
+ export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-"];
372
+ export function runFolders(own, root = tmpdir()) {
373
+ return { root: real(root), own: own === null ? null : real(own) };
374
+ }
375
+ /** Every other launch's private folder there now. */
376
+ function otherRunFolders(folders) {
377
+ let entries = [];
378
+ try {
379
+ entries = readdirSync(folders.root);
380
+ }
381
+ catch {
382
+ entries = [];
383
+ }
384
+ return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
385
+ }
386
+ /**
387
+ * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
388
+ * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
389
+ * (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
390
+ * confined; the run's proxy resolves names for it.
391
+ */
392
+ export const DELEGATION_SERVICES = [
393
+ "com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
394
+ "com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
395
+ "com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
396
+ ];
397
+ export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
398
+ /**
399
+ * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
400
+ * the recorded checkout was made in), never from the checkout. A root that holds this checkout's own Git data (the
401
+ * binding's gitdir or the repository's .git) is never denied whole: it is split around that data instead, entry by
402
+ * entry, so it is denied as far as it can be (fail closed by denying more, never by refusing). A root inside the
403
+ * checkout, the home folder, a folder above it or / is left alone: the first is the build's own work, the others the
404
+ * whole machine.
405
+ */
406
+ export function checkoutPool(options, list = path => readdirSync(path)) {
407
+ const own = options.worktree === null ? null : real(options.worktree);
408
+ const home = real(options.home ?? homedir());
409
+ const binding = options.binding ?? null;
410
+ const needed = binding === null ? [] : [realpathSync(binding.gitdir), realpathSync(join(binding.main, ".git"))];
411
+ const deny = new Set();
412
+ const visited = new Set();
413
+ const split = (input) => {
414
+ const path = realpathSync(input);
415
+ if (visited.has(path))
416
+ return;
417
+ visited.add(path);
418
+ if (!needed.some(target => target === path || inside(target, path))) {
419
+ deny.add(path);
420
+ return;
421
+ }
422
+ if (needed.includes(path))
423
+ return;
424
+ const entries = list(path); // An unreadable pool must refuse preparation, never silently widen access.
425
+ for (const entry of entries)
426
+ split(join(path, entry));
427
+ };
428
+ for (const root of options.roots) {
429
+ if (root === null || !existsSync(root))
430
+ continue;
431
+ const path = real(root);
432
+ if (path === dirname(path) || path === home || inside(home, path) || (own !== null && (path === own || inside(path, own))))
433
+ continue;
434
+ split(path);
435
+ }
436
+ const holding = own === null ? [] : [...deny].filter(path => inside(own, path));
437
+ const lookup = new Set([...visited].filter(path => needed.some(target => inside(target, path))));
438
+ for (const path of holding)
439
+ for (let folder = dirname(own); folder === path || inside(folder, path); folder = dirname(folder))
440
+ lookup.add(folder);
441
+ return { deny: [...deny].sort(), own: holding.length === 0 ? null : own, lookup: [...lookup].sort(), ...(needed.length === 0 ? {} : { gitPaths: needed }) };
442
+ }
443
+ /** The grants for one build: its task boundary's git rules, the shared dependencies its checkout links, and the
444
+ * checkout pools (`poolRoots`, from the plane's records) denied around its own checkout. */
445
+ export function fenceGrants(options) {
446
+ const readable = [];
447
+ const lookup = [...options.boundary?.lookup ?? []];
448
+ if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:" && options.worktree !== null && options.linked != null) {
449
+ // The link note names the copy as the plane wrote it; the sandbox sees real paths. The note is in the build's own
450
+ // checkout, so it is only a hint: the copy must be a ready one the plane made for this run's project under the
451
+ // setup it stamped on this checkout, or nothing is granted.
452
+ const written = join(dirname(options.databaseFile), DEPS_FOLDER);
453
+ const deps = join(dirname(real(options.databaseFile)), DEPS_FOLDER);
454
+ const key = linkedKey(options.worktree, written) ?? linkedKey(options.worktree, deps);
455
+ const linked = options.linked;
456
+ const copy = key === null || !/^[0-9a-f]{24}$/.test(key) ? undefined : sharedCopies(deps).find(one => one.key === key);
457
+ const ours = copy !== undefined && !copy.retired && copy.repo === linked.repo && linked.setupDigest !== null && readySetup(deps, key) === linked.setupDigest && readyCopy(deps, key) !== null;
458
+ if (ours && key !== null && existsSync(join(deps, key, "node_modules"))) {
459
+ lookup.push(deps, join(deps, key));
460
+ readable.push(join(deps, key, "node_modules"));
461
+ }
462
+ }
463
+ const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
464
+ return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
465
+ ...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
466
+ }
467
+ /** The setup a shared copy was installed by (its ready note), or null. */
468
+ function readySetup(deps, key) {
469
+ try {
470
+ const setup = JSON.parse(readFileSync(join(deps, key, "ready.json"), "utf8")).setup;
471
+ return typeof setup === "string" ? setup : null;
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ }
477
+ /**
478
+ * Git settings every fenced build runs with (GIT_CONFIG_COUNT, after any the
479
+ * caller already set): `git gc` never expires a reflog, so it never rewrites
480
+ * another task's reflog, which the fence would refuse mid-collection.
481
+ */
482
+ export function fencedGitEnv(base = process.env) {
483
+ const count = Number.parseInt(base["GIT_CONFIG_COUNT"] ?? "0", 10);
484
+ const start = Number.isInteger(count) && count > 0 ? count : 0;
485
+ const settings = [["gc.reflogExpire", "never"], ["gc.reflogExpireUnreachable", "never"]];
486
+ const env = { GIT_CONFIG_COUNT: String(start + settings.length) };
487
+ settings.forEach(([key, value], index) => { env[`GIT_CONFIG_KEY_${start + index}`] = key; env[`GIT_CONFIG_VALUE_${start + index}`] = value; });
488
+ return env;
192
489
  }
193
490
  /** The repository a git worktree belongs to (its `.git` file names the main repository's git folder). */
194
491
  function worktreeGitDir(worktree) {
@@ -200,40 +497,248 @@ function worktreeGitDir(worktree) {
200
497
  return null;
201
498
  }
202
499
  }
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
500
  const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
212
501
  /**
213
502
  * Codex's own sandbox, fenced. Auto: the `:workspace` profile Codex's
214
503
  * `--sandbox workspace-write` already gives (workspace and temp writes, no
215
504
  * network). Full access: the same profile widened to write anywhere with
216
505
  * 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.
506
+ * denied, which in Codex outranks any write or read grant. The checkout pools
507
+ * are never `flattened` (which leaves what is made after launch readable): see
508
+ * `codexPool`, which denies them around the build's own checkout, written back.
218
509
  */
219
- export function codexFenceArgv(fence, fullAccess) {
220
- const filesystem = [...(fullAccess ? ['"/"="write"'] : []), ...fence.map(path => `${toml(path)}="deny"`)];
510
+ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
511
+ const { deny, readOnly } = flattened(fence, grants);
512
+ // Codex's harness reads its own launch files outside the command sandbox.
513
+ // Deny the namespace, including folders created after this policy was built.
514
+ if (grants.runFolders !== undefined) {
515
+ for (const prefix of RUN_FOLDER_PREFIXES) {
516
+ deny.push(join(grants.runFolders.root, `${prefix}*`), join(grants.runFolders.root, `${prefix}*`, "**"));
517
+ }
518
+ }
519
+ // Launch folders can already be in the base fence when invoke appends them.
520
+ // TOML forbids duplicate keys, even with identical values. Normalize aliases
521
+ // at this boundary and apply stronger access restrictions last.
522
+ const access = new Map();
523
+ const pool = grants.pool;
524
+ // Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
525
+ const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
526
+ && !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
527
+ if (fullAccess)
528
+ access.set("/", "write");
529
+ for (const path of readOnly)
530
+ if (!pooled(real(path)))
531
+ access.set(real(path), "read");
532
+ if (pool !== undefined) {
533
+ const held = codexPool(pool);
534
+ for (const path of held.read)
535
+ access.set(path, "read");
536
+ for (const path of held.deny)
537
+ access.set(path, "deny");
538
+ for (const path of held.write)
539
+ access.set(path, "write");
540
+ }
541
+ // Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
542
+ // stays unreplaceable, not only its target.
543
+ for (const path of deny) {
544
+ access.set(real(path), "deny");
545
+ if (basename(path) === INSTANCE_DIR)
546
+ access.set(path, "deny");
547
+ }
548
+ const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
221
549
  return [
222
550
  "-c", 'default_permissions="standing-orders"',
223
551
  "-c", 'permissions.standing-orders.extends=":workspace"',
224
552
  "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
225
- ...(fullAccess ? ["-c", "permissions.standing-orders.network.enabled=true", "-c", 'approval_policy="never"'] : []),
553
+ // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
554
+ // network is open (and Full access asked for it), and are offline otherwise.
555
+ ...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
556
+ ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
226
557
  ];
227
558
  }
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}/**)`]);
559
+ /**
560
+ * Codex cannot deny an ancestor and still traverse it to the own checkout. Deny existing
561
+ * siblings individually, from the trusted pool roots and a fresh listing, never through
562
+ * flattened or name patterns. A project added after launch is covered on the next launch;
563
+ * Seatbelt and bubblewrap retain the stronger whole-pool rule for later-created projects.
564
+ */
565
+ export function codexPool(pool, list = path => readdirSync(path)) {
566
+ const deny = new Set();
567
+ const read = new Set(pool.lookup);
568
+ const own = pool.own;
569
+ const needed = [...pool.gitPaths ?? [], ...(own === null ? [] : [own])].map(path => realpathSync(path));
570
+ const visited = new Set();
571
+ const split = (input) => {
572
+ let path;
573
+ try {
574
+ path = realpathSync(input);
575
+ }
576
+ catch {
577
+ deny.add(resolve(input));
578
+ return;
579
+ }
580
+ if (visited.has(path))
581
+ return;
582
+ visited.add(path);
583
+ if (needed.some(target => path === target || inside(path, target)))
584
+ return;
585
+ if (!needed.some(target => inside(target, path))) {
586
+ deny.add(path);
587
+ return;
588
+ }
589
+ // Only ancestors needed for traversal stay readable, never writable in Full access.
590
+ read.add(path);
591
+ for (const entry of list(path))
592
+ split(join(path, entry));
593
+ };
594
+ for (const root of pool.deny)
595
+ split(root);
596
+ return { read: [...read].sort(), deny: [...deny].sort(), write: own === null ? [] : [own] };
597
+ }
598
+ /** Secret-looking names denied by pattern inside a folder `flattened` makes read-only, so one written there after
599
+ * launch is denied too (the database and its journals, tokens, login and other text files). */
600
+ export const SPLIT_SECRET_PATTERNS = ["orders.db*", "*token*", "up-login*", "*.txt", "*secret*", "*password*", "*credential*", "*.pem", "*.key"];
601
+ /**
602
+ * A fence as plain denied and read-only paths, for a sandbox that can't
603
+ * grant inside a denied folder (Codex: a deny outranks any grant): a denied
604
+ * folder holding something readable becomes read-only, with each of its
605
+ * entries not on the way to what is readable denied instead. Fails closed: a
606
+ * folder that can't be listed stays denied whole and its grant is dropped.
607
+ * Residual: an entry made after launch in a read-only folder is denied only
608
+ * when its name matches SPLIT_SECRET_PATTERNS; any other new entry there is
609
+ * readable (never writable) until the next launch. The macOS fence has no
610
+ * such gap: it denies the folder and grants back only the readable path.
611
+ */
612
+ export function flattened(fence, grants = NO_GRANTS, list = path => readdirSync(path)) {
613
+ const deny = [];
614
+ const readOnly = new Set(grants.readOnly);
615
+ const split = (path) => {
616
+ const within = grants.readable.filter(one => inside(one, path));
617
+ if (within.length === 0) {
618
+ deny.push(path);
619
+ return;
620
+ }
621
+ let entries;
622
+ try {
623
+ entries = list(path);
624
+ }
625
+ catch (error) {
626
+ deny.push(path);
627
+ 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`);
628
+ return;
629
+ }
630
+ readOnly.add(path);
631
+ for (const pattern of SPLIT_SECRET_PATTERNS)
632
+ deny.push(join(path, pattern));
633
+ for (const entry of entries) {
634
+ const child = join(path, entry);
635
+ if (grants.readable.includes(child))
636
+ readOnly.add(child);
637
+ else
638
+ split(child);
639
+ }
640
+ };
641
+ for (const path of fence)
642
+ split(path);
643
+ // Fail closed: a grant equal to or inside a denied path is dropped, so no read grant can outrank a deny (Codex lets
644
+ // the more specific path win). Patterns stay as Codex patterns; they can't contain a grant here.
645
+ const denied = deny.filter(path => !path.includes("*"));
646
+ return { deny, readOnly: [...readOnly].filter(path => !denied.some(one => path === one || inside(path, one))) };
647
+ }
648
+ /** Claude's own file tools refuse the fenced paths in every mode (its shell is fenced by the macOS sandbox), and
649
+ * never edit a read-only one. */
650
+ export function claudeFenceSettings(fence, readOnly = []) {
651
+ const rules = [...fence.flatMap(path => [`Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`]), ...readOnly.flatMap(path => [`Edit(/${path})`, `Edit(/${path}/**)`])];
231
652
  return JSON.stringify({ permissions: { deny: rules } });
232
653
  }
233
654
  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");
655
+ const seatbeltRegex = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
656
+ /**
657
+ * The folders the user owns above each path (never `/`, nor the path itself), and each `roots` folder itself when the
658
+ * user owns it. A Seatbelt path rule follows the name, not the folder: renaming one of these would bring a fenced path
659
+ * back under a name no rule mentions. A folder the user doesn't own can't be renamed by a build anyway.
660
+ */
661
+ export function ownedAncestors(paths, roots = [], uid = process.getuid?.()) {
662
+ const out = new Set();
663
+ const owned = (path) => { try {
664
+ return uid !== undefined && statSync(path).uid === uid;
665
+ }
666
+ catch {
667
+ return false;
668
+ } };
669
+ const climb = (from) => {
670
+ for (let folder = from; folder !== dirname(folder); folder = dirname(folder))
671
+ if (owned(folder))
672
+ out.add(folder);
673
+ };
674
+ for (const path of paths)
675
+ climb(dirname(path));
676
+ for (const root of roots)
677
+ climb(root);
678
+ return [...out].sort();
679
+ }
680
+ /**
681
+ * A Seatbelt profile that allows everything except reading or writing the
682
+ * fenced paths, less what `grants` gives back (a later Seatbelt rule
683
+ * outranks an earlier one). Whatever the paths, the process can't read
684
+ * another's arguments and environment (`kern.procargs`, which `ps -E` and
685
+ * KERN_PROCARGS2 use) or inspect a process outside its own sandbox:
686
+ * `process-info*` limited by target alone doesn't stop the sysctl. It can't
687
+ * take another process's task port either (`task_for_pid`, what lldb attaches
688
+ * with; a task name port too), so a tool server's or Toolroll's own memory
689
+ * and environment stay out of reach. Last, every folder of the user's above
690
+ * a fenced or read-only path and above the temp folder (and the temp folder
691
+ * itself) can't be renamed or removed, though what is inside them stays
692
+ * writable as before. The checkout pools are denied first, and the build's own
693
+ * checkout given back right after, so every later deny still holds inside it.
694
+ */
695
+ export function macosFenceProfile(fence, grants = NO_GRANTS) {
696
+ const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
697
+ const folders = grants.runFolders;
698
+ const pool = grants.pool;
699
+ const above = ownedAncestors([...fence, ...grants.readOnly, ...pool?.deny ?? []], [folders?.root ?? real(tmpdir())]);
700
+ return [
701
+ "(version 1)",
702
+ "(allow default)",
703
+ ...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
704
+ ...(pool?.own == null ? [] : [
705
+ `(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
706
+ `(deny file-write* ${paths("literal", [pool.own])})`,
707
+ ]),
708
+ ...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
709
+ ...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
710
+ ...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
711
+ ...(folders === undefined ? [] : [`(deny file-read* file-write* (regex #"^${seatbeltRegex(folders.root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`]),
712
+ ...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
713
+ // Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
714
+ ...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
715
+ ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
716
+ '(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
717
+ "(deny process-info-pidinfo)",
718
+ "(allow process-info-pidinfo (target same-sandbox))",
719
+ "(deny mach-priv-task-port)",
720
+ "(deny mach-task-name)",
721
+ ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
722
+ ...macosNetworkRules(grants.network),
723
+ ].join("\n");
724
+ }
725
+ /**
726
+ * The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
727
+ * allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
728
+ * allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
729
+ * or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
730
+ * connection.
731
+ */
732
+ export function macosNetworkRules(network) {
733
+ if (network === undefined || network.mode === "open")
734
+ return [];
735
+ const local = (port) => `(remote ip "localhost:${port}")`;
736
+ return [
737
+ "(deny network-outbound)",
738
+ `(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
739
+ ...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
740
+ `(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
741
+ ];
237
742
  }
238
743
  export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
239
744
  /** 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 +772,8 @@ export function macosFenceAvailable(platform = process.platform, probe) {
267
772
  return seatbeltWorks;
268
773
  }
269
774
  /** 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] };
775
+ export function macosFenced(file, args, fence, grants = NO_GRANTS) {
776
+ return { file: SANDBOX_EXEC, args: ["-p", macosFenceProfile(fence, grants), file, ...args] };
272
777
  }
273
778
  /** Whether bubblewrap runs here (checked once): installed, and allowed to make its namespace. */
274
779
  let bubblewrapWorks = null;
@@ -285,9 +790,33 @@ export function linuxFenceAvailable(platform = process.platform, probe = () => s
285
790
  }
286
791
  return bubblewrapWorks;
287
792
  }
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 => {
793
+ /** A secret-name pattern (SPLIT_SECRET_PATTERNS) as a test of one path segment. */
794
+ const patternTest = (pattern) => new RegExp(`^${pattern.split("*").map(seatbeltRegex).join(".*")}$`);
795
+ const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
796
+ /**
797
+ * The grants bubblewrap may bind back over its masks: each existing read-only or readable path, never one that is a
798
+ * fenced path itself, a read-only one inside a fenced path (only the shared install this run links is read inside
799
+ * one), another launch's private folder, or one with a secret-looking name below its fenced folder. Bubblewrap can't
800
+ * mask by pattern, so a grant a pattern would deny is dropped instead (fail closed).
801
+ */
802
+ function linuxGrants(fence, grants) {
803
+ const folders = grants.runFolders;
804
+ const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
805
+ && RUN_FOLDER_PREFIXES.some(prefix => relative(folders.root, path).split(sep)[0].startsWith(prefix));
806
+ const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
807
+ const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
808
+ && !(readOnlyGrant && fence.some(one => inside(path, one)));
809
+ return [...new Set([...grants.readOnly.filter(path => ok(path, true)), ...grants.readable.filter(path => ok(path, false))])];
810
+ }
811
+ /** The spawn that runs `file args` inside the Linux fence: the whole filesystem as it is, the checkout pools masked
812
+ * and the build's own checkout bound back read-write over them, then every fenced path masked whole (an empty
813
+ * folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
814
+ * bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
815
+ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
816
+ const network = grants.network?.mode === "allowlist" ? grants.network : null;
817
+ if (network !== null && network.bridge === undefined)
818
+ throw new Error("A confined network on Linux needs its bridge");
819
+ const mask = (path) => {
291
820
  let folder = true;
292
821
  try {
293
822
  folder = statSync(path).isDirectory();
@@ -296,6 +825,59 @@ export function linuxFenced(file, args, fence) {
296
825
  folder = true;
297
826
  }
298
827
  return folder ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path];
299
- });
300
- return { file: "bwrap", args: ["--dev-bind", "/", "/", ...masks, "--die-with-parent", "--", file, ...args] };
828
+ };
829
+ const pool = grants.pool?.deny ?? [];
830
+ const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
831
+ const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
832
+ // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
833
+ // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
834
+ const isolated = network === null ? [] : [
835
+ "--unshare-net", "--unshare-pid", "--proc", "/proc",
836
+ ...linuxHostSockets().flatMap(mask),
837
+ "--ro-bind", network.bridge.folder, network.bridge.folder,
838
+ ];
839
+ // A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
840
+ // all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
841
+ const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => ["--bind", path, path])];
842
+ const command = network === null ? [file, ...args]
843
+ : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
844
+ return { file: "bwrap", args: ["--dev-bind", "/", "/", ...privateTmp, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...isolated, "--die-with-parent", "--", ...command] };
845
+ }
846
+ /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
847
+ * engines), wherever they exist here. */
848
+ export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp") {
849
+ // The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
850
+ const system = [
851
+ ...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
852
+ "/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
853
+ ];
854
+ const masks = new Set();
855
+ for (const path of system) {
856
+ try {
857
+ masks.add(realpathSync(path));
858
+ }
859
+ catch { /* not here */ }
860
+ }
861
+ // The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
862
+ // through a link (a planted link would turn a mask onto any folder at all). Container engines
863
+ // run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
864
+ // Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
865
+ // socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
866
+ // Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
867
+ // a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
868
+ const owned = (path) => {
869
+ try {
870
+ const held = lstatSync(path);
871
+ return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
872
+ }
873
+ catch {
874
+ return false;
875
+ }
876
+ };
877
+ for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name)))
878
+ if (owned(path))
879
+ masks.add(path);
880
+ // /tmp's sockets (the display's, an SSH agent's, a multiplexer's, editors') go with the build's private /tmp.
881
+ void tmp;
882
+ return [...masks].sort();
301
883
  }