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 +34 -0
- package/dist/agent-fence.d.ts +58 -0
- package/dist/agent-fence.js +211 -19
- package/dist/agentconfig.d.ts +1 -1
- package/dist/agentconfig.js +6 -2
- package/dist/batch-checks.js +14 -26
- package/dist/builder.d.ts +9 -0
- package/dist/builder.js +27 -1
- package/dist/check-access.d.ts +54 -0
- package/dist/check-access.js +80 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/invoke.d.ts +39 -2
- package/dist/invoke.js +176 -21
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +73 -0
- package/dist/project-delete-ui.d.ts +16 -0
- package/dist/project-delete-ui.js +60 -1
- package/dist/result-follow-ups.js +10 -15
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +12 -5
- package/dist/serve.js +9 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/render-results.d.ts +3 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +37 -0
- package/dist/server/tasks.js +2 -0
- package/dist/store.d.ts +29 -2
- package/dist/store.js +86 -3
- package/dist/surface.js +2 -1
- package/package.json +1 -1
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
|
package/dist/agent-fence.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent-fence.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
374
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
672
|
-
|
|
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
|
|
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
|
|
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 ? [] :
|
|
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
|
-
|
|
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 &&
|
|
989
|
-
&& RUN_FOLDER_PREFIXES.some(prefix => relative(
|
|
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
|
+
}
|
package/dist/agentconfig.d.ts
CHANGED
|
@@ -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:
|
package/dist/agentconfig.js
CHANGED
|
@@ -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
|
-
|
|
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 = {}) {
|
package/dist/batch-checks.js
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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" });
|