toolroll 0.9.65 → 0.9.67

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 (49) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -2
  3. package/dist/agent-fence.d.ts +69 -2
  4. package/dist/agent-fence.js +291 -11
  5. package/dist/agentconfig.js +4 -1
  6. package/dist/batch-checks.js +3 -2
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  8. package/dist/browser/terminal.js +134 -23
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/builder.d.ts +4 -0
  11. package/dist/builder.js +54 -12
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +1 -0
  14. package/dist/invoke.d.ts +18 -0
  15. package/dist/invoke.js +137 -14
  16. package/dist/live-terminal.d.ts +24 -13
  17. package/dist/live-terminal.js +79 -42
  18. package/dist/operate.d.ts +2 -0
  19. package/dist/operate.js +60 -2
  20. package/dist/project-delete-ui.d.ts +6 -0
  21. package/dist/project-delete-ui.js +20 -1
  22. package/dist/release-adapters.d.ts +6 -0
  23. package/dist/release-adapters.js +70 -3
  24. package/dist/release.d.ts +7 -1
  25. package/dist/release.js +20 -7
  26. package/dist/result-follow-ups.js +3 -2
  27. package/dist/scope.d.ts +13 -0
  28. package/dist/scope.js +10 -5
  29. package/dist/serve.js +1 -0
  30. package/dist/server/chrome.d.ts +10 -1
  31. package/dist/server/chrome.js +45 -4
  32. package/dist/server/console.css +55 -4
  33. package/dist/server/render-pages.d.ts +28 -3
  34. package/dist/server/render-pages.js +78 -9
  35. package/dist/server/render-tasks.js +2 -1
  36. package/dist/server/route-table.js +1 -0
  37. package/dist/server/settings.js +21 -0
  38. package/dist/server/tasks.js +7 -4
  39. package/dist/store.d.ts +11 -1
  40. package/dist/store.js +33 -2
  41. package/dist/teardown-git.d.ts +22 -0
  42. package/dist/teardown-git.js +136 -2
  43. package/dist/tool-broker.d.ts +12 -0
  44. package/dist/tool-broker.js +66 -2
  45. package/dist/unfenced-test-support.d.ts +2 -0
  46. package/dist/unfenced-test-support.js +11 -0
  47. package/dist/writes-mode.d.ts +9 -0
  48. package/dist/writes-mode.js +8 -0
  49. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.67 — 2026-10-23
