toolroll 0.9.71 → 0.9.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/agent-fence.d.ts +10 -2
  3. package/dist/agent-fence.js +47 -9
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +28 -28
  9. package/dist/builder.js +48 -6
  10. package/dist/chat-actions.js +13 -8
  11. package/dist/chat-decide.d.ts +47 -1
  12. package/dist/chat-decide.js +186 -5
  13. package/dist/check-access.js +2 -2
  14. package/dist/check-report.d.ts +109 -0
  15. package/dist/check-report.js +205 -0
  16. package/dist/claim.js +1 -1
  17. package/dist/coding-provider.js +4 -2
  18. package/dist/commit-extract.d.ts +111 -0
  19. package/dist/commit-extract.js +514 -0
  20. package/dist/contracts/console-api.d.ts +4 -4
  21. package/dist/contracts/console-api.js +5 -4
  22. package/dist/contracts/flow.d.ts +16 -0
  23. package/dist/contracts/flow.js +3 -0
  24. package/dist/contracts/release-check.d.ts +335 -0
  25. package/dist/contracts/release-check.js +159 -0
  26. package/dist/contracts/release-terms.d.ts +66 -0
  27. package/dist/contracts/release-terms.js +74 -0
  28. package/dist/contracts/verification-receipt.d.ts +42 -8
  29. package/dist/contracts/verification-receipt.js +24 -7
  30. package/dist/contracts/verification-view.d.ts +1 -0
  31. package/dist/deploy-gate.d.ts +17 -0
  32. package/dist/deploy-gate.js +68 -0
  33. package/dist/dispatch.js +3 -10
  34. package/dist/dispose.js +4 -1
  35. package/dist/evidence-pack.d.ts +1 -1
  36. package/dist/evidence-pack.js +20 -2
  37. package/dist/evidence.d.ts +1 -0
  38. package/dist/evidence.js +2 -1
  39. package/dist/exec.d.ts +1 -1
  40. package/dist/exec.js +7 -3
  41. package/dist/flow-engine.js +5 -1
  42. package/dist/flow-gallery-ui.js +4 -2
  43. package/dist/flow-gallery.d.ts +3 -0
  44. package/dist/flow-gallery.js +6 -2
  45. package/dist/flow-pull-request.d.ts +5 -2
  46. package/dist/flow-pull-request.js +14 -5
  47. package/dist/flow-schedule.js +25 -6
  48. package/dist/flow-triggers.d.ts +8 -0
  49. package/dist/flow-triggers.js +15 -0
  50. package/dist/flows-cli.js +13 -3
  51. package/dist/flows-ui.js +7 -1
  52. package/dist/guides.js +10 -1
  53. package/dist/invoke.d.ts +17 -6
  54. package/dist/invoke.js +48 -18
  55. package/dist/kits-ui.js +4 -2
  56. package/dist/kits.d.ts +6 -4
  57. package/dist/kits.js +9 -11
  58. package/dist/lead-status.js +5 -2
  59. package/dist/live-terminal.d.ts +10 -6
  60. package/dist/live-terminal.js +55 -14
  61. package/dist/mcp-connect.d.ts +10 -0
  62. package/dist/mcp-connect.js +2 -0
  63. package/dist/nightly-check.d.ts +104 -0
  64. package/dist/nightly-check.js +362 -0
  65. package/dist/operate.js +39 -2
  66. package/dist/person-proof.d.ts +2 -0
  67. package/dist/person-proof.js +14 -0
  68. package/dist/plan-auto.js +4 -4
  69. package/dist/planner-source.d.ts +1 -1
  70. package/dist/project-delete-ui.js +1 -1
  71. package/dist/project-tools.js +3 -1
  72. package/dist/proof.js +2 -0
  73. package/dist/protected-storage.d.ts +67 -0
  74. package/dist/protected-storage.js +398 -0
  75. package/dist/publish.js +156 -144
  76. package/dist/pull-request-flow.d.ts +2 -0
  77. package/dist/pull-request-flow.js +24 -2
  78. package/dist/release-adapters.d.ts +16 -1
  79. package/dist/release-adapters.js +292 -29
  80. package/dist/release-authority.d.ts +118 -0
  81. package/dist/release-authority.js +135 -0
  82. package/dist/release-card.d.ts +76 -0
  83. package/dist/release-card.js +252 -0
  84. package/dist/release-carry.d.ts +236 -0
  85. package/dist/release-carry.js +664 -0
  86. package/dist/release-cli.d.ts +3 -0
  87. package/dist/release-cli.js +158 -6
  88. package/dist/release-coverage.js +3 -0
  89. package/dist/release-git.d.ts +43 -0
  90. package/dist/release-git.js +106 -0
  91. package/dist/release-recheck.d.ts +146 -0
  92. package/dist/release-recheck.js +657 -0
  93. package/dist/release-view.d.ts +34 -0
  94. package/dist/release-view.js +27 -0
  95. package/dist/release.d.ts +139 -3
  96. package/dist/release.js +641 -250
  97. package/dist/remote-main.d.ts +2 -0
  98. package/dist/remote-main.js +1 -1
  99. package/dist/result-completion.d.ts +6 -0
  100. package/dist/result-completion.js +15 -1
  101. package/dist/result-follow-ups.js +29 -1
  102. package/dist/sandbox-runtime.d.ts +16 -5
  103. package/dist/sandbox-runtime.js +19 -6
  104. package/dist/scope.d.ts +122 -5
  105. package/dist/scope.js +236 -7
  106. package/dist/server/flows.js +40 -10
  107. package/dist/server/render-settings.js +1 -1
  108. package/dist/server/render-tasks.d.ts +8 -0
  109. package/dist/server/render-tasks.js +50 -9
  110. package/dist/server/route-table.js +1 -0
  111. package/dist/server/settings.js +26 -5
  112. package/dist/server/task-views.js +4 -0
  113. package/dist/server/tasks.js +12 -0
  114. package/dist/setup-guide.d.ts +2 -2
  115. package/dist/store.d.ts +230 -5
  116. package/dist/store.js +733 -37
  117. package/dist/subagent-memory.d.ts +1 -0
  118. package/dist/subagent-memory.js +1 -1
  119. package/dist/subagent-tools.d.ts +22 -6
  120. package/dist/subagent-tools.js +44 -8
  121. package/dist/subagent-work.d.ts +2 -0
  122. package/dist/subagent-work.js +5 -2
  123. package/dist/subagents-ui.js +6 -4
  124. package/dist/task-outcome-cli.js +7 -3
  125. package/dist/telegram.d.ts +6 -1
  126. package/dist/telegram.js +58 -6
  127. package/dist/toolroll-update.js +2 -2
  128. package/dist/verification-evidence.d.ts +25 -1
  129. package/dist/verification-evidence.js +57 -2
  130. package/dist/work-index.js +3 -6
  131. package/package.json +1 -1
