toolroll 0.9.68 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
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
+
3
37
  ## 0.9.68 — 2026-10-23
4
38
 
5
39
  - **The lead chat is redesigned.** Your messages sit in grey bubbles and the
@@ -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,15 +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)})`]),
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
+ ]),
886
958
  // Each readable copy's own folder can be listed, never read: bundlers such as esbuild find node_modules by listing
887
959
  // its parent, which a lookup alone refuses (the Linux and Codex fences list it as read-only already).
888
960
  ...(grants.readable.length === 0 ? [] : [`(allow file-read-data ${paths("literal", [...new Set(grants.readable.map(one => dirname(one)))])})`]),
889
- ...(folders?.own == null ? [] : [`(allow file-read* file-write* ${paths("subpath", [folders.own])})`]),
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])})`]),
890
963
  '(deny sysctl-read (sysctl-name-prefix "kern.procargs"))',
891
964
  "(deny process-info-pidinfo)",
892
965
  "(allow process-info-pidinfo (target same-sandbox))",
@@ -985,8 +1058,8 @@ const SECRET_SEGMENT = SPLIT_SECRET_PATTERNS.map(patternTest);
985
1058
  */
986
1059
  function linuxGrants(fence, grants) {
987
1060
  const folders = grants.runFolders;
988
- const launchFolder = (path) => folders !== undefined && inside(path, folders.root) && path !== folders.own && !(folders.own != null && inside(path, folders.own))
989
- && 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)));
990
1063
  const secretBelow = (path) => fence.some(one => inside(path, one) && relative(one, path).split(sep).some(segment => SECRET_SEGMENT.some(test => test.test(segment))));
991
1064
  const ok = (path, readOnlyGrant) => existsSync(path) && !fence.includes(path) && !launchFolder(path) && !secretBelow(path)
992
1065
  && !(readOnlyGrant && fence.some(one => inside(path, one)));
@@ -1013,6 +1086,14 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
1013
1086
  const pool = grants.pool?.deny ?? [];
1014
1087
  const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
1015
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
+ ];
1016
1097
  // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
1017
1098
  // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
1018
1099
  const isolated = network === null ? [] : [
@@ -1037,7 +1118,7 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
1037
1118
  const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
1038
1119
  const command = network === null ? [file, ...args]
1039
1120
  : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
1040
- 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] };
1041
1122
  }
1042
1123
  /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
1043
1124
  * engines), wherever they exist here. */
