toolroll 0.9.67 → 0.9.69

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 (250) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/acp-client.js +1 -3
  3. package/dist/agent-board.d.ts +0 -1
  4. package/dist/agent-board.js +0 -1
  5. package/dist/agent-fence.d.ts +58 -0
  6. package/dist/agent-fence.js +214 -19
  7. package/dist/agentconfig.d.ts +1 -1
  8. package/dist/agentconfig.js +6 -2
  9. package/dist/batch-checks.js +14 -26
  10. package/dist/browser/workspace.css +1 -1
  11. package/dist/browser/workspace.js +31 -29
  12. package/dist/builder.d.ts +9 -0
  13. package/dist/builder.js +30 -27
  14. package/dist/canonical-json.d.ts +14 -0
  15. package/dist/canonical-json.js +36 -0
  16. package/dist/chat-actions.js +1 -2
  17. package/dist/chat-admission.d.ts +19 -0
  18. package/dist/chat-admission.js +22 -0
  19. package/dist/chat-channel.js +1 -1
  20. package/dist/chat-delivery.js +0 -1
  21. package/dist/chat-follow.d.ts +17 -0
  22. package/dist/chat-follow.js +32 -0
  23. package/dist/chat-messages.js +0 -1
  24. package/dist/chat-settings-page.d.ts +23 -0
  25. package/dist/chat-settings-page.js +41 -0
  26. package/dist/check-access.d.ts +54 -0
  27. package/dist/check-access.js +80 -0
  28. package/dist/claim.js +3 -6
  29. package/dist/claude-print.d.ts +12 -0
  30. package/dist/claude-print.js +34 -0
  31. package/dist/cli-http.d.ts +0 -7
  32. package/dist/cli-http.js +0 -21
  33. package/dist/cli.d.ts +3 -10
  34. package/dist/cli.js +15 -24
  35. package/dist/coding-provider.js +2 -1
  36. package/dist/coding-workspace.js +1 -1
  37. package/dist/{team-cli.d.ts → connect-cli.d.ts} +5 -9
  38. package/dist/connect-cli.js +331 -0
  39. package/dist/containment.d.ts +0 -2
  40. package/dist/containment.js +2 -6
  41. package/dist/contracts/chat-actions.d.ts +0 -1
  42. package/dist/contracts/chat-content.d.ts +0 -4
  43. package/dist/contracts/chat-content.js +0 -2
  44. package/dist/contracts/chat-tables.d.ts +0 -1
  45. package/dist/contracts/cli.d.ts +0 -62
  46. package/dist/contracts/cli.js +0 -4
  47. package/dist/contracts/coding-workspace.js +1 -1
  48. package/dist/contracts/console-api.d.ts +1 -0
  49. package/dist/contracts/console-api.js +1 -0
  50. package/dist/contracts/contract.js +1 -1
  51. package/dist/contracts/flow-card.d.ts +0 -1
  52. package/dist/contracts/flow-propose.d.ts +0 -1
  53. package/dist/contracts/flow-send.js +1 -1
  54. package/dist/contracts/gateway-tools.d.ts +0 -1
  55. package/dist/contracts/integration-metadata.js +1 -1
  56. package/dist/contracts/lead-tools.d.ts +0 -1
  57. package/dist/contracts/memory-pass.d.ts +0 -1
  58. package/dist/contracts/memory-pass.js +1 -1
  59. package/dist/contracts/plan.js +1 -1
  60. package/dist/contracts/project-knowledge.js +1 -1
  61. package/dist/contracts/project-skills.d.ts +0 -1
  62. package/dist/contracts/proof.js +1 -1
  63. package/dist/contracts/recipes.js +1 -1
  64. package/dist/contracts/scope.js +1 -1
  65. package/dist/contracts/scout-report.js +1 -1
  66. package/dist/contracts/teams-callback.d.ts +0 -1
  67. package/dist/converse.d.ts +0 -6
  68. package/dist/converse.js +0 -19
  69. package/dist/daemon.js +3 -2
  70. package/dist/decision.d.ts +1 -3
  71. package/dist/deprecations.d.ts +12 -4
  72. package/dist/deprecations.js +27 -4
  73. package/dist/desktop-update-recovery.js +1 -1
  74. package/dist/desktop-update.js +3 -2
  75. package/dist/discord-chat.js +4 -15
  76. package/dist/discord-settings.d.ts +2 -6
  77. package/dist/discord-settings.js +10 -38
  78. package/dist/discord.js +12 -23
  79. package/dist/evidence.d.ts +0 -1
  80. package/dist/evidence.js +0 -3
  81. package/dist/exec.d.ts +0 -9
  82. package/dist/exec.js +3 -81
  83. package/dist/flow-actions.js +0 -3
  84. package/dist/flow-draft.d.ts +1 -3
  85. package/dist/flow-draft.js +15 -28
  86. package/dist/flow-scripts.d.ts +1 -8
  87. package/dist/flow-scripts.js +0 -13
  88. package/dist/flow-send.js +1 -1
  89. package/dist/flow-share.d.ts +0 -2
  90. package/dist/flow-share.js +1 -6
  91. package/dist/flow-steps.js +1 -1
  92. package/dist/flow-triggers.d.ts +0 -1
  93. package/dist/flow-triggers.js +1 -2
  94. package/dist/flow-unattended.js +1 -12
  95. package/dist/flows.js +1 -1
  96. package/dist/health-cli.d.ts +2 -2
  97. package/dist/health-cli.js +1 -1
  98. package/dist/invoke.d.ts +39 -5
  99. package/dist/invoke.js +181 -24
  100. package/dist/is-record.d.ts +2 -0
  101. package/dist/is-record.js +2 -0
  102. package/dist/lead-doors.d.ts +1 -5
  103. package/dist/lead-doors.js +0 -4
  104. package/dist/lead-tools.js +3 -6
  105. package/dist/live-detail.d.ts +0 -1
  106. package/dist/live-detail.js +0 -6
  107. package/dist/live-terminal.js +2 -1
  108. package/dist/live.d.ts +0 -1
  109. package/dist/live.js +1 -2
  110. package/dist/mcp-core.js +0 -2
  111. package/dist/memory-pass.js +1 -1
  112. package/dist/model-catalog.js +1 -1
  113. package/dist/modes.js +1 -12
  114. package/dist/operate.d.ts +4 -0
  115. package/dist/operate.js +456 -750
  116. package/dist/password-bearer.d.ts +0 -2
  117. package/dist/password-bearer.js +0 -4
  118. package/dist/peek-cli.js +2 -1
  119. package/dist/peek.d.ts +2 -26
  120. package/dist/peek.js +2 -168
  121. package/dist/phase-routing.d.ts +0 -4
  122. package/dist/phase-routing.js +2 -21
  123. package/dist/planner-source.d.ts +2 -1
  124. package/dist/planner-source.js +2 -12
  125. package/dist/planner.js +2 -14
  126. package/dist/process-tree.d.ts +0 -2
  127. package/dist/process-tree.js +0 -10
  128. package/dist/project-delete-ui.d.ts +16 -0
  129. package/dist/project-delete-ui.js +60 -1
  130. package/dist/project-delete.d.ts +0 -2
  131. package/dist/project-delete.js +1 -3
  132. package/dist/project-learning.d.ts +0 -14
  133. package/dist/project-skills.d.ts +0 -1
  134. package/dist/project-skills.js +0 -14
  135. package/dist/project-tools.js +2 -10
  136. package/dist/provider.d.ts +0 -17
  137. package/dist/provider.js +0 -26
  138. package/dist/pulls.d.ts +0 -2
  139. package/dist/pulls.js +0 -9
  140. package/dist/release-adapters.d.ts +3 -0
  141. package/dist/release-adapters.js +15 -5
  142. package/dist/remote-exec.d.ts +3 -2
  143. package/dist/remote-exec.js +3 -2
  144. package/dist/repos.d.ts +0 -5
  145. package/dist/repos.js +2 -10
  146. package/dist/repository-context.d.ts +0 -2
  147. package/dist/repository-context.js +0 -4
  148. package/dist/restart-certification.d.ts +0 -2
  149. package/dist/restart-certification.js +1 -5
  150. package/dist/result-follow-ups.d.ts +1 -4
  151. package/dist/result-follow-ups.js +11 -21
  152. package/dist/result-review.d.ts +0 -2
  153. package/dist/result-review.js +0 -2
  154. package/dist/retention.d.ts +0 -1
  155. package/dist/retention.js +0 -1
  156. package/dist/review-context.d.ts +0 -1
  157. package/dist/review-context.js +1 -12
  158. package/dist/reviewer.d.ts +0 -16
  159. package/dist/reviewer.js +0 -18
  160. package/dist/run-closeout.js +2 -1
  161. package/dist/scope.d.ts +9 -0
  162. package/dist/scope.js +13 -18
  163. package/dist/scout-browser.js +2 -1
  164. package/dist/serve.js +11 -2
  165. package/dist/server/console.css +5 -133
  166. package/dist/server/flows.js +2 -2
  167. package/dist/server/http.d.ts +4 -0
  168. package/dist/server/http.js +5 -0
  169. package/dist/server/pages.js +2 -2
  170. package/dist/server/people-tokens.js +3 -4
  171. package/dist/server/remote.js +1 -6
  172. package/dist/server/render-chat.d.ts +9 -2
  173. package/dist/server/render-chat.js +53 -7
  174. package/dist/server/render-results.d.ts +3 -6
  175. package/dist/server/render-results.js +4 -9
  176. package/dist/server/render-tasks.js +6 -6
  177. package/dist/server/route-table.d.ts +0 -4
  178. package/dist/server/route-table.js +1 -6
  179. package/dist/server/settings.js +60 -23
  180. package/dist/server/task-views.js +1 -1
  181. package/dist/server/tasks.js +5 -3
  182. package/dist/setup-guide.d.ts +2 -2
  183. package/dist/skills.d.ts +0 -6
  184. package/dist/skills.js +0 -8
  185. package/dist/slack-chat.d.ts +1 -7
  186. package/dist/slack-chat.js +5 -18
  187. package/dist/slack-settings.d.ts +2 -6
  188. package/dist/slack-settings.js +10 -38
  189. package/dist/slack.js +12 -23
  190. package/dist/sleep.d.ts +2 -0
  191. package/dist/sleep.js +2 -0
  192. package/dist/store.d.ts +29 -75
  193. package/dist/store.js +89 -180
  194. package/dist/structured-output.d.ts +0 -1
  195. package/dist/structured-output.js +0 -1
  196. package/dist/subagent-work.d.ts +0 -2
  197. package/dist/subagent-work.js +0 -2
  198. package/dist/subagents.d.ts +1 -3
  199. package/dist/subagents.js +20 -33
  200. package/dist/subscription-chat.d.ts +1 -3
  201. package/dist/subscription-chat.js +2 -3
  202. package/dist/surface.js +2 -1
  203. package/dist/task-sizing.d.ts +1 -4
  204. package/dist/task-sizing.js +3 -10
  205. package/dist/teams-chat.js +4 -11
  206. package/dist/teams.js +12 -19
  207. package/dist/telegram-lead.js +2 -2
  208. package/dist/telegram.js +1 -1
  209. package/dist/token-notices.d.ts +0 -11
  210. package/dist/token-notices.js +1 -5
  211. package/dist/tool-secret-store.d.ts +0 -3
  212. package/dist/tool-secret-store.js +2 -11
  213. package/dist/toolroll-update.d.ts +0 -2
  214. package/dist/toolroll-update.js +2 -2
  215. package/dist/verification-evidence.d.ts +0 -1
  216. package/dist/verification-evidence.js +0 -1
  217. package/dist/webhooks.d.ts +0 -1
  218. package/dist/webhooks.js +0 -1
  219. package/dist/workspace-ui.d.ts +0 -8
  220. package/dist/workspace-ui.js +1 -6
  221. package/package.json +1 -1
  222. package/dist/contracts/native-census.d.ts +0 -42
  223. package/dist/contracts/native-census.js +0 -36
  224. package/dist/process-recovery-anchor.d.ts +0 -75
  225. package/dist/process-recovery-anchor.js +0 -188
  226. package/dist/process-recovery-coalition.d.ts +0 -25
  227. package/dist/process-recovery-coalition.js +0 -105
  228. package/dist/process-recovery-eligibility.d.ts +0 -179
  229. package/dist/process-recovery-eligibility.js +0 -174
  230. package/dist/process-recovery-native.d.ts +0 -161
  231. package/dist/process-recovery-native.js +0 -595
  232. package/dist/process-recovery-provenance.d.ts +0 -163
  233. package/dist/process-recovery-provenance.js +0 -292
  234. package/dist/process-recovery-services.d.ts +0 -56
  235. package/dist/process-recovery-services.js +0 -191
  236. package/dist/process-recovery-settlement.d.ts +0 -32
  237. package/dist/process-recovery-settlement.js +0 -133
  238. package/dist/process-recovery.d.ts +0 -41
  239. package/dist/process-recovery.js +0 -91
  240. package/dist/session-brief.d.ts +0 -8
  241. package/dist/session-brief.js +0 -24
  242. package/dist/session-cli.d.ts +0 -42
  243. package/dist/session-cli.js +0 -271
  244. package/dist/session-contract.d.ts +0 -183
  245. package/dist/session-contract.js +0 -122
  246. package/dist/session-remote.d.ts +0 -19
  247. package/dist/session-remote.js +0 -86
  248. package/dist/session-service.d.ts +0 -22
  249. package/dist/session-service.js +0 -157
  250. package/dist/team-cli.js +0 -692
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.69 — 2026-10-23
4
+
5
+ - **Choose how far a project's checks reach.** Settings → Project has a new
6
+ Check access section, and `toolroll project check-access --repo <p>` shows
7
+ or sets the same thing. **Sandboxed** (the default) runs a project's setup
8
+ and check commands in the plane's fence, as before. **Not sandboxed** runs
9
+ them with your full access, as before 0.9.66: for a project whose checks
10
+ must create sandboxes themselves, such as Toolroll's own.
11
+ - Widening takes an instance operator with their password typed now (a
12
+ remembered sign-in or a single sign-on recheck doesn't count). Any approver
13
+ for the project can narrow it back. Each change is in the ledger.
14
+ - The level is part of what you approve. A scope seals it, the approval card
15
+ says "Checks: not sandboxed", and tasks already approved keep the level
16
+ they were approved with. Each run records the level its checks actually ran
17
+ at, and the result page says when they ran without a sandbox. Later checks
18
+ of a saved result never run wider than that run did. A mode, a grant or a
19
+ chat confirmation can't approve a wider level; only a person's password can.
20
+ - **A third level, "Sandboxed, with model and GitHub sign-ins", is built but
21
+ off.** It would let a check read your Claude, Codex and GitHub sign-in files.
22
+ Reading those files safely needs a way to open a file that a running build
23
+ can't redirect partway (Node can't do that yet), so the option is shown
24
+ disabled with the reason. Choosing it is refused, and a level saved before
25
+ runs sandboxed and says so.
26
+ - **The fence holds your home configuration by name.** `~/.codex`,
27
+ `~/.config/gh`, `~/.ssh` and the rest are denied whether or not they exist
28
+ when a build starts, so a build can't create one or swap it for a link
29
+ later. The folders above them can't be renamed or made during a build.
30
+ - A check's temporary folder on a Mac is now short (under `/private/tmp`), so
31
+ tests that open a unix socket there work inside the fence. Other launches'
32
+ folders stay out of reach.
33
+ - With no OS sandbox on the machine and unconfined running allowed, the
34
+ settings page and `project check-access` say checks run without a sandbox,
35
+ whatever level is saved.
36
+
37
+ ## 0.9.68 — 2026-10-23
38
+
39
+ - **The lead chat is redesigned.** Your messages sit in grey bubbles and the
40
+ lead's replies read as plain text, with code blocks. Its work folds into one
41
+ "Worked …" line, live steps show a spinner and then a check, and anything
42
+ waiting on you carries a "Needs you" chip. The message box is one row that
43
+ grows as you type, with a send arrow (Queue while a reply runs).
44
+ - **Comment on a line of a change.** In a result's Changes tab, a pin on any
45
+ line opens a one-line comment. Enter sends it to the lead as its next
46
+ message, naming the file, line and run and quoting the line. The thread
47
+ shows it as a reference, the quoted line, and your words. On a phone the
48
+ result opens as a sheet over the chat.
49
+ - **A fenced check can build bundles from the shared dependencies.** On a Mac
50
+ the sandbox let a build look up the linked copy's folder but not list it,
51
+ so esbuild couldn't find node_modules ("could not resolve react"). That one
52
+ folder may now be listed; nothing in it is readable beyond node_modules.
53
+ The console's own browser build also resolves through the checkout's links.
54
+ - The release check passes inside the plane's macOS check sandbox: tests that
55
+ need to create a sandbox or watch other processes skip there and still run
56
+ in CI, and the browser journeys' throwaway planes run their agents inside
57
+ the check's own sandbox.
58
+ - A Claude build no longer refuses to start when a sandbox around Toolroll
59
+ hides your Claude settings file: it signs in from the keychain as before,
60
+ and a sign-in that needed the file still fails quickly and says so.
61
+ - The test suite reads its own home and temp folders, never yours, so it runs
62
+ the same on your machine and inside a check's sandbox. Tests that need a
63
+ socket path shorter than any folder the sandbox allows, and the one browser
64
+ journey that resumes a saved Claude session, skip inside the sandbox and
65
+ still run in CI.
66
+ - `toolroll release` no longer stops at merge when GitHub has already deleted
67
+ the merged branch: it looks a few times, 2 seconds apart, before calling the
68
+ delete a failure (0.9.66 and 0.9.67 both stopped there).
69
+ - Leaner code, with every feature kept (about 5,600 fewer lines). The
70
+ deprecated team and native session commands are gone, and `chat` and
71
+ `brief` without `--local` refuse when a central profile is saved.
72
+
3
73
  ## 0.9.67 — 2026-10-23
4
74
 
5
75
  - **A project's checks read their own dependencies again.** 0.9.66 began
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "./is-record.js";
1
2
  /**
2
3
  * The Agent Client Protocol, from the plane's side (docs/plans/acp-adapter.md).
3
4
  *
@@ -37,9 +38,6 @@ const TEXT_CAP = 2048;
37
38
  const USAGE_CAP = 8 * 1024;
38
39
  const PROTOCOL_VERSION = 1;
39
40
  const STOP_REASONS = new Set(["end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"]);
40
- function isRecord(value) {
41
- return typeof value === "object" && value !== null && !Array.isArray(value);
42
- }
43
41
  function bounded(text, cap) {
44
42
  return text.length <= cap ? text : text.slice(0, cap);
45
43
  }
@@ -1,6 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
2
  export type AgentTurn = "busy" | "idle" | "crashed";
3
- export declare const AGENT_TURNS: readonly AgentTurn[];
4
3
  export type AgentSaid = "working" | "blocked" | "done";
5
4
  /** A busy run whose transcript and activity are both this old is Stuck. */
6
5
  export declare const STUCK_AFTER_MS: number;
@@ -27,7 +27,6 @@
27
27
  * Display state, never evidence: nothing here changes a run's outcome.
28
28
  */
29
29
  import { randomBytes } from "node:crypto";
