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.
- package/CHANGELOG.md +57 -0
- package/README.md +20 -2
- package/dist/agent-fence.d.ts +69 -2
- package/dist/agent-fence.js +291 -11
- package/dist/agentconfig.js +4 -1
- package/dist/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
- package/dist/browser/terminal.js +134 -23
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +54 -12
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/invoke.d.ts +18 -0
- package/dist/invoke.js +137 -14
- package/dist/live-terminal.d.ts +24 -13
- package/dist/live-terminal.js +79 -42
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +60 -2
- package/dist/project-delete-ui.d.ts +6 -0
- package/dist/project-delete-ui.js +20 -1
- package/dist/release-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +7 -1
- package/dist/release.js +20 -7
- package/dist/result-follow-ups.js +3 -2
- package/dist/scope.d.ts +13 -0
- package/dist/scope.js +10 -5
- package/dist/serve.js +1 -0
- package/dist/server/chrome.d.ts +10 -1
- package/dist/server/chrome.js +45 -4
- package/dist/server/console.css +55 -4
- package/dist/server/render-pages.d.ts +28 -3
- package/dist/server/render-pages.js +78 -9
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +21 -0
- package/dist/server/tasks.js +7 -4
- package/dist/store.d.ts +11 -1
- package/dist/store.js +33 -2
- package/dist/teardown-git.d.ts +22 -0
- package/dist/teardown-git.js +136 -2
- package/dist/tool-broker.d.ts +12 -0
- package/dist/tool-broker.js +66 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- 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
|
|
585
|
-
| **Claude, Gemini, ACP agents** | macOS:
|
|
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,
|
package/dist/agent-fence.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent-fence.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
881
|
-
|
|
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
|
+
}
|
package/dist/agentconfig.js
CHANGED
|
@@ -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
|
-
|
|
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 = {}) {
|
package/dist/batch-checks.js
CHANGED
|
@@ -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 } };
|