4
+
5
+ - **A project's checks read their own dependencies again.** 0.9.66 began
6
+ running checks in a fenced checkout but left out the grant for the shared
7
+ install the checkout links, so any check that loaded a package failed
8
+ ("could not resolve"). The linked copy is readable again; others stay fenced.
9
+ - **Builds that write only their workspace resolve host names again on a
10
+ Mac.** 0.9.66 denied every unix socket to such a build, including the
11
+ system's DNS service, so with an open network nothing resolved (npm, git,
12
+ any fetch failed with "could not resolve host"). The DNS socket is allowed;
13
+ every other socket stays denied.
14
+ - 0.9.66 as released to the console, published: its macOS check failed on a
15
+ test whose socket path outgrew macOS's 104-byte limit in the runner's temp
16
+ folder. The test now makes its sockets under /tmp. The browser journeys'
17
+ model stand-ins also keep their call journal where a workspace-only build
18
+ may write, so the release check runs them under the default sandbox.
19
+ - `toolroll project network` takes `--open` and `--allowlist` again, and
20
+ reads `--remove <hosts>` and `--local any|none` as values: the shared flag
21
+ parser had refused the first two and read the others as switches.
22
+
23
+ ## 0.9.66 — 2026-10-22 (tagged, not published)
24
+
25
+ - **The board is live to watch.** A strip of workers sits above the lanes:
26
+ what each is building on this board, its stage and a running clock (or
27
+ offline, or no build here). A card that changes lane slides there, a new one
28
+ drops in, and a worker that picks up work lights up once — all off under
29
+ reduced motion. Building cards show three quiet steps (setting up, writing,
30
+ checking and committing) from the run's own phase, never a percent. On a phone
31
+ the lane sections now use the full width.
32
+ - **Builds write only their workspace.** A new per-project setting (Settings →
33
+ Project → Where builds write, `toolroll project writes`), sealed into each
34
+ approval: by default a build writes only its checkout and commit data, the
35
+ project's package caches, its temp folder and its agent's session files —
36
+ never your shell profile, login items, agent settings or the repository's Git
37
+ config and hooks. `anywhere` keeps the old behaviour; approvals from before
38
+ keep it too. Enforced by Seatbelt, bubblewrap and Codex's own profile.
39
+ - **Setup and check commands run in the build's fence**, with their own temp
40
+ folder and the project's caches.
41
+ - **The plane's Git on a build's checkout runs hardened** (no hooks, fsmonitor
42
+ or submodules) and refuses a checkout whose config names a command.
43
+ - **Tool servers start outside the checkout** and never run a file the build
44
+ wrote.
45
+ - **A live terminal comes back as its screen.** The server keeps each
46
+ terminal's screen (a headless xterm), so a page that reconnects gets the
47
+ screen, its last 5,000 lines and its modes — a full-screen program like
48
+ Claude Code or Codex redraws whole, never from a raw replay cut mid-sequence.
49
+ - **Terminals draw on the GPU** where the browser allows (falling back when it
50
+ doesn't), with Unicode 11 widths for emoji and wide characters.
51
+ - **`toolroll release` finishes when GitHub deletes the merged branch
52
+ itself.** A delete that fails because the branch is already gone counts as
53
+ done; one that fails while the branch is still there still stops.
54
+ - **`toolroll release` merges a Homebrew tap that runs no checks on a
55
+ change.** It no longer waits out its time limit for checks that will never
56
+ come. Any check run or commit status that does appear is still judged (each
57
+ app on its own), and a tap whose workflows run on pull requests still needs
58
+ its own GitHub Actions run to pass.
59
+
3
60
  ## 0.9.65 — 2026-10-22
4
61
 
5
62
  0.9.64 was tagged but never published: a macOS check failed on Node 24.
package/README.md CHANGED
@@ -581,8 +581,26 @@ and launch tools remain available.
581
581
 
582
582
  | | Auto | Full access |
583
583
  |---|---|---|
584
- | **Codex / OpenRouter** (any OS) | Codex's workspace sandbox (workspace writes, no network) with the fence | Codex's sandbox widened to write anywhere, with the fence; its commands reach the network only when the project's network is open |
585
- | **Claude, Gemini, ACP agents** | macOS: the agent runs inside a Seatbelt sandbox that denies the fence. Linux: inside bubblewrap. Neither (Windows, or Linux without bubblewrap): the build doesn't start, unless an instance operator allows unconfined agents | same |
584
+ | **Codex / OpenRouter** (any OS) | Codex's sandbox with the fence: writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network | the same writes, with the fence; its commands reach the network only when the project's network is open |
585
+ | **Claude, Gemini, ACP agents** | macOS: inside a Seatbelt sandbox that denies the fence and writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files. Linux: the same in bubblewrap, with a /tmp of its own. Neither (Windows, or Linux without bubblewrap): the build doesn't start, unless an instance operator allows unconfined agents | same |
586
+
587
+ **Where a build writes.** Each approved scope also seals where its build may
588
+ write. By default (Settings → Project → **Where builds write**, or `toolroll
589
+ project writes --repo <p> workspace`) a build writes only its checkout, its
590
+ checkout's commit data (never the repository's Git config, hooks or info, other
591
+ checkouts' data or submodules), the project's own package caches (npm, pnpm,
592
+ yarn, pip, uv, Go, Cargo, Gradle: its package managers are pointed there), the
593
+ run's temp folder, and the session files its agent CLI must keep. Your shell
594
+ profile, login items, `~/.claude` settings and everything else stay as they
595
+ are; Claude's other live sessions aren't even readable. A project whose tools
596
+ must write elsewhere can choose `anywhere` (outside the fence, as before);
597
+ approvals from before this setting keep writing anywhere, as they were approved.
598
+ The build's setup, check, follow-up and batch commands run in the same fence.
599
+ Toolroll's own Git on a build's checkout runs hardened (no hooks, fsmonitor,
600
+ submodules or signing), reads none of your global Git config but its plain values
601
+ (identity, line endings, a standard git-lfs install), and refuses a checkout
602
+ whose config includes a file or names a command; a project's tool servers can't
603
+ read the checkout and never run a file the build wrote.
586
604
 
587
605
  **The network.** Each approved scope says what its build may reach: the
588
606
  agent's provider, the package registries the project uses (npm, PyPI,
@@ -60,7 +60,7 @@ export type RunFolders = {
60
60
  };
61
61
  /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
62
62
  * network bridge (egress-bridge.ts). */
63
- export declare const RUN_FOLDER_PREFIXES: readonly ["so-tools-", "so-codex-", "so-net-"];
63
+ export declare const RUN_FOLDER_PREFIXES: readonly ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
64
64
  export declare function runFolders(own: string | null, root?: string): RunFolders;
65
65
  /**
66
66
  * What a fence gives back beside its denied paths. Each is narrow and none
@@ -80,7 +80,61 @@ export type FenceGrants = {
80
80
  runFolders?: RunFolders;
81
81
  pool?: CheckoutPool;
82
82
  network?: NetworkFence;
83
+ writes?: WriteAllowlist;
83
84
  };
85
+ export { isWritesMode, WRITES_DEFAULT, type WritesMode } from "./writes-mode.js";
86
+ /**
87
+ * The only places a `workspace` build writes: `subpaths`, each a folder (or file) writable whole, and `prefixes`, a file
88
+ * with the temporary names it is atomically replaced through, or a folder named by a pattern (a regex on macOS;
89
+ * elsewhere the path alone, when it exists). Everything else is read-only to the build; its fence's denies and read-only grants still hold inside these.
90
+ */
91
+ export type WriteAllowlist = {
92
+ subpaths: string[];
93
+ prefixes: WritePrefix[];
94
+ };
95
+ /** A file or folder named by `path` followed by what `suffix` (a regular expression, anchored at its end) allows. */
96
+ export type WritePrefix = {
97
+ path: string;
98
+ suffix: string;
99
+ };
100
+ /** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
101
+ * (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
102
+ export declare function claudeProjectFolder(home: string, cwd: string): {
103
+ path: string;
104
+ byPrefix: boolean;
105
+ };
106
+ /**
107
+ * A folder a write grant may name: made when absent, then only as the real folder it is — never a link, nor reached
108
+ * through one (its real path is its parent's real path and its own name). A build can rewrite what it may write, so a
109
+ * folder it was given could otherwise come back as a link to anywhere and be granted, followed, on its next launch.
110
+ * Null when it isn't such a folder: nothing is granted, and the write it was for fails.
111
+ */
112
+ export declare function grantFolder(path: string, create?: boolean): string | null;
113
+ /**
114
+ * What a `workspace` build writes and what it loses (arc 2): its checkout, its checkout's Git data (less the shared
115
+ * repository's config, hooks and info, which Git runs or reads as commands), its own launch folder (its temp is there),
116
+ * its project's cache folder, the terminal devices, and only the provider state its CLI must write (measured: Claude's
117
+ * transcript folder for this checkout; Gemini's history, temp and project list; an ACP agent's own folders). The
118
+ * folders named here are made first, so the provider never has to make them where it can't. `deny` is what this
119
+ * provider must not even read (Claude's live sessions' keys).
120
+ */
121
+ export declare function writeAllowlist(options: {
122
+ provider: string;
123
+ home: string;
124
+ worktree: string;
125
+ binding: WorktreeBinding | null;
126
+ launchFolder: string | null;
127
+ cache: string | null;
128
+ }): {
129
+ writes: WriteAllowlist;
130
+ readOnly: string[];
131
+ deny: string[];
132
+ };
133
+ /** Where a project's builds keep their package caches: one folder per project (one trust domain), never used by the
134
+ * plane's own tools, below the user's cache folder. */
135
+ export declare function buildCacheFolder(repo: string, home?: string, platform?: NodeJS.Platform, env?: Readonly<Record<string, string | undefined>>): string;
136
+ /** The environment that points a build's package managers at its project's cache folder, not the user's own. */
137
+ export declare function buildCacheEnv(folder: string): Record<string, string>;
84
138
  /**
85
139
  * A launch's network confinement (egress.ts, docs/plans/agent-baseline-hardening.md arc 1). `open`: the network as it
86
140
  * always was. `allowlist`: nothing leaves the machine but through the run's own proxy (`proxyPort`); the run's tool
@@ -233,6 +287,8 @@ export declare function macosFenceProfile(fence: readonly string[], grants?: Fen
233
287
  */
234
288
  export declare function macosNetworkRules(network: NetworkFence | undefined): string[];
235
289
  export declare const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
290
+ /** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
291
+ export declare const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
236
292
  /**
237
293
  * Whether this machine can run an agent inside the macOS fence (checked once). Not from inside another
238
294
  * sandbox: a Toolroll a fenced process starts (the end-to-end journeys a flow's check zone runs) can't nest
@@ -260,4 +316,15 @@ export declare function linuxFenced(file: string, args: readonly string[], fence
260
316
  };
261
317
  /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
262
318
  * engines), wherever they exist here. */
263
- export declare function linuxHostSockets(uid?: number | undefined, home?: string, tmp?: string): string[];
319
+ export declare function linuxHostSockets(uid?: number | undefined, home?: string, tmp?: string, followLinks?: boolean, env?: Readonly<Record<string, string | undefined>>): string[];
320
+ /**
321
+ * What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
322
+ * (fenceSummaryMarkdown; a test holds them equal).
323
+ */
324
+ export declare const FENCE_SUMMARY: readonly {
325
+ agent: string;
326
+ auto: string;
327
+ full: string;
328
+ }[];
329
+ /** FENCE_SUMMARY as README's markdown table. */
330
+ export declare function fenceSummaryMarkdown(): string;
@@ -43,7 +43,8 @@
43
43
  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
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
46
+ import { createHash } from "node:crypto";
47
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
47
48
  import { homedir, tmpdir } from "node:os";
48
49
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
49
50
  import { readMonitoring } from "./monitoring-settings.js";
@@ -368,7 +369,7 @@ export function taskBoundary(options) {
368
369
  }
369
370
  /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
370
371
  * network bridge (egress-bridge.ts). */
371
- export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-"];
372
+ export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
372
373
  export function runFolders(own, root = tmpdir()) {
373
374
  return { root: real(root), own: own === null ? null : real(own) };
374
375
  }
@@ -383,6 +384,161 @@ function otherRunFolders(folders) {
383
384
  }
384
385
  return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
385
386
  }
387
+ export { isWritesMode, WRITES_DEFAULT } from "./writes-mode.js";
388
+ /** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
389
+ * (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
390
+ export function claudeProjectFolder(home, cwd) {
391
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
392
+ return slug.length > 200 ? { path: join(home, ".claude", "projects", slug.slice(0, 200)), byPrefix: true } : { path: join(home, ".claude", "projects", slug), byPrefix: false };
393
+ }
394
+ /**
395
+ * A folder a write grant may name: made when absent, then only as the real folder it is — never a link, nor reached
396
+ * through one (its real path is its parent's real path and its own name). A build can rewrite what it may write, so a
397
+ * folder it was given could otherwise come back as a link to anywhere and be granted, followed, on its next launch.
398
+ * Null when it isn't such a folder: nothing is granted, and the write it was for fails.
399
+ */
400
+ export function grantFolder(path, create = true) {
401
+ if (create) {
402
+ try {
403
+ mkdirSync(path, { recursive: true, mode: 0o700 });
404
+ }
405
+ catch { /* checked below */ }
406
+ }
407
+ try {
408
+ const held = lstatSync(path);
409
+ if (held.isSymbolicLink() || !held.isDirectory())
410
+ return null;
411
+ // Nor through a link of this user's anywhere above it (a system alias, /var → /private/var, is root's and fine).
412
+ const uid = process.getuid?.();
413
+ for (let folder = dirname(resolve(path)); folder !== dirname(folder); folder = dirname(folder)) {
414
+ const above = lstatSync(folder);
415
+ if (above.isSymbolicLink() && (uid === undefined || above.uid === uid))
416
+ return null;
417
+ }
418
+ const at = realpathSync(path);
419
+ return at === join(realpathSync(dirname(path)), basename(path)) ? at : null;
420
+ }
421
+ catch {
422
+ return null;
423
+ }
424
+ }
425
+ /**
426
+ * The data folders an ACP agent writes besides a ~/.local/share|state root, per agent, only where known: Goose's
427
+ * sessions and state; Qwen Code (a Gemini CLI fork) its history and temp, as Gemini measured. An agent not listed
428
+ * (Cursor, Cline: their settings sit among their data) writes nothing of its own under a workspace build until its
429
+ * layout is measured; a project using one chooses `anywhere`.
430
+ */
431
+ const ACP_DATA = {
432
+ goose: [".local/share/goose", ".local/state/goose"],
433
+ qwen: [".qwen/tmp", ".qwen/history"],
434
+ };
435
+ /** The suffix Claude Code gives a transcript folder whose name it shortened: `-` and a hash, nothing else. */
436
+ const CLAUDE_SHORTENED = "-[a-z0-9]+(/|$)";
437
+ /** A file and the temporary names it is replaced through (`name.tmp-1`, `name.json.12345`), never a folder below it. */
438
+ const ATOMIC_NAMES = "([.~][^/]*)?$";
439
+ /** The parts of a repository's shared Git data a commit in a worktree writes: objects, refs and their logs (and the
440
+ * packed refs). Its config, hooks, info, other worktrees' data and submodules' are never among them. */
441
+ function commitGitData(common) {
442
+ const made = (path) => { try {
443
+ mkdirSync(path, { recursive: true });
444
+ }
445
+ catch { /* granted as named */ } return real(path); };
446
+ return {
447
+ subpaths: ["objects", "refs", "logs"].map(name => grantFolder(join(common, name))).filter((path) => path !== null),
448
+ prefixes: [{ path: join(common, "packed-refs"), suffix: ATOMIC_NAMES }],
449
+ // Alternates there would let objects come from anywhere.
450
+ readOnly: [made(join(common, "objects", "info"))],
451
+ };
452
+ }
453
+ /**
454
+ * What a `workspace` build writes and what it loses (arc 2): its checkout, its checkout's Git data (less the shared
455
+ * repository's config, hooks and info, which Git runs or reads as commands), its own launch folder (its temp is there),
456
+ * its project's cache folder, the terminal devices, and only the provider state its CLI must write (measured: Claude's
457
+ * transcript folder for this checkout; Gemini's history, temp and project list; an ACP agent's own folders). The
458
+ * folders named here are made first, so the provider never has to make them where it can't. `deny` is what this
459
+ * provider must not even read (Claude's live sessions' keys).
460
+ */
461
+ export function writeAllowlist(options) {
462
+ const home = real(options.home);
463
+ const worktree = real(options.worktree);
464
+ const subpaths = new Set([worktree]);
465
+ const prefixes = new Map();
466
+ const readOnly = new Set();
467
+ const deny = new Set();
468
+ const made = (path) => { try {
469
+ mkdirSync(path, { recursive: true, mode: 0o700 });
470
+ }
471
+ catch { /* granted as named either way */ } return real(path); };
472
+ const grant = (path, create = true) => { const at = grantFolder(path, create); if (at !== null)
473
+ subpaths.add(at); };
474
+ if (options.binding !== null) {
475
+ const common = real(join(options.binding.main, ".git"));
476
+ const gitdir = real(options.binding.gitdir);
477
+ grant(gitdir, false);
478
+ // The shared Git data a commit writes, and nothing Git reads as commands or settings: the shared config (filters,
479
+ // hooks path, fsmonitor), hooks, info (attributes, excludes), other worktrees' data and submodules' stay as they are.
480
+ const data = commitGitData(common);
481
+ for (const path of data.subpaths)
482
+ subpaths.add(path);
483
+ for (const one of data.prefixes)
484
+ prefixes.set(`${one.path}\0${one.suffix}`, one);
485
+ for (const path of data.readOnly)
486
+ readOnly.add(path);
487
+ // This worktree's own settings file, present (empty) so it is held read-only everywhere, a bind included.
488
+ const own = join(gitdir, "config.worktree");
489
+ try {
490
+ if (!existsSync(own))
491
+ writeFileSync(own, "", { mode: 0o644, flag: "wx" });
492
+ }
493
+ catch { /* held read-only by name */ }
494
+ readOnly.add(own);
495
+ }
496
+ if (options.launchFolder !== null)
497
+ grant(options.launchFolder, false);
498
+ if (options.cache !== null)
499
+ grant(options.cache);
500
+ if (options.provider === "claude") {
501
+ const folder = claudeProjectFolder(home, worktree);
502
+ if (folder.byPrefix) {
503
+ made(dirname(folder.path));
504
+ prefixes.set(folder.path, { path: folder.path, suffix: CLAUDE_SHORTENED });
505
+ }
506
+ else
507
+ grant(folder.path);
508
+ deny.add(join(home, ".claude", "sessions"));
509
+ }
510
+ else if (options.provider === "gemini") {
511
+ for (const name of ["history", "tmp"])
512
+ grant(join(home, ".gemini", name));
513
+ prefixes.set("gemini", { path: join(home, ".gemini", "projects.json"), suffix: ATOMIC_NAMES });
514
+ }
515
+ else if (Object.hasOwn(ACP_AGENTS, options.provider)) {
516
+ // An ACP agent's configuration stays read-only whole (its MCP servers, hooks, plugins, agents and commands could
517
+ // otherwise be planted for the operator's next session of it); only its known data folders are written (ACP_DATA).
518
+ for (const name of ACP_AGENTS[options.provider].home)
519
+ if (/^\.local\/(share|state)\//.test(name))
520
+ grant(join(home, name));
521
+ for (const name of ACP_DATA[options.provider] ?? [])
522
+ grant(join(home, name));
523
+ }
524
+ return { writes: { subpaths: [...subpaths].sort(), prefixes: [...prefixes.values()].sort((a, b) => a.path.localeCompare(b.path)) }, readOnly: [...readOnly].sort(), deny: [...deny].sort() };
525
+ }
526
+ /** Where a project's builds keep their package caches: one folder per project (one trust domain), never used by the
527
+ * plane's own tools, below the user's cache folder. */
528
+ export function buildCacheFolder(repo, home = homedir(), platform = process.platform, env = process.env) {
529
+ const root = platform === "darwin" ? join(home, "Library", "Caches") : env["XDG_CACHE_HOME"] !== undefined && isAbsolute(env["XDG_CACHE_HOME"]) ? env["XDG_CACHE_HOME"] : join(home, ".cache");
530
+ return join(root, "toolroll-builds", createHash("sha256").update(real(repo)).digest("hex").slice(0, 16));
531
+ }
532
+ /** The environment that points a build's package managers at its project's cache folder, not the user's own. */
533
+ export function buildCacheEnv(folder) {
534
+ const at = (name) => join(folder, name);
535
+ return {
536
+ npm_config_cache: at("npm"), npm_config_store_dir: at("pnpm"), YARN_CACHE_FOLDER: at("yarn"), BUN_INSTALL_CACHE_DIR: at("bun"),
537
+ COREPACK_HOME: at("corepack"), npm_config_devdir: at("node-gyp"), PIP_CACHE_DIR: at("pip"), UV_CACHE_DIR: at("uv"),
538
+ GOCACHE: at("go-build"), GOMODCACHE: at("go-mod"), CARGO_HOME: at("cargo"), GRADLE_USER_HOME: at("gradle"),
539
+ DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
540
+ };
541
+ }
386
542
  /**
387
543
  * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
388
544
  * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
@@ -524,7 +680,14 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
524
680
  // Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
525
681
  const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
526
682
  && !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
527
- if (fullAccess)
683
+ // Full access writes anywhere its fence allows, unless the build writes only its workspace (arc 2): then exactly its
684
+ // allowlist, in either mode, beside the workspace Codex's own profile gives.
685
+ // Codex's own workspace profile also writes the shared temp folders; a workspace build starts from its read-only
686
+ // profile instead, with exactly the allowlist (its checkout among it) written back.
687
+ if (grants.writes !== undefined)
688
+ for (const path of grants.writes.subpaths)
689
+ access.set(real(path), "write");
690
+ else if (fullAccess)
528
691
  access.set("/", "write");
529
692
  for (const path of readOnly)
530
693
  if (!pooled(real(path)))
@@ -548,7 +711,7 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
548
711
  const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
549
712
  return [
550
713
  "-c", 'default_permissions="standing-orders"',
551
- "-c", 'permissions.standing-orders.extends=":workspace"',
714
+ "-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
552
715
  "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
553
716
  // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
554
717
  // network is open (and Full access asked for it), and are offline otherwise.
@@ -700,6 +863,14 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
700
863
  return [
701
864
  "(version 1)",
702
865
  "(allow default)",
866
+ // A workspace build writes only its allowlist (and the terminal devices); every deny and read-only rule after this
867
+ // still holds inside it.
868
+ ...(grants.writes === undefined ? [] : [
869
+ "(deny file-write*)",
870
+ `(allow file-write* (subpath "/dev")${grants.writes.subpaths.length === 0 ? "" : ` ${paths("subpath", grants.writes.subpaths)}`}${grants.writes.prefixes.map(one => ` (regex #"^${seatbeltRegex(one.path)}${one.suffix}")`).join("")})`,
871
+ // A granted folder itself can't be renamed, removed or replaced (by a link to anywhere, say): only what is in it.
872
+ ...(grants.writes.subpaths.length === 0 ? [] : [`(deny file-write* ${paths("literal", grants.writes.subpaths)})`]),
873
+ ]),
703
874
  ...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
704
875
  ...(pool?.own == null ? [] : [
705
876
  `(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
@@ -720,6 +891,14 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
720
891
  "(deny mach-task-name)",
721
892
  ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
722
893
  ...macosNetworkRules(grants.network),
894
+ // A workspace build reaches no unix socket but its own (a container engine's, an agent's, a database's would act
895
+ // outside its limits for it), whatever its network: a confined network has its own rule for this already. Its open
896
+ // network still names hosts: the system's DNS service answers on a unix socket, and without it nothing resolves.
897
+ ...(grants.writes === undefined || grants.network?.mode === "allowlist" ? [] : [
898
+ "(deny network-outbound (remote unix-socket))",
899
+ `(allow network-outbound (remote unix-socket (path-literal ${seatbelt(MACOS_DNS_SOCKET)})))`,
900
+ ...(grants.writes.subpaths.length === 0 ? [] : [`(allow network-outbound ${grants.writes.subpaths.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
901
+ ]),
723
902
  ].join("\n");
724
903
  }
725
904
  /**
@@ -741,6 +920,8 @@ export function macosNetworkRules(network) {
741
920
  ];
742
921
  }
743
922
  export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
923
+ /** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
924
+ export const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
744
925
  /** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
745
926
  const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
746
927
  let seatbeltWorks = null;
@@ -833,19 +1014,31 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
833
1014
  // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
834
1015
  const isolated = network === null ? [] : [
835
1016
  "--unshare-net", "--unshare-pid", "--proc", "/proc",
836
- ...linuxHostSockets().flatMap(mask),
1017
+ ...linuxHostSockets(undefined, undefined, undefined, grants.writes !== undefined).flatMap(mask),
837
1018
  "--ro-bind", network.bridge.folder, network.bridge.folder,
838
1019
  ];
839
1020
  // A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
840
1021
  // all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
841
- const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => ["--bind", path, path])];
1022
+ // A workspace build (arc 2) sees the whole filesystem read-only, a /dev of its own and a /tmp of its own, with only
1023
+ // its allowlist bound back writable; its own checkout's Git data is in that list.
1024
+ const writes = grants.writes;
1025
+ const root = writes === undefined ? ["--dev-bind", "/", "/"] : ["--ro-bind", "/", "/", "--dev", "/dev"];
1026
+ const writable = writes === undefined ? [] : [...writes.subpaths, ...writes.prefixes.map(one => one.path)].filter(path => existsSync(path)).flatMap(path => ["--bind", path, path]);
1027
+ // What a private /tmp gives back is read-only for a workspace build (its writable part is bound again after): the
1028
+ // checkout's whole shared Git data among it must not come back writable.
1029
+ // A confined network brings a /tmp of its own (arc 1); a workspace build with an open network sees the shared /tmp
1030
+ // read-only like the rest of the root, its own folders there bound back writable.
1031
+ const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network?.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => [writes === undefined ? "--bind" : "--ro-bind", path, path])];
1032
+ // A workspace build never reaches the host's own services either (a container engine would write anywhere for it),
1033
+ // whatever its network: confined networks mask them below already.
1034
+ const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
842
1035
  const command = network === null ? [file, ...args]
843
1036
  : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
844
- return { file: "bwrap", args: ["--dev-bind", "/", "/", ...privateTmp, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...isolated, "--die-with-parent", "--", ...command] };
1037
+ return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...services, ...isolated, "--die-with-parent", "--", ...command] };
845
1038
  }
846
1039
  /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
847
1040
  * engines), wherever they exist here. */
848
- export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp") {
1041
+ export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp", followLinks = false, env = process.env) {
849
1042
  // The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
850
1043
  const system = [
851
1044
  ...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
@@ -874,10 +1067,97 @@ export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp
874
1067
  return false;
875
1068
  }
876
1069
  };
877
- for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name)))
1070
+ for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name))) {
878
1071
  if (owned(path))
879
1072
  masks.add(path);
880
- // /tmp's sockets (the display's, an SSH agent's, a multiplexer's, editors') go with the build's private /tmp.
881
- void tmp;
1073
+ // A workspace build can't plant a link in the home folder, so a link there is the operator's own: its target is
1074
+ // masked too (an engine moved elsewhere).
1075
+ else if (followLinks) {
1076
+ try {
1077
+ if (lstatSync(path).isSymbolicLink())
1078
+ masks.add(realpathSync(path));
1079
+ }
1080
+ catch { /* dangling */ }
1081
+ }
1082
+ }
1083
+ // An engine Toolroll's own environment points at, wherever it is (never the build's to choose): always masked. So is
1084
+ // the SSH agent it names (it signs as you), which a shared /tmp would otherwise leave in reach.
1085
+ for (const name of ["DOCKER_HOST", "CONTAINER_HOST"]) {
1086
+ const socket = /^unix:\/\/(\/.+)$/.exec(env[name] ?? "")?.[1];
1087
+ if (socket !== undefined) {
1088
+ try {
1089
+ masks.add(realpathSync(socket));
1090
+ }
1091
+ catch { /* not there */ }
1092
+ }
1093
+ }
1094
+ const agent = env["SSH_AUTH_SOCK"];
1095
+ if (agent !== undefined && isAbsolute(agent)) {
1096
+ try {
1097
+ const socket = realpathSync(agent);
1098
+ masks.add(socket);
1099
+ // The folder an agent makes for its socket (ssh-XXXX in /tmp) too, so a socket it makes next is out of reach.
1100
+ if (inside(socket, real(tmp)) && dirname(socket) !== real(tmp) && !lstatSync(dirname(socket)).isSymbolicLink())
1101
+ masks.add(dirname(socket));
1102
+ }
1103
+ catch { /* not there */ }
1104
+ }
1105
+ // A build that shares /tmp (a workspace build with an open network) reaches none of the sockets there either: every
1106
+ // socket two levels down (a multiplexer's, an agent's, a display's, an editor's), and the folder that holds it, taken
1107
+ // as it is (never through a link). Toolroll's own launch folders are fenced by name already.
1108
+ if (followLinks) {
1109
+ const socketIn = (folder, depth) => {
1110
+ let found = false;
1111
+ let entries = [];
1112
+ try {
1113
+ entries = readdirSync(folder);
1114
+ }
1115
+ catch {
1116
+ return false;
1117
+ }
1118
+ for (const entry of entries) {
1119
+ const path = join(folder, entry);
1120
+ if (RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix)))
1121
+ continue;
1122
+ let held;
1123
+ try {
1124
+ held = lstatSync(path);
1125
+ }
1126
+ catch {
1127
+ continue;
1128
+ }
1129
+ if (held.isSocket()) {
1130
+ masks.add(path);
1131
+ found = true;
1132
+ }
1133
+ else if (held.isDirectory() && !held.isSymbolicLink() && depth > 0 && socketIn(path, depth - 1)) {
1134
+ masks.add(path);
1135
+ found = true;
1136
+ }
1137
+ }
1138
+ return found;
1139
+ };
1140
+ socketIn(tmp, 1);
1141
+ }
882
1142
  return [...masks].sort();
883
1143
  }
1144
+ /**
1145
+ * What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
1146
+ * (fenceSummaryMarkdown; a test holds them equal).
1147
+ */
1148
+ export const FENCE_SUMMARY = [
1149
+ {
1150
+ agent: "**Codex / OpenRouter** (any OS)",
1151
+ auto: "Codex's sandbox with the fence: writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
1152
+ full: "the same writes, with the fence; its commands reach the network only when the project's network is open",
1153
+ },
1154
+ {
1155
+ agent: "**Claude, Gemini, ACP agents**",
1156
+ auto: "macOS: inside a Seatbelt sandbox that denies the fence and writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files. Linux: the same in bubblewrap, with a /tmp of its own. Neither (Windows, or Linux without bubblewrap): the build doesn't start, unless an instance operator allows unconfined agents",
1157
+ full: "same",
1158
+ },
1159
+ ];
1160
+ /** FENCE_SUMMARY as README's markdown table. */
1161
+ export function fenceSummaryMarkdown() {
1162
+ return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
1163
+ }
@@ -20,6 +20,7 @@
20
20
  * the caller snapshots it into the run row.
21
21
  */
22
22
  import { PROJECT_NETWORK_DEFAULT, repositoryEcosystems, resolveNetwork } from "./egress.js";
23
+ import { WRITES_DEFAULT } from "./writes-mode.js";
23
24
  import { isProviderId, validateSpec } from "./provider.js";
24
25
  import { ACP_LIMITS, CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
25
26
  import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
@@ -196,7 +197,9 @@ export function resolveScopeProfile(store, repo, ref, flags) {
196
197
  // What the build may reach (egress.ts): resolved now, from the project's setting, the provider's own hosts and the
197
198
  // registries the repository's own HEAD names, and sealed with the rest of the profile.
198
199
  const network = resolveNetwork(provider, repo === null ? PROJECT_NETWORK_DEFAULT : store.projectNetwork(repo), repo === null ? [] : repositoryEcosystems(repo));
199
- return { ok: true, profile: { ...profile, network }, provenance: { resolvedFrom: build.source, repairFrom } };
200
+ // Where the build may write (writes-mode.ts): the project's setting, sealed likewise.
201
+ const writes = repo === null ? WRITES_DEFAULT : store.projectWrites(repo);
202
+ return { ok: true, profile: { ...profile, network, writes }, provenance: { resolvedFrom: build.source, repairFrom } };
200
203
  }
201
204
  const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
202
205
  export function resolveRouteCandidates(store, repo, pins = {}) {
@@ -18,6 +18,7 @@
18
18
  * The queue is the follow-up check ledger (result-follow-ups.ts), with `batch` requests: the same
19
19
  * claim, receipts, abandoned-check reading, status projection and Merge gate. The policy is an
20
20
  * append-only ledger entry, like the check level. */
21
+ import { fencedCheckoutCommands } from "./invoke.js";
21
22
  import { mkdtempSync, rmSync } from "node:fs";
22
23
  import { tmpdir } from "node:os";
23
24
  import { join } from "node:path";
@@ -161,7 +162,7 @@ async function checkCohort(store, root, repo, cohort, deps) {
161
162
  return { conflict: false, tested, outcome: { ran: false, failure: "The project's approved setup changed before the check started." }, setupFailed: false };
162
163
  if (liveSetup !== null) {
163
164
  const shell = approvedCommandShell(liveSetup.command);
164
- const prepared = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: liveSetup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
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 });
165
166
  note(set, section(`${label} · approved project setup on ${tested.slice(0, 7)}`, liveSetup.command, prepared));
166
167
  if (prepared.code !== 0 || prepared.timedOut || prepared.notFound)
167
168
  return { conflict: false, tested, outcome: { ran: false, failure: "The project's setup failed, so the check didn't run." }, setupFailed: true };
@@ -170,7 +171,7 @@ async function checkCohort(store, root, repo, cohort, deps) {
170
171
  if (live === null || live.digest !== command.digest)
171
172
  return { conflict: false, tested, outcome: { ran: false, failure: "The approved check changed before it started." }, setupFailed: false };
172
173
  const shell = approvedCommandShell(live.command);
173
- const ran = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
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 });
174
175
  note(set, section(`${label} · full check on ${tested.slice(0, 7)}`, live.command, ran));
175
176
  return { conflict: false, tested, setupFailed: false,
176
177
  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 } };