30
- export const AGENT_TURNS = ["busy", "idle", "crashed"];
31
30
  /** A busy run whose transcript and activity are both this old is Stuck. */
32
31
  export const STUCK_AFTER_MS = 4 * 60_000;
33
32
  /** With a tool call still open (a long test run is silent), it is Stuck only after this long. */
@@ -1,6 +1,9 @@
1
1
  import type { LoopbackMode } from "./egress.js";
2
2
  /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
3
3
  export declare const BRIDGE_DIR = "bridge-secrets";
4
+ /** Where a Linux sign-ins check's copies of its sign-in files live while it runs (check-access.ts): one folder per launch,
5
+ * `c-<pid>-…`, beside the database; fenced from every build by name, like the bridges. */
6
+ export declare const SIGN_IN_COPIES_DIR = "sign-in-copies";
4
7
  /** How a launch was fenced, as recorded on the run and said on the task. */
5
8
  export type FenceMethod = "codex-profile" | "macos-sandbox" | "linux-bubblewrap" | "claude-rules" | "none";
6
9
  /** Only the plane's recorded repository may supply an own-Git exemption. */
@@ -38,6 +41,9 @@ export type TaskBoundary = {
38
41
  fileTools: string[];
39
42
  readOnly: string[];
40
43
  lookup: string[];
44
+ /** Every home configuration name the launch may not reach, by name, whether or not it exists now (macOS: Seatbelt
45
+ * denies a path that isn't there yet, so a build can't create it, or later replace it with a link). */
46
+ homeNames?: string[];
41
47
  };
42
48
  /**
43
49
  * Everything outside a task's own folders that a build could otherwise
@@ -54,14 +60,24 @@ export declare function taskBoundary(options: {
54
60
  binding?: WorktreeBinding | null;
55
61
  }): TaskBoundary;
56
62
  /** Where launches keep their private folders (the system temp folder), and this launch's own tool folder. */
63
+ /** Where launches keep their private folders: `root` (the system temp folder) and, on a Mac, `also` the short shared one
64
+ * a check's launch folder is made in (/private/tmp, so its unix sockets fit); `own` is this launch's. */
57
65
  export type RunFolders = {
58
66
  root: string;
59
67
  own: string | null;
68
+ also?: string[];
60
69
  };
61
70
  /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
62
71
  * network bridge (egress-bridge.ts). */
63
72
  export declare const RUN_FOLDER_PREFIXES: readonly ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
64
73
  export declare function runFolders(own: string | null, root?: string): RunFolders;
74
+ /** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
75
+ * path (about 104 bytes); every other launch folder there is denied by pattern, as under the system temp folder. */
76
+ export declare const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
77
+ /** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder (unchanged). */
78
+ export declare const checkLaunchRoot: () => string;
79
+ /** Every root launch folders are made under (the system temp folder, and on a Mac the short shared one). */
80
+ export declare const runRoots: (folders: RunFolders) => string[];
65
81
  /**
66
82
  * What a fence gives back beside its denied paths. Each is narrow and none
67
83
  * ever writes: `readOnly` is read, never written (other tasks' git
@@ -81,6 +97,11 @@ export type FenceGrants = {
81
97
  pool?: CheckoutPool;
82
98
  network?: NetworkFence;
83
99
  writes?: WriteAllowlist;
100
+ /** macOS: the home configuration names denied by name, present or not (TaskBoundary.homeNames). */
101
+ homeNames?: string[];
102
+ /** A `sign-ins` check's exact sign-in files (read) and its checkout's own Claude transcript folder (written):
103
+ * check-access.ts, signInGrants. Bound back over the home fence by path, never a pattern or a parent folder. */
104
+ signIns?: SignInGrants;
84
105
  };
85
106
  export { isWritesMode, WRITES_DEFAULT, type WritesMode } from "./writes-mode.js";
86
107
  /**
@@ -328,3 +349,40 @@ export declare const FENCE_SUMMARY: readonly {
328
349
  }[];
329
350
  /** FENCE_SUMMARY as README's markdown table. */
330
351
  export declare function fenceSummaryMarkdown(): string;
352
+ /** What a `sign-ins` check reads and writes beyond its fence (check-access.ts). */
353
+ export type SignInGrants = {
354
+ read: string[];
355
+ transcripts: string[];
356
+ copies?: Record<string, string>;
357
+ };
358
+ /**
359
+ * A `sign-ins` check's grants: each sign-in file only as the real regular file it is (never a link, nor reached
360
+ * through one), and on a Mac the checkout's own Claude transcript folder (made when absent, only as the real folder it
361
+ * is), whose session files alone are written so a session the check starts can be resumed. Nothing else under
362
+ * ~/.claude, ~/.codex or ~/.config is granted.
363
+ */
364
+ export declare function signInGrants(home: string, checkout: string, platform?: NodeJS.Platform): SignInGrants;
365
+ /**
366
+ * A sign-in file's bytes, read only as the real regular file it is: its folder reached through no link (its real path
367
+ * is its own), and the file itself opened without following a link and checked as a regular file through what was
368
+ * opened, so a link swapped in at the file's own name is never read. Null for anything else.
369
+ *
370
+ * Its folders are still resolved by name (Node has no openat), so a folder above it swapped for a link between the
371
+ * checks and the open would be followed. That is detected after the fact: every folder from / down to the file's own
372
+ * is looked at (no link, the same device and inode) before the open and again after it, the file's real path is
373
+ * checked again, and the name must still be the very file that was opened. Any difference reads nothing.
374
+ *
375
+ * Residual risk: a swap made and undone entirely between the two looks isn't seen. On a Mac every build's and check's
376
+ * fence denies the home configuration names (~/.codex among them) by name, present or not, so no build or sandboxed
377
+ * check can make that swap; only a check the operator set to Not sandboxed, or the person themselves, still could.
378
+ * `afterOpen` is for tests: it runs between the open and the second look.
379
+ */
380
+ export declare function readGrantedFile(path: string, seams?: {
381
+ afterOpen?: () => void;
382
+ }): Buffer | null;
383
+ /**
384
+ * The sign-in copies folder in a state folder (check-access.ts; Linux checks only, and sign-ins are off for now), or null:
385
+ * only the real folder Toolroll made there, owned by this user, mode 0700, reached through no link. Anything else (a
386
+ * link planted at that name, someone else's folder, one others may open) is never used: nothing is copied into it.
387
+ */
388
+ export declare function signInCopiesRoot(state: string, uid?: number | undefined): string | null;
@@ -44,7 +44,7 @@ import { INSTANCE_DIR } from "./console-instance.js";
44
44
  import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
45
45
  import { spawnSync } from "node:child_process";
46
46
  import { createHash } from "node:crypto";
47
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
47
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
48
48
  import { homedir, tmpdir } from "node:os";
49
49
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
50
50
  import { readMonitoring } from "./monitoring-settings.js";
@@ -52,6 +52,9 @@ import { existingFolders } from "./names.js";
52
52
  import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
53
53
  /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
54
54
  export const BRIDGE_DIR = "bridge-secrets";
55
+ /** Where a Linux sign-ins check's copies of its sign-in files live while it runs (check-access.ts): one folder per launch,
56
+ * `c-<pid>-…`, beside the database; fenced from every build by name, like the bridges. */
57
+ export const SIGN_IN_COPIES_DIR = "sign-in-copies";
55
58
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
56
59
  const SENSITIVE_NAME = /(^up-login\.txt$|token|secret|login|password|credential|vapid|keys?\b|\.pem$|\.key$|^backups$|^evidence$|^remote$|^sign-in\.json$|^monitoring\.json$)/i;
57
60
  function real(path) {
@@ -136,6 +139,8 @@ export function agentFence(options) {
136
139
  let runFolder = null;
137
140
  /** The bridges' folder, likewise. */
138
141
  let bridgeFolder = null;
142
+ /** The sign-in copies folder, likewise. */
143
+ let copiesFolder = null;
139
144
  const home = real(options.home ?? homedir());
140
145
  const worktree = options.worktree === null ? null : real(options.worktree);
141
146
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
@@ -168,6 +173,23 @@ export function agentFence(options) {
168
173
  // refuses a root that isn't a private folder: egress-bridge.ts).
169
174
  bridgeFolder = bridges;
170
175
  fence.add(bridges);
176
+ // The sign-in copies (check-access.ts): made before any build starts and fenced from every build, by its own name as
177
+ // well as where it leads (as the bridges and the run folder are), whether or not the state folder is Toolroll's own.
178
+ // A Linux check binds its own copy from outside its sandbox, so nothing here is granted back. Never swept: a copy a
179
+ // launch that died left behind stays in this fenced, private folder (walking a folder a build might swap is the
180
+ // greater risk); a root that isn't Toolroll's private folder is never used (signInCopiesRoot).
181
+ const copies = join(state, SIGN_IN_COPIES_DIR);
182
+ try {
183
+ lstatSync(copies);
184
+ }
185
+ catch {
186
+ try {
187
+ mkdirSync(copies, { mode: 0o700 });
188
+ }
189
+ catch { /* fenced below either way */ }
190
+ }
191
+ copiesFolder = copies;
192
+ fence.add(copies);
171
193
  runFolder = join(state, INSTANCE_DIR);
172
194
  const present = () => { try {
173
195
  lstatSync(runFolder);
@@ -227,12 +249,12 @@ export function agentFence(options) {
227
249
  fence.add(path);
228
250
  for (const path of options.boundary?.protectedShell ?? [])
229
251
  fence.add(real(path));
230
- const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
252
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]));
231
253
  for (const path of options.boundary?.shell ?? [])
232
254
  fence.add(path);
233
255
  const binding = options.binding;
234
256
  const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
235
- return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
257
+ return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder || path === copiesFolder ? [path, real(path)] : [real(path)]))].filter(path => {
236
258
  if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
237
259
  return true;
238
260
  console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
@@ -365,15 +387,28 @@ export function taskBoundary(options) {
365
387
  }
366
388
  }
367
389
  const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
368
- return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
390
+ // By name too, present or not: a build started while ~/.codex is absent must never make it (and later swap it for a
391
+ // link while a sign-ins check reads through it). Only Seatbelt uses these (macosFenceProfile); bubblewrap can only
392
+ // mask what exists, and Codex's own sandbox takes existing paths.
393
+ const homeNames = HOME_CONFIGURATION.filter(name => !own.has(name)).map(name => join(home, name)).sort();
394
+ return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort(), homeNames };
369
395
  }
370
396
  /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
371
397
  * network bridge (egress-bridge.ts). */
372
398
  export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
373
- export function runFolders(own, root = tmpdir()) {
374
- return { root: real(root), own: own === null ? null : real(own) };
399
+ export function runFolders(own, root) {
400
+ // The system's own roots (not one a caller names): on a Mac, checks' launch folders are under the short one too.
401
+ const also = root === undefined && process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? [CHECK_LAUNCH_ROOT_MACOS] : [];
402
+ return { root: real(root ?? tmpdir()), own: own === null ? null : real(own), ...(also.length === 0 ? {} : { also }) };
375
403
  }
376
- /** Every other launch's private folder there now. */
404
+ /** Where a check's launch folder is made on a Mac: a short shared folder, so its TMPDIR leaves room for a unix socket
405
+ * path (about 104 bytes); every other launch folder there is denied by pattern, as under the system temp folder. */
406
+ export const CHECK_LAUNCH_ROOT_MACOS = "/private/tmp";
407
+ /** The folder a check's launch folder is made in: on a Mac the short one, elsewhere the system temp folder (unchanged). */
408
+ export const checkLaunchRoot = () => process.platform === "darwin" && existsSync(CHECK_LAUNCH_ROOT_MACOS) ? CHECK_LAUNCH_ROOT_MACOS : tmpdir();
409
+ /** Every other launch's private folder in the system temp folder now, each only as the real folder it is (a link planted
410
+ * there is never followed, nor fenced in its target's place) and this user's own. A shared root (`also`, /private/tmp on
411
+ * a Mac) is never listed: anyone may plant entries there, so it is fenced by name pattern alone. */
377
412
  function otherRunFolders(folders) {
378
413
  let entries = [];
379
414
  try {
@@ -382,8 +417,21 @@ function otherRunFolders(folders) {
382
417
  catch {
383
418
  entries = [];
384
419
  }
385
- return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
420
+ const uid = process.getuid?.();
421
+ return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => {
422
+ if (path === folders.own)
423
+ return false;
424
+ try {
425
+ const held = lstatSync(path);
426
+ return held.isDirectory() && !held.isSymbolicLink() && (uid === undefined || held.uid === uid);
427
+ }
428
+ catch {
429
+ return false;
430
+ }
431
+ });
386
432
  }
433
+ /** Every root launch folders are made under (the system temp folder, and on a Mac the short shared one). */
434
+ export const runRoots = (folders) => [folders.root, ...(folders.also ?? [])];
387
435
  export { isWritesMode, WRITES_DEFAULT } from "./writes-mode.js";
388
436
  /** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
389
437
  * (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
@@ -618,6 +666,7 @@ export function fenceGrants(options) {
618
666
  }
619
667
  const pool = options.poolRoots === undefined ? undefined : checkoutPool({ roots: options.poolRoots, worktree: options.worktree, binding: options.binding ?? null, ...(options.home === undefined ? {} : { home: options.home }) });
620
668
  return { readOnly: [...options.boundary?.readOnly ?? []], readable, lookup: [...new Set(lookup)].sort(), ...(options.runFolders === undefined ? {} : { runFolders: options.runFolders }),
669
+ ...(options.boundary?.homeNames === undefined || options.boundary.homeNames.length === 0 ? {} : { homeNames: [...options.boundary.homeNames] }),
621
670
  ...(pool === undefined || pool.deny.length === 0 ? {} : { pool }) };
622
671
  }
623
672
  /** The setup a shared copy was installed by (its ready note), or null. */
@@ -668,8 +717,9 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
668
717
  // Codex's harness reads its own launch files outside the command sandbox.
669
718
  // Deny the namespace, including folders created after this policy was built.
670
719
  if (grants.runFolders !== undefined) {
671
- for (const prefix of RUN_FOLDER_PREFIXES) {
672
- deny.push(join(grants.runFolders.root, `${prefix}*`), join(grants.runFolders.root, `${prefix}*`, "**"));
720
+ for (const root of runRoots(grants.runFolders)) {
721
+ for (const prefix of RUN_FOLDER_PREFIXES)
722
+ deny.push(join(root, `${prefix}*`), join(root, `${prefix}*`, "**"));
673
723
  }
674
724
  }
675
725
  // Launch folders can already be in the base fence when invoke appends them.
@@ -701,11 +751,11 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
701
751
  for (const path of held.write)
702
752
  access.set(path, "write");
703
753
  }
704
- // Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
705
- // stays unreplaceable, not only its target.
754
+ // Denies are keyed where they resolve; the console's run folder and the sign-in copies folder are also denied by their
755
+ // own pathname, so a link there stays unreplaceable, not only its target.
706
756
  for (const path of deny) {
707
757
  access.set(real(path), "deny");
708
- if (basename(path) === INSTANCE_DIR)
758
+ if (basename(path) === INSTANCE_DIR || basename(path) === SIGN_IN_COPIES_DIR)
709
759
  access.set(path, "deny");
710
760
  }
711
761
  const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
@@ -859,7 +909,16 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
859
909
  const paths = (filter, list) => list.map(path => `(${filter} ${seatbelt(path)})`).join(" ");
860
910
  const folders = grants.runFolders;
861
911
  const pool = grants.pool;
862
- const above = ownedAncestors([...fence, ...grants.readOnly, ...pool?.deny ?? []], [folders?.root ?? real(tmpdir())]);
912
+ const homeNames = grants.homeNames ?? [];
913
+ const above = ownedAncestors([...fence, ...grants.readOnly, ...homeNames, ...pool?.deny ?? []], folders === undefined ? [real(tmpdir())] : runRoots(folders));
914
+ // A home name's folder that isn't there yet (~/.config for ~/.config/gh): made by the build, it could be a link to
915
+ // anywhere and carry the name out of its deny, so nothing may make it while the build runs.
916
+ const missingAbove = [...new Set(homeNames.flatMap(name => {
917
+ const out = [];
918
+ for (let folder = dirname(name); folder !== dirname(folder) && !existsSync(folder); folder = dirname(folder))
919
+ out.push(folder);
920
+ return out;
921
+ }))].sort();
863
922
  return [
864
923
  "(version 1)",
865
924
  "(allow default)",
@@ -878,12 +937,29 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
878
937
  ]),
879
938
  ...(pool === undefined || pool.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", pool.lookup)})`]),
880
939
  ...(fence.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", fence)})`]),
940
+ // The home configuration by name, there or not: nothing may create, read, replace or write one (the sign-ins grants
941
+ // after this give back only their exact files).
942
+ ...(homeNames.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", homeNames)})`]),
943
+ ...(missingAbove.length === 0 ? [] : [`(deny file-write* ${paths("literal", missingAbove)})`]),
881
944
  ...(grants.readOnly.length === 0 ? [] : [`(deny file-write* ${paths("subpath", grants.readOnly)})`]),
882
- ...(folders === undefined ? [] : [`(deny file-read* file-write* (regex #"^${seatbeltRegex(folders.root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`]),
945
+ ...(folders === undefined ? [] : runRoots(folders).map(root => `(deny file-read* file-write* (regex #"^${seatbeltRegex(root)}/(${RUN_FOLDER_PREFIXES.map(seatbeltRegex).join("|")})"))`)),
883
946
  ...(grants.lookup.length === 0 ? [] : [`(allow file-read-metadata ${paths("literal", grants.lookup)})`]),
884
947
  // Denied whole above, granted back narrowly here (a later rule wins): an entry made after launch beside it stays denied.
885
948
  ...(grants.readable.length === 0 ? [] : [`(allow file-read* file-test-existence ${paths("subpath", grants.readable)})`]),
886
- ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
949
+ // A sign-ins check (check-access.ts): its exact sign-in files read, its own transcript folder written; nothing else
950
+ // of the home configuration comes back.
951
+ ...(grants.signIns === undefined || grants.signIns.read.length === 0 ? [] : [`(allow file-read* ${paths("literal", grants.signIns.read)})`]),
952
+ // Its own transcript folder: only the session files directly in it (a session can be resumed). Nothing else there —
953
+ // never memory/ or any folder Claude would load into a later real session — comes back.
954
+ ...(grants.signIns === undefined || grants.signIns.transcripts.length === 0 ? [] : [
955
+ `(allow file-read-data file-read-metadata ${paths("literal", grants.signIns.transcripts)})`,
956
+ `(allow file-read* file-write* ${grants.signIns.transcripts.map(folder => `(regex #"^${seatbeltRegex(folder)}/[^/]+\\.jsonl$")`).join(" ")})`,
957
+ ]),
958
+ // Each readable copy's own folder can be listed, never read: bundlers such as esbuild find node_modules by listing
959
+ // its parent, which a lookup alone refuses (the Linux and Codex fences list it as read-only already).
960
+ ...(grants.readable.length === 0 ? [] : [`(allow file-read-data ${paths("literal", [...new Set(grants.readable.map(one => dirname(one)))])})`]),
961
+ // Its own launch folder: what is in it, never the folder itself (it can't be renamed, removed or replaced by a link).
962
+ ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`, `(deny file-write* ${paths("literal", [folders.own])})`]),
887
963
  '(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
888
964
  "(deny process-info-pidinfo)",
889
965
  "(allow process-info-pidinfo (target same-sandbox))",
@@ -982,8 +1058,8 @@ const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
982
1058
  */
983
1059
  function linuxGrants(fence, grants) {
984
1060
  const folders = grants.runFolders;
985
- const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
986
- && RUN_FOLDER_PREFIXES.some(prefix => relative(folders.root, path).split(sep)[0].startsWith(prefix));
1061
+ const launchFolder = (path) => folders !== undefined && path !== folders.own && !(folders.own != null && inside(path, folders.own))
1062
+ && runRoots(folders).some(root => inside(path, root) && RUN_FOLDER_PREFIXES.some(prefix => relative(root, path).split(sep)[0].startsWith(prefix)));
987
1063
  const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
988
1064
  const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
989
1065
  && !(readOnlyGrant && fence.some(one => inside(path, one)));
@@ -1010,6 +1086,14 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
1010
1086
  const pool = grants.pool?.deny ?? [];
1011
1087
  const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
1012
1088
  const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
1089
+ // A sign-ins check's exact files, bound back over the masks (validated when granted: signInGrants).
1090
+ const signIns = grants.signIns === undefined ? [] : [
1091
+ // Each from the launch's own copy (made from the validated, opened file), never the path itself: a link swapped in
1092
+ // after validation is never followed by the bind. A file with no copy isn't granted.
1093
+ ...grants.signIns.read.flatMap(path => { const copy = grants.signIns.copies?.[path]; return copy !== undefined && existsSync(copy) ? ["--ro-bind", copy, path] : []; }),
1094
+ // Bubblewrap can't bind by pattern: a session transcript folder is never given back writable on Linux (a check
1095
+ // there can't resume a Claude session; it can't plant anything a later session loads either).
1096
+ ];
1013
1097
  // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
1014
1098
  // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
1015
1099
  const isolated = network === null ? [] : [
@@ -1034,7 +1118,7 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
1034
1118
  const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
1035
1119
  const command = network === null ? [file, ...args]
1036
1120
  : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
1037
- return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...services, ...isolated, "--die-with-parent", "--", ...command] };
1121
+ return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...signIns, ...services, ...isolated, "--die-with-parent", "--", ...command] };
1038
1122
  }
1039
1123
  /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
1040
1124
  * engines), wherever they exist here. */
@@ -1161,3 +1245,114 @@ export const FENCE_SUMMARY = [
1161
1245
  export function fenceSummaryMarkdown() {
1162
1246
  return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
1163
1247
  }
1248
+ /** The sign-in files a `sign-ins` check reads, by fixed name under the person's home (never from the environment, so no
1249
+ * caller's CLAUDE_CONFIG_DIR or GH_CONFIG_DIR points a grant at .ssh): Claude's account and settings (and, on Linux,
1250
+ * its credentials file; on a Mac they are in the keychain), and gh's hosts and config. Codex reads copies in a folder
1251
+ * of the launch's own (invoke.ts), so nothing of ~/.codex is granted. */
1252
+ const SIGN_IN_FILES = (platform) => [
1253
+ ".claude.json", join(".claude", "settings.json"), ...(platform === "linux" ? [join(".claude", ".credentials.json")] : []),
1254
+ join(".config", "gh", "hosts.yml"), join(".config", "gh", "config.yml"),
1255
+ ];
1256
+ /**
1257
+ * A `sign-ins` check's grants: each sign-in file only as the real regular file it is (never a link, nor reached
1258
+ * through one), and on a Mac the checkout's own Claude transcript folder (made when absent, only as the real folder it
1259
+ * is), whose session files alone are written so a session the check starts can be resumed. Nothing else under
1260
+ * ~/.claude, ~/.codex or ~/.config is granted.
1261
+ */
1262
+ export function signInGrants(home, checkout, platform = process.platform) {
1263
+ const base = real(home);
1264
+ const read = SIGN_IN_FILES(platform).map(name => join(base, name)).filter(path => {
1265
+ try {
1266
+ const held = lstatSync(path);
1267
+ // One name only: a hard link made to another file (Toolroll's database, say) would be read through this grant.
1268
+ return held.isFile() && !held.isSymbolicLink() && held.nlink === 1 && realpathSync(path) === path;
1269
+ }
1270
+ catch {
1271
+ return false;
1272
+ }
1273
+ });
1274
+ const transcript = claudeProjectFolder(base, real(checkout));
1275
+ // A transcript folder Claude would name by prefix (a very long checkout path) isn't granted: never a prefix of others.
1276
+ const folder = transcript.byPrefix || !existsSync(join(base, ".claude")) ? null : grantFolder(transcript.path);
1277
+ return { read, transcripts: folder === null || platform !== "darwin" ? [] : [folder] };
1278
+ }
1279
+ /**
1280
+ * A sign-in file's bytes, read only as the real regular file it is: its folder reached through no link (its real path
1281
+ * is its own), and the file itself opened without following a link and checked as a regular file through what was
1282
+ * opened, so a link swapped in at the file's own name is never read. Null for anything else.
1283
+ *
1284
+ * Its folders are still resolved by name (Node has no openat), so a folder above it swapped for a link between the
1285
+ * checks and the open would be followed. That is detected after the fact: every folder from / down to the file's own
1286
+ * is looked at (no link, the same device and inode) before the open and again after it, the file's real path is
1287
+ * checked again, and the name must still be the very file that was opened. Any difference reads nothing.
1288
+ *
1289
+ * Residual risk: a swap made and undone entirely between the two looks isn't seen. On a Mac every build's and check's
1290
+ * fence denies the home configuration names (~/.codex among them) by name, present or not, so no build or sandboxed
1291
+ * check can make that swap; only a check the operator set to Not sandboxed, or the person themselves, still could.
1292
+ * `afterOpen` is for tests: it runs between the open and the second look.
1293
+ */
1294
+ export function readGrantedFile(path, seams = {}) {
1295
+ const folder = dirname(path);
1296
+ const chain = [];
1297
+ for (let at = folder;; at = dirname(at)) {
1298
+ chain.push(at);
1299
+ if (at === dirname(at))
1300
+ break;
1301
+ }
1302
+ const look = () => {
1303
+ const held = [];
1304
+ for (const at of chain) {
1305
+ const one = lstatSync(at);
1306
+ if (one.isSymbolicLink() || !one.isDirectory())
1307
+ return null;
1308
+ held.push({ dev: one.dev, ino: one.ino });
1309
+ }
1310
+ return held;
1311
+ };
1312
+ const same = (a, b) => a.length === b.length && a.every((one, index) => one.dev === b[index].dev && one.ino === b[index].ino);
1313
+ try {
1314
+ const before = look();
1315
+ if (before === null || realpathSync(folder) !== folder)
1316
+ return null;
1317
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1318
+ try {
1319
+ const held = fstatSync(fd);
1320
+ if (!held.isFile() || held.nlink !== 1)
1321
+ return null;
1322
+ seams.afterOpen?.();
1323
+ const after = look();
1324
+ if (after === null || !same(before, after) || realpathSync(folder) !== folder)
1325
+ return null;
1326
+ const named = lstatSync(path);
1327
+ if (named.isSymbolicLink() || named.dev !== held.dev || named.ino !== held.ino)
1328
+ return null;
1329
+ const bytes = readFileSync(fd);
1330
+ // And once more after the read: what was read must still be what the checked name holds.
1331
+ const last = look();
1332
+ return last !== null && same(before, last) ? bytes : null;
1333
+ }
1334
+ finally {
1335
+ closeSync(fd);
1336
+ }
1337
+ }
1338
+ catch {
1339
+ return null;
1340
+ }
1341
+ }
1342
+ /**
1343
+ * The sign-in copies folder in a state folder (check-access.ts; Linux checks only, and sign-ins are off for now), or null:
1344
+ * only the real folder Toolroll made there, owned by this user, mode 0700, reached through no link. Anything else (a
1345
+ * link planted at that name, someone else's folder, one others may open) is never used: nothing is copied into it.
1346
+ */
1347
+ export function signInCopiesRoot(state, uid = process.getuid?.()) {
1348
+ const root = join(state, SIGN_IN_COPIES_DIR);
1349
+ try {
1350
+ const held = lstatSync(root);
1351
+ if (held.isSymbolicLink() || !held.isDirectory() || (uid !== undefined && held.uid !== uid) || (held.mode & 0o777) !== 0o700)
1352
+ return null;
1353
+ return grantFolder(root, false) === root ? root : null;
1354
+ }
1355
+ catch {
1356
+ return null;
1357
+ }
1358
+ }
@@ -65,7 +65,7 @@ export type ProfileResolution = {
65
65
  export declare function resolveScopeProfile(store: Store, repo: string | null, ref: Pick<TaskRef, "agentProvider" | "agentModel"> | undefined, flags: PhaseFlags & {
66
66
  repairModel?: string | undefined;
67
67
  permissionMode?: UnattendedPermissionMode | undefined;
68
- }): ProfileResolution;
68
+ }, platform?: NodeJS.Platform): ProfileResolution;
69
69
  /**
70
70
  * The two candidate tiers the routing policy chooses between, per phase,
71
71
  * read from configuration ONLY — never inferred from a model's name:
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { PROJECT_NETWORK_DEFAULT, repositoryEcosystems, resolveNetwork } from "./egress.js";
23
23
  import { WRITES_DEFAULT } from "./writes-mode.js";
24
+ import { checkAccessHere } from "./check-access.js";
24
25
  import { isProviderId, validateSpec } from "./provider.js";
25
26
  import { ACP_LIMITS, CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
26
27
  import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
@@ -102,7 +103,7 @@ ref) {
102
103
  const valid = validateSpec(spec);
103
104
  return valid.ok ? { ok: true, spec, source: flags.model !== undefined ? "flag" : source } : { ok: false, problem: valid.problem };
104
105
  }
105
- export function resolveScopeProfile(store, repo, ref, flags) {
106
+ export function resolveScopeProfile(store, repo, ref, flags, platform = process.platform) {
106
107
  const build = resolvePhaseAgent(store, "build", repo, flags, ref);
107
108
  if (!build.ok)
108
109
  return { ok: false, reason: "unknown-provider", problem: build.problem };
@@ -199,7 +200,10 @@ export function resolveScopeProfile(store, repo, ref, flags) {
199
200
  const network = resolveNetwork(provider, repo === null ? PROJECT_NETWORK_DEFAULT : store.projectNetwork(repo), repo === null ? [] : repositoryEcosystems(repo));
200
201
  // Where the build may write (writes-mode.ts): the project's setting, sealed likewise.
201
202
  const writes = repo === null ? WRITES_DEFAULT : store.projectWrites(repo);
202
- return { ok: true, profile: { ...profile, network, writes }, provenance: { resolvedFrom: build.source, repairFrom } };
203
+ // How far its checks reach (check-access.ts): the project's setting as checks can get it (sign-ins, off for now,
204
+ // seal as sandboxed: an approval never claims what its checks won't get), sealed only when wider than sandboxed.
205
+ const checkAccess = repo === null ? "sandboxed" : checkAccessHere(store.projectCheckAccess(repo), platform);
206
+ return { ok: true, profile: { ...profile, network, writes, ...(checkAccess === "sandboxed" ? {} : { checkAccess }) }, provenance: { resolvedFrom: build.source, repairFrom } };
203
207
  }
204
208
  const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
205
209
  export function resolveRouteCandidates(store, repo, pins = {}) {