@@ -0,0 +1,664 @@
1
+ /**
2
+ * One approval per release (docs/plans/release-once.md, part 1): which later commits carry a person's release approval,
3
+ * and the proof that one does.
4
+ *
5
+ * A later commit carries only when every change since the approved commit is tests, docs or the changelog (CARRY_RULES,
6
+ * each commit against its parent and the whole range), within the limits, under the stricter of the sealed and the
7
+ * current policy — AND the production build is byte-for-byte the approved one. The proof runs in this order and nothing
8
+ * from the candidate runs until the read-only preflight passed:
9
+ *
10
+ * 1. an idempotent retry first (a carry already written for this commit, still standing, is returned as it is);
11
+ * 2. the read-only preflight over objects hashed here (commit-extract.ts) and the live authority (scope.ts
12
+ * liveReleaseOf) — ancestry through the current tip, no merges, the rules, the limits, the grant and access;
13
+ * 3. both sides built inside the fence with no network, installed from the lockfile with no scripts, and their output
14
+ * digested as untrusted (process group gone, lstat, O_NOFOLLOW, regular files only, contained paths);
15
+ * 4. one BEGIN IMMEDIATE transaction that proves every mutable fact again and writes the carry (store.ts
16
+ * carryReleaseApproval).
17
+ *
18
+ * The limits keep a carried change small enough to review; they are not a security boundary. Authority decisions here
19
+ * read only Toolroll's records and verified objects. (Coverage, in release-recheck.ts, trusts test code by Alex's
20
+ * explicit decision; nothing here does.)
21
+ */
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join, sep } from "node:path";
26
+ import { execFileSync } from "node:child_process";
27
+ import { checkLaunchRoot, fenceFor, linuxFenceAvailable, macosFenceAvailable, runFolders } from "./agent-fence.js";
28
+ import { touchesProtected } from "./approval-policy.js";
29
+ import { extractCommit, readVerifiedCommit, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
30
+ import { projectGitDir } from "./check-report.js";
31
+ import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./contracts/release-terms.js";
32
+ import { run } from "./exec.js";
33
+ import { proxyEnv, startEgressProxy } from "./egress.js";
34
+ import { liveReleaseOf } from "./scope.js";
35
+ import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
36
+ import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
37
+ // ------------------------------------------------------------------ the rules
38
+ /** The journey scripts: tests, by Alex's decision (they carry). */
39
+ export const JOURNEY_SCRIPTS = Object.freeze(["scripts/app-e2e.mjs", "scripts/flows-e2e.mjs"]);
40
+ /** What carries (version 1). Everything else asks again — including every other `test/**` or `scripts/**` file (the
41
+ * harness, what it imports, helpers and fixtures), so no harness file can slip in under a class. */
42
+ export const CARRY_RULES = Object.freeze({
43
+ version: CARRY_RULES_VERSION,
44
+ classes: Object.freeze([
45
+ { name: "unit tests", matches: (path) => /^src\/.+\.test\.ts$/.test(path) },
46
+ { name: "journey scripts", matches: (path) => JOURNEY_SCRIPTS.includes(path) },
47
+ // Rules read the normalized, lower-case path (rulePath).
48
+ { name: "changelog", matches: (path) => path === "changelog.md" },
49
+ // An allowlist (security review): Markdown under docs/, and the root README. Never any other .md anywhere.
50
+ { name: "docs", matches: (path) => (path.startsWith("docs/") && path.endsWith(".md")) || path === "readme.md" },
51
+ ]),
52
+ /** Never carry, even when a class above matches them. */
53
+ never: Object.freeze([
54
+ // Agent instructions and policy, anywhere, whatever the case.
55
+ { why: "it's an agent or security policy file", matches: (path) => /(^|\/)(AGENTS|CLAUDE|GEMINI|SECURITY)\.md$/i.test(path) },
56
+ // Agent and CI configuration folders, anywhere: they steer agents and checks.
57
+ { why: "it configures agents or CI", matches: (path) => /(^|\/)\.(claude|codex|gemini|cursor|github)(\/|$)/i.test(path) },
58
+ // Docs agents are told to follow (AGENTS.md names the testing guide; plans are the input documents tasks build
59
+ // from). Nothing under docs/ is read by the product itself: its guides are compiled into src/guides.ts.
60
+ { why: "agents follow it as instructions", matches: (path) => /^docs\/plans\//.test(path) || path === "docs/testing.md" },
61
+ { why: "it's part of the test harness, a helper or a fixture", matches: (path) => path.startsWith("test/") },
62
+ { why: "it's part of the test harness", matches: (path) => path.startsWith("scripts/") && !JOURNEY_SCRIPTS.includes(path) },
63
+ { why: "it's part of the test harness", matches: (path) => /(^|\/)vitest\.config\.[cm]?[jt]s$/.test(path) },
64
+ ]),
65
+ });
66
+ /** The limits (Alex, 2026-10-25): 400 changed lines and 20 files after the approval, 800 lines of churn. */
67
+ export const CARRY_LIMITS = Object.freeze({ lines: 400, files: 20, churn: 800 });
68
+ /** "Ask for every release commit": a project setting, newest choice wins (a ledger row each); off by default. */
69
+ export const ASK_EVERY_COMMIT_ACTION = "release carrying changed";
70
+ export function asksEveryReleaseCommit(store, repo) {
71
+ const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, ASK_EVERY_COMMIT_ACTION);
72
+ return row !== undefined && row["outcome"] === "ask";
73
+ }
74
+ /** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
75
+ * after a person's own proof (release-cli.ts). */
76
+ export function setUnsandboxedCarry(store, repo, on, by, now) {
77
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: UNSANDBOXED_CARRY_ACTION, outcome: on ? "on" : "off", source: "request",
78
+ detail: on ? "Carry release approvals when checks aren't sandboxed: later test-only commits run outside the sandbox without being shown first" : "Release approvals carry only when checks are sandboxed" });
79
+ }
80
+ export function setAskEveryReleaseCommit(store, repo, ask, by, now) {
81
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: ASK_EVERY_COMMIT_ACTION, outcome: ask ? "ask" : "carry", source: "request",
82
+ detail: ask ? "Ask for every release commit" : "Later test-only commits ship under the release approval" });
83
+ }
84
+ /** The effective policy now: the runtime rules, the project's protected paths and the limits. */
85
+ export function effectivePolicy(store, repo, options = {}) {
86
+ const rules = store.approvalRules(repo);
87
+ return {
88
+ version: 1, rules: CARRY_RULES_VERSION, carry: options.noCarry !== true && !asksEveryReleaseCommit(store, repo),
89
+ protectProject: rules.protectProject, protectedPaths: [...new Set(rules.protectedPaths)].sort(), limits: { ...CARRY_LIMITS },
90
+ unsandboxedCarry: carriesUnsandboxed(store, repo),
91
+ };
92
+ }
93
+ /** The stricter of two policies: what either protects asks again, the lower limit wins, either one's "ask" asks. Null
94
+ * when either is a policy this runtime doesn't know (then nothing carries). */
95
+ export function stricterOf(a, b) {
96
+ if (a === null || b === null || !readReleasePolicy(a).ok || !readReleasePolicy(b).ok)
97
+ return null;
98
+ return {
99
+ version: 1, rules: CARRY_RULES_VERSION, carry: a.carry && b.carry, protectProject: a.protectProject || b.protectProject,
100
+ protectedPaths: [...new Set([...a.protectedPaths, ...b.protectedPaths])].sort(),
101
+ limits: { lines: Math.min(a.limits.lines, b.limits.lines), files: Math.min(a.limits.files, b.limits.files), churn: Math.min(a.limits.churn, b.limits.churn) },
102
+ // Carrying when checks aren't sandboxed only while both allow it: turning it on later never widens an approval.
103
+ unsandboxedCarry: a.unsandboxedCarry === true && b.unsandboxedCarry === true,
104
+ };
105
+ }
106
+ /**
107
+ * A path as the rules compare it: lower case (a case-insensitive file system reads docs/Plans as docs/plans), each part
108
+ * without the trailing dots and spaces Windows drops, and no empty or `.` parts (a verified tree never holds them, but
109
+ * the rules don't rely on that).
110
+ */
111
+ export function rulePath(path) {
112
+ return path.toLowerCase().split("/").map(part => part.replace(/[. ]+$/, "")).filter(part => part !== "" && part !== ".").join("/");
113
+ }
114
+ /** Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. */
115
+ export function changeRefusal(change, policy) {
116
+ const path = change.path;
117
+ const normal = rulePath(path);
118
+ if (change.kind === "deleted")
119
+ return `${path} was deleted or renamed`;
120
+ const modes = [change.before?.mode, change.after?.mode].filter((one) => one !== undefined);
121
+ if (modes.includes("120000"))
122
+ return `${path} is a symlink`;
123
+ if (modes.includes("160000"))
124
+ return `${path} is a submodule`;
125
+ if (change.before !== null && change.after !== null && change.before.mode !== change.after.mode)
126
+ return `${path} changed its file mode`;
127
+ if (change.binary)
128
+ return `${path} is a binary file`;
129
+ const never = CARRY_RULES.never.find(one => one.matches(normal));
130
+ if (never !== undefined)
131
+ return `${path} doesn't carry: ${never.why}`;
132
+ if (policy.protectProject || policy.protectedPaths.some(glob => touchesProtected(path, glob) || touchesProtected(normal, glob.toLowerCase())))
133
+ return `${path} is protected in this project`;
134
+ if (!CARRY_RULES.classes.some(one => one.matches(normal)))
135
+ return `${path} isn't a test, a doc or the changelog`;
136
+ return null;
137
+ }
138
+ // ------------------------------------------------------------------ release terms
139
+ /**
140
+ * A release scope's terms, from the plane's own records: the project's repository, the approved commit and its
141
+ * verified tree, the effective policy, the live release check grant and the check access (only the fence), and the
142
+ * expiry. Null with why when the release can't carry at all (it then asks for every commit, as before).
143
+ */
144
+ export function releaseTermsFor(store, input) {
145
+ const gitDir = projectGitDir(input.repo);
146
+ if (gitDir === null)
147
+ return { ok: false, why: "the project's Git directory can't be read" };
148
+ const commit = readVerifiedCommit(gitDir, input.candidate);
149
+ if (!commit.ok)
150
+ return { ok: false, why: commit.message };
151
+ const grant = store.liveVerifyCommand(input.repo);
152
+ if (grant === null)
153
+ return { ok: false, why: "the project has no approved release check" };
154
+ const access = store.projectCheckAccess(input.repo);
155
+ const policy = effectivePolicy(store, input.repo, { noCarry: input.noCarry ?? false });
156
+ // Checks outside the sandbox carry only with the project's "Carry release approvals when checks aren't sandboxed" on,
157
+ // sealed here: turning it on after this approval never carries for it.
158
+ if (access !== "sandboxed" && policy.unsandboxedCarry !== true) {
159
+ return { ok: false, why: "the project's checks run outside the sandbox, so later commits always ask again (unless “Carry release approvals when checks aren't sandboxed” is on)" };
160
+ }
161
+ return { ok: true, terms: {
162
+ version: 1, repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
163
+ approvedSha: input.candidate, approvedTree: commit.value.tree, policy,
164
+ grant: { id: grant.id, digest: grant.digest }, access,
165
+ expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
166
+ } };
167
+ }
168
+ /**
169
+ * Step 2, read-only: nothing from the candidate is installed, built or run here. Objects come from the project's own
170
+ * Git directory as Toolroll records it, hashed here; the authority is read live.
171
+ */
172
+ export function preflightCarry(store, input) {
173
+ const refuse = (reason, message, path) => ({ ok: false, reason, message, ...(path === undefined ? {} : { path }) });
174
+ const a = releaseAuthority(store, input.authority);
175
+ if (a === null)
176
+ return refuse("authority", `No release ${input.authority}.`);
177
+ const live = liveReleaseOf(store, a.tipTask, "dispatch", input.now);
178
+ if (!live.applies || !live.ok)
179
+ return refuse("authority", `The release approval doesn't stand: ${live.applies && !live.ok ? live.why : "its current commit isn't part of it"}.`);
180
+ if (input.epoch !== undefined && input.epoch !== null && (input.epoch !== a.leaseEpoch || a.leaseExpires === null || a.leaseExpires <= input.now.toISOString())) {
181
+ return refuse("authority", "This coordinator no longer holds the release's lease.");
182
+ }
183
+ const policy = stricterOf(a.policy, effectivePolicy(store, a.repo));
184
+ if (policy === null)
185
+ return refuse("authority", "This Toolroll doesn't know the release's carry rules; update Toolroll first.");
186
+ if (!policy.carry)
187
+ return refuse("rules", "This project asks for every release commit.");
188
+ // Checks outside the sandbox (at the approval or now): only when the sealed setting and the current one both allow it.
189
+ if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
190
+ return refuse("rules", "This project's checks run outside the sandbox, and carrying release approvals then wasn't on when this release was approved, or is off now.");
191
+ }
192
+ if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
193
+ return refuse("ancestry", `${input.to} isn't a full commit id.`);
194
+ if (input.to === a.tipSha)
195
+ return refuse("ancestry", `${input.to.slice(0, 7)} is already the release's commit.`);
196
+ const gitDir = projectGitDir(a.repo);
197
+ if (gitDir === null)
198
+ return refuse("ancestry", "The project's Git directory can't be read.");
199
+ const walk = verifiedFirstParentWalk(gitDir, input.to, a.approvedSha, 200);
200
+ if (!walk.ok)
201
+ return refuse("ancestry", `${input.to.slice(0, 7)} doesn't follow the approved commit: ${walk.message}`);
202
+ const chain = walk.value;
203
+ if (a.tipSha !== a.approvedSha && !chain.some(one => one.id === a.tipSha))
204
+ return refuse("ancestry", `${input.to.slice(0, 7)} doesn't follow the release's current commit ${a.tipSha.slice(0, 7)} (was the branch rewritten?).`);
205
+ const merge = chain.find(one => one.parents.length !== 1);
206
+ if (merge !== undefined)
207
+ return refuse("shape", `${merge.id.slice(0, 7)} is a merge commit; a carried change is plain commits.`);
208
+ // Each commit against its parent, oldest first.
209
+ const ordered = [...chain].reverse();
210
+ const treeOf = new Map([[a.approvedSha, a.approvedTree]]);
211
+ for (const commit of ordered)
212
+ treeOf.set(commit.id, commit.tree);
213
+ const commits = [];
214
+ let churn = 0;
215
+ for (const commit of ordered) {
216
+ const parentTree = treeOf.get(commit.parents[0]);
217
+ if (parentTree === undefined)
218
+ return refuse("ancestry", `${commit.id.slice(0, 7)}'s parent couldn't be read.`);
219
+ const diff = verifiedTreeDiff(gitDir, parentTree, commit.tree);
220
+ if (!diff.ok)
221
+ return refuse("shape", `${commit.id.slice(0, 7)} couldn't be read: ${diff.message}`);
222
+ for (const change of diff.value) {
223
+ const why = changeRefusal(change, policy);
224
+ if (why !== null)
225
+ return refuse("rules", `${commit.id.slice(0, 7)}: ${why}.`, change.path);
226
+ }
227
+ churn += diff.value.reduce((n, one) => n + one.added + one.deleted, 0);
228
+ commits.push({ sha: commit.id, files: diff.value.map(one => ({ path: one.path, added: one.added, deleted: one.deleted })) });
229
+ }
230
+ const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
231
+ if (!whole.ok)
232
+ return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
233
+ for (const change of whole.value) {
234
+ const why = changeRefusal(change, policy);
235
+ if (why !== null)
236
+ return refuse("rules", `Since the approval: ${why}.`, change.path);
237
+ }
238
+ const changedLines = whole.value.reduce((n, one) => n + one.added + one.deleted, 0);
239
+ if (changedLines > policy.limits.lines)
240
+ return refuse("limits", `${changedLines} lines changed since the approval; at most ${policy.limits.lines} carry.`);
241
+ if (whole.value.length > policy.limits.files)
242
+ return refuse("limits", `${whole.value.length} files changed since the approval; at most ${policy.limits.files} carry.`);
243
+ if (churn > policy.limits.churn)
244
+ return refuse("limits", `${churn} lines of churn across the carried commits; at most ${policy.limits.churn} carry.`);
245
+ return { ok: true, plan: { authority: a, to: input.to, toTree: chain[0].tree, commits, changedLines, files: whole.value.length, churn, policy,
246
+ currentPolicyDigest: releasePolicyDigest(effectivePolicy(store, a.repo)) } };
247
+ }
248
+ /** The default build runner: the OS fence, writes only where named, and no network — its only way out is a proxy of its
249
+ * own that allows no host. No fence on this machine, no build. */
250
+ export function fencedBuildRunner(store) {
251
+ return async (file, args, options) => {
252
+ if (!macosFenceAvailable() && !linuxFenceAvailable()) {
253
+ return { code: 1, stdout: "", stderr: "This machine can't confine the build (no macOS sandbox, no working bubblewrap), so it didn't run.\n", timedOut: false, notFound: false };
254
+ }
255
+ const egress = await startEgressProxy({ hosts: [] });
256
+ try {
257
+ const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: "proxy-only", socketRoots: [...options.writable], token: egress.secret };
258
+ const launch = fenceFor("check", {
259
+ databaseFile: store.databaseFile(), worktree: options.cwd, boundary: null, binding: null, runFolders: runFolders(null), poolRoots: [],
260
+ writes: { writes: { subpaths: [...options.writable], prefixes: [] }, readOnly: [] }, network,
261
+ });
262
+ return await run(file, args, { cwd: options.cwd, env: { ...options.env, ...proxyEnv(egress, "proxy-only") }, timeoutMs: options.timeoutMs, processGroup: true,
263
+ fence: launch.fence, fenceGrants: launch.fenceGrants, confinement: "required" });
264
+ }
265
+ finally {
266
+ await egress.close();
267
+ }
268
+ };
269
+ }
270
+ const realScanIO = () => ({
271
+ platform: process.platform, pid: process.pid, uid: process.geteuid?.() ?? -1,
272
+ procPids: () => readdirSync("/proc").filter(one => /^\d+$/.test(one)),
273
+ procCwd: pid => readlinkSync(`/proc/${pid}/cwd`),
274
+ procIdentity: pid => {
275
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
276
+ const uids = /^Uid:\s+(\d+)\s+(\d+)/m.exec(status);
277
+ // The command name (field 2) is in parentheses and may hold spaces or parentheses: fields count from the last ")".
278
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
279
+ const start = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
280
+ if (uids === null || !Number.isSafeInteger(start))
281
+ throw Object.assign(new Error("unreadable"), { code: "EUNREAD" });
282
+ return { uids: [Number(uids[1]), Number(uids[2])], startTicks: start };
283
+ },
284
+ nowTicks: () => {
285
+ try {
286
+ const hz = Number(execFileSync("getconf", ["CLK_TCK"], { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] }).trim());
287
+ const up = Number(readFileSync("/proc/uptime", "utf8").split(" ")[0]);
288
+ return Number.isFinite(hz) && hz > 0 && Number.isFinite(up) ? { ticks: Math.floor(up * hz), hz } : null;
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ },
294
+ processes: () => {
295
+ try {
296
+ return execFileSync("ps", ["-A", "-o", "pid=,uid=,stat="], { encoding: "utf8", timeout: 20_000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] })
297
+ .split("\n").map(line => line.trim().split(/\s+/)).filter(parts => parts.length === 3 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1]))
298
+ .map(([pid, uid, stat]) => ({ pid: Number(pid), uid: Number(uid), zombie: stat.startsWith("Z") }));
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ },
304
+ lsof: () => {
305
+ try {
306
+ return { stdout: execFileSync("lsof", ["-nP", "-d", "cwd", "-Fpn"], { encoding: "utf8", timeout: 20_000, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }), status: 0, signal: null, code: null };
307
+ }
308
+ catch (error) {
309
+ const e = error;
310
+ return { stdout: typeof e.stdout === "string" ? e.stdout : "", status: typeof e.status === "number" ? e.status : null, signal: typeof e.signal === "string" ? e.signal : null, code: typeof e.code === "string" ? e.code : null };
311
+ }
312
+ },
313
+ describe: pids => {
314
+ let text;
315
+ try {
316
+ text = execFileSync("ps", ["-o", "pid=,uid=,stat=,lstart=", "-p", pids.join(",")], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 20_000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
317
+ }
318
+ catch (error) {
319
+ // ps exits 1 when some of the pids are gone; what it printed is the rest. Anything else: no answer.
320
+ const e = error;
321
+ if (e.status !== 1 || e.signal != null)
322
+ return null;
323
+ text = typeof e.stdout === "string" ? e.stdout : "";
324
+ }
325
+ const found = new Map();
326
+ for (const line of text.split("\n")) {
327
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4})\s*$/.exec(line);
328
+ if (m === null)
329
+ continue;
330
+ const at = Date.parse(`${m[5].padStart(2, "0")} ${m[4]} ${m[7]} ${m[6]} GMT`);
331
+ found.set(Number(m[1]), { uid: Number(m[2]), zombie: m[3].startsWith("Z"), startedMs: Number.isFinite(at) ? at : null });
332
+ }
333
+ return found;
334
+ },
335
+ gone: pid => { try {
336
+ process.kill(pid, 0);
337
+ return false;
338
+ }
339
+ catch (error) {
340
+ return error.code === "ESRCH";
341
+ } },
342
+ });
343
+ /**
344
+ * Pids whose working folder is inside `root` (lsof on macOS, /proc on Linux), or null when that can't be shown (the
345
+ * caller refuses: nothing is digested unless it is shown that no writer remains). A process is passed over only when
346
+ * it is gone, or provably runs as another user than the build; one of the build's own user that can't be read refuses.
347
+ *
348
+ * On Linux a fenced build can't leave one at all: the sandbox runtime starts every launch in a PID namespace of its own
349
+ * (bwrap --unshare-pid, --die-with-parent), and when the build's process ends the kernel kills everything left in that
350
+ * namespace, a detached or new-session child included, before the launch returns. The scan is the check for whatever
351
+ * runs outside such a namespace (an unfenced runner) and the proof on macOS, where Seatbelt has no PID namespace.
352
+ */
353
+ export function processesInside(root, since, io = realScanIO()) {
354
+ const inside = (cwd) => cwd === root || cwd.startsWith(`${root}/`);
355
+ const code = (error) => error?.code;
356
+ if (io.platform === "linux") {
357
+ let names;
358
+ try {
359
+ names = io.procPids();
360
+ }
361
+ catch {
362
+ return null;
363
+ }
364
+ const found = [];
365
+ for (const name of names) {
366
+ if (Number(name) === io.pid)
367
+ continue;
368
+ let cwd;
369
+ try {
370
+ cwd = io.procCwd(name);
371
+ }
372
+ catch (error) {
373
+ if (code(error) === "ENOENT" || code(error) === "ESRCH")
374
+ continue;
375
+ if (code(error) !== "EACCES" && code(error) !== "EPERM")
376
+ return null;
377
+ // Unreadable: passed over only when it is gone by now, provably runs as another user (neither its real nor its
378
+ // effective uid is the build's), or provably started before the build did (so the build didn't start it).
379
+ let who;
380
+ try {
381
+ who = io.procIdentity(name);
382
+ }
383
+ catch (again) {
384
+ if (code(again) === "ENOENT" || code(again) === "ESRCH")
385
+ continue;
386
+ return null;
387
+ }
388
+ if (io.uid >= 0 && !who.uids.includes(io.uid))
389
+ continue;
390
+ // A whole second earlier than the build began: tick rounding can't make the build's own child look older.
391
+ if (since?.ticks != null && who.startTicks < since.ticks.ticks - since.ticks.hz)
392
+ continue;
393
+ return null;
394
+ }
395
+ if (inside(cwd))
396
+ found.push(Number(name));
397
+ }
398
+ return found;
399
+ }
400
+ // macOS: every process of the build's user, listed first; then lsof's cwd for each.
401
+ const listed = io.processes();
402
+ if (listed === null)
403
+ return null;
404
+ const out = io.lsof();
405
+ // lsof exits 1 when it couldn't read some process; anything else (a signal, a timeout, an overflow) is no listing.
406
+ const complete = out.status === 0 && out.signal === null && out.code === null;
407
+ const partial = out.status === 1 && out.signal === null && out.code !== "ETIMEDOUT" && out.code !== "ENOBUFS" && !/maxBuffer/i.test(out.code ?? "");
408
+ if (!complete && !partial)
409
+ return null;
410
+ const found = [], read = new Set();
411
+ let pid = 0;
412
+ for (const line of out.stdout.split("\n")) {
413
+ if (line.startsWith("p"))
414
+ pid = Number(line.slice(1));
415
+ else if (line.startsWith("n") && pid > 0) {
416
+ read.add(pid);
417
+ if (pid !== io.pid && inside(line.slice(1)))
418
+ found.push(pid);
419
+ }
420
+ }
421
+ // A process of the build's own user whose folder lsof couldn't read refuses, unless a second look shows it gone, a
422
+ // zombie (it has ended: it holds no folder and writes nothing), another user's, or started a whole second before the
423
+ // build began (so the build didn't start it).
424
+ const unread = listed.filter(one => one.uid === io.uid && one.pid !== io.pid && !one.zombie && !read.has(one.pid) && !io.gone(one.pid)).map(one => one.pid);
425
+ if (unread.length > 0) {
426
+ const again = io.describe(unread);
427
+ if (again === null)
428
+ return null;
429
+ for (const pid of unread) {
430
+ const now = again.get(pid);
431
+ if (now === undefined || now.zombie || now.uid !== io.uid)
432
+ continue;
433
+ if (since !== null && now.startedMs !== null && now.startedMs < since.ms - 1000)
434
+ continue;
435
+ return null;
436
+ }
437
+ }
438
+ return [...new Set(found)];
439
+ }
440
+ /** Files left out of the artifact digest and listed as doc changes instead. */
441
+ export const ARTIFACT_EXCLUDED = Object.freeze(["CHANGELOG.md", "README.md"]);
442
+ /**
443
+ * The digest of one built tree's production artifacts, read as untrusted: every entry walked with lstat, opened with
444
+ * O_NOFOLLOW and checked again on the descriptor; only regular files inside `root` count; a symlink, a special file or a
445
+ * path that escapes refuses. Each entry records path, mode and sha256.
446
+ */
447
+ export function digestTree(root, paths) {
448
+ const realRoot = realpathSync(root);
449
+ const entries = [];
450
+ for (const path of [...new Set(paths)].sort()) {
451
+ if (path === "" || path.startsWith("/") || path.split("/").some(part => part === "" || part === "." || part === ".."))
452
+ return { ok: false, message: `${path} isn't a contained path.` };
453
+ const full = join(realRoot, ...path.split("/"));
454
+ let stat;
455
+ try {
456
+ stat = lstatSync(full);
457
+ }
458
+ catch {
459
+ return { ok: false, message: `${path} is missing.` };
460
+ }
461
+ if (stat.isSymbolicLink())
462
+ return { ok: false, message: `${path} is a symlink; the build's output has to be plain files.` };
463
+ if (!stat.isFile())
464
+ return { ok: false, message: `${path} isn't a regular file.` };
465
+ // Every folder on the way is a real folder inside the root.
466
+ if (!realpathSync(join(full, "..")).startsWith(realRoot))
467
+ return { ok: false, message: `${path} escapes the build folder.` };
468
+ let fd;
469
+ try {
470
+ fd = openSync(full, constants.O_RDONLY | constants.O_NOFOLLOW);
471
+ }
472
+ catch {
473
+ return { ok: false, message: `${path} couldn't be opened as a plain file.` };
474
+ }
475
+ try {
476
+ const on = fstatSync(fd);
477
+ if (!on.isFile() || on.ino !== stat.ino || on.dev !== stat.dev)
478
+ return { ok: false, message: `${path} changed while it was read.` };
479
+ const bytes = readFileSync(fd);
480
+ entries.push([path, on.mode & 0o777, createHash("sha256").update(bytes).digest("hex")]);
481
+ }
482
+ finally {
483
+ closeSync(fd);
484
+ }
485
+ }
486
+ return { ok: true, digest: createHash("sha256").update(JSON.stringify(entries)).digest("hex"), entries: entries.length };
487
+ }
488
+ /** Every regular entry under a folder (relative paths), walked with lstat; anything else is listed as is, so the
489
+ * digest refuses it. */
490
+ function walk(root, prefix) {
491
+ const out = [];
492
+ let names;
493
+ try {
494
+ names = readdirSync(join(root, prefix));
495
+ }
496
+ catch {
497
+ return out;
498
+ }
499
+ for (const name of names.sort()) {
500
+ const path = prefix === "" ? name : `${prefix}/${name}`;
501
+ const stat = lstatSync(join(root, path));
502
+ if (stat.isDirectory())
503
+ out.push(...walk(root, path));
504
+ else
505
+ out.push(path);
506
+ }
507
+ return out;
508
+ }
509
+ /**
510
+ * Step 3 for one commit: extract it (verified objects, regular files only) into a fresh folder, install from its
511
+ * lockfile with no scripts and no network (npm checks every package against the lockfile's integrity hash), build it,
512
+ * list what `npm pack` would ship, wait until nothing runs from the folder any more, and digest dist/ plus the packed
513
+ * files twice — a difference means something is still writing, and refuses.
514
+ */
515
+ export async function artifactDigest(gitDir, sha, runner, options = {}) {
516
+ const timeoutMs = options.timeoutMs ?? 20 * 60_000;
517
+ const dir = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-carry-")));
518
+ // When the build began (Linux: ticks since boot): an unreadable process that started before it isn't the build's.
519
+ const scan = realScanIO();
520
+ const began = { ms: Date.now(), ticks: scan.platform === "linux" ? scan.nowTicks() : null };
521
+ try {
522
+ const src = join(dir, "src"), tmp = join(dir, "tmp");
523
+ mkdirSync(src);
524
+ mkdirSync(tmp, { mode: 0o700 });
525
+ // npm reads no user or global config of the person's or the commit's: two empty files of this build's own.
526
+ writeFileSync(join(tmp, "user.npmrc"), "");
527
+ writeFileSync(join(tmp, "global.npmrc"), "");
528
+ const extracted = extractCommit(gitDir, sha, src);
529
+ if (!extracted.ok)
530
+ return { ok: false, message: extracted.message };
531
+ const env = {
532
+ PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: homedir(), TMPDIR: tmp, TMP: tmp, TEMP: tmp, NODE_OPTIONS: "",
533
+ npm_config_userconfig: join(tmp, "user.npmrc"), npm_config_globalconfig: join(tmp, "global.npmrc"), npm_config_cache: options.npmCache ?? join(homedir(), ".npm"),
534
+ npm_config_logs_dir: tmp, npm_config_update_notifier: "false", npm_config_fund: "false", npm_config_audit: "false",
535
+ };
536
+ const step = async (what, args) => {
537
+ const result = await runner("npm", args, { cwd: src, env, writable: [src, tmp], timeoutMs });
538
+ return result.code === 0 && !result.timedOut ? null : `${what} failed (exit ${result.code}${result.timedOut ? ", timed out" : ""}): ${(result.stderr || result.stdout).trim().split("\n").slice(-3).join(" ")}`;
539
+ };
540
+ const installed = await step("The install", ["ci", "--offline", "--ignore-scripts", "--no-audit", "--no-fund"]);
541
+ if (installed !== null)
542
+ return { ok: false, message: installed };
543
+ const built = await step("The build", ["run", "build"]);
544
+ if (built !== null)
545
+ return { ok: false, message: built };
546
+ // Listing what would ship touches no dependency: it gets an empty cache of its own.
547
+ const listing = await runner("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: src, env: { ...env, npm_config_cache: join(tmp, "pack-cache") }, writable: [tmp], timeoutMs });
548
+ if (listing.code !== 0)
549
+ return { ok: false, message: `Listing the packed files failed: ${listing.stderr.trim().split("\n").slice(-2).join(" ")}` };
550
+ let packed;
551
+ try {
552
+ const parsed = JSON.parse(listing.stdout);
553
+ packed = (parsed[0]?.files ?? []).map(one => String(one.path)).filter(path => !ARTIFACT_EXCLUDED.includes(path));
554
+ }
555
+ catch {
556
+ return { ok: false, message: "The packed file list couldn't be read." };
557
+ }
558
+ // The build's process group is gone; anything still running from the folder (a background writer) refuses.
559
+ // A process caught mid-exit or mid-exec can't be read for a moment: the scan is asked again before it refuses.
560
+ let left = processesInside(dir, began, scan);
561
+ for (let again = 0; left === null && again < 2; again++) {
562
+ await new Promise(resolve => setTimeout(resolve, 250));
563
+ left = processesInside(dir, began, scan);
564
+ }
565
+ if (left === null)
566
+ return { ok: false, message: "It can't be shown that nothing the build started is still running, so its output isn't trusted." };
567
+ if (left.length > 0)
568
+ return { ok: false, message: `Something the build started is still running (process ${left.join(", ")}).` };
569
+ const paths = [...walk(src, "dist"), ...packed];
570
+ const first = digestTree(src, paths);
571
+ if (!first.ok)
572
+ return first;
573
+ await new Promise(resolve => setTimeout(resolve, 300));
574
+ const again = digestTree(src, [...walk(src, "dist"), ...packed]);
575
+ if (!again.ok)
576
+ return again;
577
+ if (again.digest !== first.digest)
578
+ return { ok: false, message: "The build's output changed after the build ended: something is still writing." };
579
+ return { ok: true, digest: first.digest };
580
+ }
581
+ finally {
582
+ rmSync(dir, { recursive: true, force: true });
583
+ }
584
+ }
585
+ // ------------------------------------------------------------------ the proof
586
+ /** The release task's id for a commit (release.ts gateTaskId's naming). */
587
+ export const carriedTaskId = (version, sha) => `release-${version.replace(/[^0-9A-Za-z.-]/g, "-")}-${sha.slice(0, 7)}`;
588
+ /**
589
+ * Carry a person's release approval to commit `to`, or say exactly why not. The lead may ask; only these rules decide.
590
+ * A refusal seals nothing and writes no row.
591
+ */
592
+ export async function proveCarry(store, input) {
593
+ const requestId = input.requestId ?? randomUUID();
594
+ // 1. The idempotent retry first, before any compare-and-swap: a lost answer is retried without a refusal.
595
+ const a0 = releaseAuthority(store, input.authority);
596
+ if (a0 === null)
597
+ return { ok: false, reason: "authority", message: `No release ${input.authority}.` };
598
+ const existing = releaseCarryTo(store, a0.id, input.to);
599
+ if (existing !== null) {
600
+ const live = liveReleaseOf(store, existing.taskId, "dispatch", input.now);
601
+ if (live.applies && live.ok)
602
+ return { ok: true, taskId: existing.taskId, carry: existing, retried: true };
603
+ return { ok: false, reason: "authority", message: `The carry to ${input.to.slice(0, 7)} doesn't stand any more: ${live.applies && !live.ok ? live.why : "it isn't part of the release"}.` };
604
+ }
605
+ // 2. Read-only preflight: any refusal stops here, before anything is installed or built.
606
+ const pre = preflightCarry(store, { authority: input.authority, to: input.to, epoch: input.epoch ?? null, now: input.now });
607
+ if (!pre.ok)
608
+ return pre;
609
+ const plan = pre.plan;
610
+ const gitDir = projectGitDir(plan.authority.repo);
611
+ if (gitDir === null)
612
+ return { ok: false, reason: "build", message: "The project's Git directory can't be read." };
613
+ const runner = input.runner ?? fencedBuildRunner(store);
614
+ // 3. Both sides built. The anchor's own digest is built once (the person's seal authorized running that approved
615
+ // commit) and sealed into the authority record; until it exists nothing carries.
616
+ let anchorDigest = plan.authority.artifactDigest;
617
+ if (anchorDigest === null) {
618
+ const anchor = await artifactDigest(gitDir, plan.authority.approvedSha, runner);
619
+ if (!anchor.ok)
620
+ return { ok: false, reason: "build", message: `The approved commit couldn't be built: ${anchor.message}` };
621
+ sealAnchorArtifact(store, plan.authority.id, anchor.digest, input.now);
622
+ anchorDigest = releaseAuthority(store, plan.authority.id)?.artifactDigest ?? null;
623
+ if (anchorDigest !== anchor.digest)
624
+ return { ok: false, reason: "stale", message: "The release changed while its approved build was sealed; try again." };
625
+ }
626
+ const candidate = await artifactDigest(gitDir, input.to, runner);
627
+ if (!candidate.ok)
628
+ return { ok: false, reason: "build", message: `${input.to.slice(0, 7)} couldn't be built: ${candidate.message}` };
629
+ if (candidate.digest !== anchorDigest)
630
+ return { ok: false, reason: "artifact", message: `The production build of ${input.to.slice(0, 7)} differs from the approved one, so this commit needs approval.` };
631
+ // 4. One transaction: every mutable check again, then the writes.
632
+ const after = releaseAuthority(store, plan.authority.id);
633
+ const written = carryReleaseApproval(store, {
634
+ authority: after.id, expectRev: after.rev, expectTip: plan.authority.tipSha, to: input.to, toTree: plan.toTree, requestId, artifactDigest: candidate.digest,
635
+ commits: plan.commits, changedLines: plan.changedLines, files: plan.files, churn: plan.churn, runtime: input.runtime ?? runtimeIdentity(),
636
+ taskId: carriedTaskId(after.version, input.to), title: `Release ${after.version}`,
637
+ }, input.now, () => {
638
+ const now = releaseAuthority(store, after.id);
639
+ if (now.tipSha !== plan.authority.tipSha)
640
+ return "The release's current commit moved since this carry was checked.";
641
+ if (releasePolicyDigest(effectivePolicy(store, now.repo)) !== plan.currentPolicyDigest)
642
+ return "The project's release policy changed since this carry was checked; check it again.";
643
+ if (input.epoch !== undefined && input.epoch !== null && now.leaseEpoch !== input.epoch)
644
+ return "This coordinator no longer holds the release's lease.";
645
+ return null;
646
+ });
647
+ if (!written.ok)
648
+ return { ok: false, reason: "stale", message: written.why };
649
+ return { ok: true, taskId: written.taskId, carry: written.carry, retried: false };
650
+ }
651
+ /** The runtime that proved a carry: this package's version and where it runs from. */
652
+ function runtimeIdentity() {
653
+ try {
654
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
655
+ return `toolroll ${String(manifest.version ?? "unknown")} (${realpathSync(new URL(".", import.meta.url).pathname).split(sep).slice(-2).join("/")})`;
656
+ }
657
+ catch {
658
+ return "toolroll unknown";
659
+ }
660
+ }
661
+ /** The words a refused carry ends with: the commit, the path and the rule, and that a person approves it instead. */
662
+ export function carryRefusalWords(refusal, to) {
663
+ return `${to.slice(0, 7)} needs approval: ${refusal.message}`;
664
+ }