@@ -1164,3 +1245,114 @@ export const FENCE_SUMMARY = [
1164
1245
  export function fenceSummaryMarkdown() {
1165
1246
  return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
1166
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 = {}) {
@@ -1,24 +1,4 @@
1
- /** Batch checks: like a merge queue, for projects that run the full check after every build.
2
- *
3
- * With batching on (a per-project setting, off by default), a Full build doesn't run its check
4
- * inline. It asks for a batch check on its exact commit, and waits up to the window (10 minutes)
5
- * for another result of the same project and the same approved command. The batch pass then
6
- * merges the waiting results onto the recorded project base in a temporary, detached checkout
7
- * and runs the approved check once:
8
- *
9
- * - it passes: every result gets its own sealed receipt, "checked together with" the others,
10
- * naming the exact batch commit it tested;
11
- * - it fails: the batch is split in halves and each half checked, down to single results, so the
12
- * one that breaks it is found and the others still pass on their own;
13
- * - the results conflict when merged: each is checked on its own.
14
- *
15
- * Nothing is merged into a real branch. The batch commit exists only in the temporary checkout,
16
- * which is always removed; each result still lands on its own.
17
- *
18
- * The queue is the follow-up check ledger (result-follow-ups.ts), with `batch` requests: the same
19
- * claim, receipts, abandoned-check reading, status projection and Merge gate. The policy is an
20
- * append-only ledger entry, like the check level. */
21
- import { fencedCheckoutCommands } from "./invoke.js";
1
+ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
22
2
  import { mkdtempSync, rmSync } from "node:fs";
23
3
  import { tmpdir } from "node:os";
24
4
  import { join } from "node:path";
@@ -67,7 +47,8 @@ export async function runBatchChecks(store, root, deps = {}) {
67
47
  finish("The approved check changed while it waited. Run checks to check it now.");
68
48
  continue;
69
49
  }
70
- const key = `${ref.repo}\u0000${one.digest}`;
50
+ // A cohort shares one check level: results whose own checks ran at different levels are checked apart (check-access.ts).
51
+ const key = `${ref.repo}\u0000${one.digest}\u0000${store.runCheckAccess(one.runId)}`;
71
52
  cohorts.set(key, [...(cohorts.get(key) ?? []), { request: one.request, runId: one.runId, task: ref.externalId, head: one.head, base: one.base ?? null, at: one.at }]);
72
53
  }
73
54
  for (const [key, members] of cohorts) {
@@ -121,6 +102,8 @@ async function checkCohort(store, root, repo, cohort, deps) {
121
102
  const attempts = new Map(members.map(one => [one.request, []]));
122
103
  const bound = (text) => { const hits = scanForSecrets(text); return boundStreamHeadTail(hits.length > 0 ? redactSecretLines(text, hits) : text, 12 * 1024); };
123
104
  const section = (label, shown, result) => `=== ${label} ===\n$ ${shown}\n(exit ${result.code}${result.notFound ? ", could not start" : ""}${result.timedOut ? ", timed out" : ""})\n\n--- stdout ---\n${bound(result.stdout)}\n\n--- stderr ---\n${bound(result.stderr)}`;
105
+ // How each command actually ran (check-access.ts), per result: only the commands it was part of count for its receipt.
106
+ const ranAtOf = new Map();
124
107
  const note = (set, text) => { for (const one of set)
125
108
  logs.get(one.request).push(text); };
126
109
  const attempt = (set, tested, outcome) => { for (const one of set)
@@ -158,11 +141,15 @@ async function checkCohort(store, root, repo, cohort, deps) {
158
141
  return { conflict: false, tested, outcome: { ran: false, failure: "Stopped before the check started." }, setupFailed: false };
159
142
  // The approvals are re-read before every spawn: a revoked or changed command never runs.
160
143
  const liveSetup = store.liveWorktreeSetup(repo);
144
+ // Worked out again right before each command (check-access.ts): a narrowing meanwhile applies to the next one. Each
145
+ // member is credited with exactly the commands of this set.
146
+ const setRan = (level) => { for (const one of set)
147
+ ranAtOf.set(one.request, [...(ranAtOf.get(one.request) ?? []), level]); };
161
148
  if ((liveSetup?.digest ?? null) !== (setup?.digest ?? null))
162
149
  return { conflict: false, tested, outcome: { ran: false, failure: "The project's approved setup changed before the check started." }, setupFailed: false };
163
150
  if (liveSetup !== null) {
164
151
  const shell = approvedCommandShell(liveSetup.command);
165
- const prepared = await runWithIsolatedDatabase(fencedCheckoutCommands(store, repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: liveSetup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
152
+ const prepared = await runWithIsolatedDatabase(checkoutRunner(store, repo, checkout, runner, set.map(one => one.runId), setRan), shell.file, shell.args, { cwd: checkout, timeoutMs: liveSetup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
166
153
  note(set, section(`${label} · approved project setup on ${tested.slice(0, 7)}`, liveSetup.command, prepared));
167
154
  if (prepared.code !== 0 || prepared.timedOut || prepared.notFound)
168
155
  return { conflict: false, tested, outcome: { ran: false, failure: "The project's setup failed, so the check didn't run." }, setupFailed: true };
@@ -171,7 +158,7 @@ async function checkCohort(store, root, repo, cohort, deps) {
171
158
  if (live === null || live.digest !== command.digest)
172
159
  return { conflict: false, tested, outcome: { ran: false, failure: "The approved check changed before it started." }, setupFailed: false };
173
160
  const shell = approvedCommandShell(live.command);
174
- const ran = await runWithIsolatedDatabase(fencedCheckoutCommands(store, repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
161
+ const ran = await runWithIsolatedDatabase(checkoutRunner(store, repo, checkout, runner, set.map(one => one.runId), setRan), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
175
162
  note(set, section(`${label} · full check on ${tested.slice(0, 7)}`, live.command, ran));
176
163
  return { conflict: false, tested, setupFailed: false,
177
164
  outcome: ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code } };
@@ -183,11 +170,12 @@ async function checkCohort(store, root, repo, cohort, deps) {
183
170
  };
184
171
  const seal = (one, mode, together, tested, outcome) => {
185
172
  const at = now();
186
- const text = logs.get(one.request).length === 0 ? "No command started." : logs.get(one.request).join("\n\n");
173
+ const ranLine = ranAtLine(widestRan(ranAtOf.get(one.request) ?? []));
174
+ const text = logs.get(one.request).length === 0 ? "No command started." : [...logs.get(one.request), ...(ranLine === null ? [] : [ranLine])].join("\n\n");
187
175
  const logId = storeEvidence(store, root, one.runId, "structured-output", `batch-check-${one.request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
188
176
  const logArtifact = store.getArtifact(logId);
189
177
  const batch = { mode, tested: tested ?? one.head, base, members: together.map(m => ({ task: m.task, run: m.runId, head: m.head })), attempts: attempts.get(one.request) };
190
- const receipt = JSON.stringify({ version: 1, request: one.request, run: one.runId, head: one.head, tested, level: "full",
178
+ const receipt = JSON.stringify({ version: 1, request: one.request, run: one.runId, head: one.head, tested, level: "full", checkAccess: widestRan(ranAtOf.get(one.request) ?? []),
191
179
  command: { repo: command.repo, command: command.command, digest: command.digest, approvedBy: command.approvedBy },
192
180
  result: outcome, batch, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
193
181
  storeEvidence(store, root, one.runId, "structured-output", `batch-check-${one.request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });