knodin 0.5.0

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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/dist/bin/cli.js +1704 -0
  4. package/dist/src/agent-integration.js +250 -0
  5. package/dist/src/artifact-refresh.js +81 -0
  6. package/dist/src/cli-args.js +267 -0
  7. package/dist/src/cli-model.js +324 -0
  8. package/dist/src/compact-structural.js +96 -0
  9. package/dist/src/competitive-constraints.js +20 -0
  10. package/dist/src/competitive-manifest.js +330 -0
  11. package/dist/src/competitive-measurement.js +183 -0
  12. package/dist/src/competitive-runner.js +453 -0
  13. package/dist/src/competitive-sandbox.js +108 -0
  14. package/dist/src/context-export.js +422 -0
  15. package/dist/src/context.js +102 -0
  16. package/dist/src/docs-sections.js +141 -0
  17. package/dist/src/doctor.js +380 -0
  18. package/dist/src/engine/ann-hnsw.js +271 -0
  19. package/dist/src/engine/embeddings.js +193 -0
  20. package/dist/src/engine/file-walker.js +43 -0
  21. package/dist/src/engine/index.js +13030 -0
  22. package/dist/src/engine/perf.js +115 -0
  23. package/dist/src/engine/prune.js +112 -0
  24. package/dist/src/engine/source-policy.js +69 -0
  25. package/dist/src/engine/sqlite.js +71 -0
  26. package/dist/src/engine/symbol-delete.js +58 -0
  27. package/dist/src/failure-diagnosis.js +590 -0
  28. package/dist/src/fleet.js +7 -0
  29. package/dist/src/git-executable.js +31 -0
  30. package/dist/src/graph-query-health.js +115 -0
  31. package/dist/src/index-activity.js +125 -0
  32. package/dist/src/init-progress-worker.js +107 -0
  33. package/dist/src/init-progress.js +155 -0
  34. package/dist/src/init.js +985 -0
  35. package/dist/src/lifecycle-health.js +213 -0
  36. package/dist/src/lsp-readonly.js +217 -0
  37. package/dist/src/output-compression.js +629 -0
  38. package/dist/src/output-telemetry.js +359 -0
  39. package/dist/src/pr-triage.js +638 -0
  40. package/dist/src/relationship-adapters.js +370 -0
  41. package/dist/src/release-attestation.js +533 -0
  42. package/dist/src/repair-progress-worker.js +121 -0
  43. package/dist/src/repair-progress.js +262 -0
  44. package/dist/src/repository-init-process.js +173 -0
  45. package/dist/src/repository-management.js +1089 -0
  46. package/dist/src/response-budget.js +184 -0
  47. package/dist/src/server.js +53 -0
  48. package/dist/src/system-config.js +615 -0
  49. package/dist/src/terminal-help.js +83 -0
  50. package/dist/src/tools/knodin-tools.js +1438 -0
  51. package/dist/src/tools/reckon-tools.js +5 -0
  52. package/dist/src/update-policy.js +944 -0
  53. package/dist/src/update-trust.js +503 -0
  54. package/dist/src/version.js +13 -0
  55. package/dist/src/visualization.js +162 -0
  56. package/dist/src/wait-for-fresh.js +98 -0
  57. package/dist/src/worktree-lifecycle.js +231 -0
  58. package/docs/CLI.md +39 -0
  59. package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
  60. package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
  61. package/docs/DOCTOR-AND-UPDATES.md +84 -0
  62. package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
  63. package/docs/INSTALLATION.md +208 -0
  64. package/docs/MCP.md +100 -0
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
  66. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  67. package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
  68. package/docs/SIGNED-UPDATES.md +146 -0
  69. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
  70. package/docs/TELEMETRY.md +42 -0
  71. package/docs/releases/0.3.0.md +46 -0
  72. package/docs/releases/0.4.0.md +68 -0
  73. package/docs/releases/0.4.1.md +28 -0
  74. package/docs/releases/0.4.2.md +27 -0
  75. package/docs/releases/0.4.3.md +23 -0
  76. package/docs/releases/0.5.0.md +29 -0
  77. package/package.json +110 -0
  78. package/schemas/release-attestation-v1.schema.json +210 -0
  79. package/tree-sitter-prisma.wasm +0 -0
  80. package/tree-sitter-sql.wasm +0 -0
  81. package/tree-sitter-xml.wasm +0 -0
@@ -0,0 +1,944 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { verifyReleaseAttestation } from "./release-attestation.js";
6
+ import { canonicalizeUpdateMetadata, verifyTargetArtifact, verifyUpdateMetadataBundle, verifyUpdateRootChain, } from "./update-trust.js";
7
+ const DEFAULT_TIMEOUT_MS = 2_000;
8
+ const MAX_TIMEOUT_MS = 10_000;
9
+ const MAX_METADATA_BYTES = 512 * 1_024;
10
+ const MAX_RELEASE_EVIDENCE_BYTES = 16 * 1_024 * 1_024;
11
+ const MAX_STATE_BYTES = 4 * 1_024 * 1_024;
12
+ const MAX_ARTIFACT_BYTES = 512 * 1_024 * 1_024;
13
+ const MAX_ROOT_ROTATIONS_PER_CHECK = 32;
14
+ const MAX_CROSS_CHECK_MIRRORS = 4;
15
+ const SHA256 = /^[a-f0-9]{64}$/;
16
+ const SAFE_TARGET_TEMPLATE = /^[A-Za-z0-9._/{}-]+$/;
17
+ /** Conservative manager ownership detection; an inconclusive path remains unknown. */
18
+ export function detectUpdateInstallMethod(runtimeCommand, env = process.env) {
19
+ if (env.MISE_DATA_DIR || env.MISE_ENV)
20
+ return "mise";
21
+ if (env.VOLTA_HOME)
22
+ return "volta";
23
+ if (env.NVM_BIN || env.NVM_DIR)
24
+ return "nvm";
25
+ if (env.FNM_DIR || env.FNM_MULTISHELL_PATH)
26
+ return "fnm";
27
+ if (env.ASDF_DIR || env.ASDF_DATA_DIR)
28
+ return "asdf";
29
+ if (env.HOMEBREW_PREFIX)
30
+ return "homebrew";
31
+ const command = runtimeCommand.join(" ").toLowerCase();
32
+ if (command.includes("/homebrew/") || command.includes("/cellar/"))
33
+ return "homebrew";
34
+ if (command.includes("/.local/share/mise/"))
35
+ return "mise";
36
+ if (command.includes("/.volta/"))
37
+ return "volta";
38
+ if (command.includes("/.nvm/"))
39
+ return "nvm";
40
+ if (command.includes("/fnm"))
41
+ return "fnm";
42
+ if (command.includes("/.asdf/"))
43
+ return "asdf";
44
+ if (command.includes("node_modules/knodin/"))
45
+ return "npm";
46
+ return "unknown";
47
+ }
48
+ function statePath(stateHome) {
49
+ const root = stateHome ?? process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state");
50
+ return path.join(root, "knodin", "trusted-update-state.json");
51
+ }
52
+ function checkLeasePath(stateHome) {
53
+ return path.join(path.dirname(statePath(stateHome)), "update-check.lock");
54
+ }
55
+ function configPath(configHome) {
56
+ const root = configHome ?? process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
57
+ return path.join(root, "knodin", "update.json");
58
+ }
59
+ function privateWrite(file, value) {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
61
+ try {
62
+ fs.chmodSync(path.dirname(file), 0o700);
63
+ }
64
+ catch {
65
+ // Best effort on filesystems without POSIX modes (not a trust decision).
66
+ }
67
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
68
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: "wx" });
69
+ fs.renameSync(temporary, file);
70
+ try {
71
+ fs.chmodSync(file, 0o600);
72
+ }
73
+ catch {
74
+ // Best effort on filesystems without POSIX modes (not a trust decision).
75
+ }
76
+ }
77
+ function safeJson(file, maximumBytes = MAX_METADATA_BYTES) {
78
+ try {
79
+ const stat = fs.statSync(file);
80
+ if (stat.size > maximumBytes)
81
+ return null;
82
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export function readTrustedUpdateState(stateHome) {
89
+ const value = safeJson(statePath(stateHome), MAX_STATE_BYTES);
90
+ if (!value ||
91
+ typeof value !== "object" ||
92
+ value.schemaVersion !== 1)
93
+ return null;
94
+ return value;
95
+ }
96
+ /** Atomically claim a due background check without putting network latency on ordinary commands. */
97
+ export function claimScheduledUpdateCheck(options) {
98
+ const config = loadConfig(options);
99
+ const env = options.env ?? process.env;
100
+ if (!config ||
101
+ !config.enabled ||
102
+ config.offline ||
103
+ env.RECKON_UPDATE_CHECK === "0" ||
104
+ env.RECKON_OFFLINE === "1")
105
+ return false;
106
+ const now = options.now?.() ?? new Date();
107
+ const checkedAt = readTrustedUpdateState(options.stateHome)?.checkedAt;
108
+ if (checkedAt && now.getTime() - Date.parse(checkedAt) < config.checkIntervalHours * 3_600_000)
109
+ return false;
110
+ const lease = checkLeasePath(options.stateHome);
111
+ fs.mkdirSync(path.dirname(lease), { recursive: true, mode: 0o700 });
112
+ try {
113
+ fs.writeFileSync(lease, `${process.pid} ${now.toISOString()}\n`, { flag: "wx", mode: 0o600 });
114
+ return true;
115
+ }
116
+ catch (error) {
117
+ if (error.code !== "EEXIST")
118
+ return false;
119
+ try {
120
+ const recorded = fs.readFileSync(lease, "utf-8").trim().split(/\s+/, 2)[1];
121
+ const recordedAt = recorded ? Date.parse(recorded) : Number.NaN;
122
+ const age = now.getTime() - (Number.isFinite(recordedAt) ? recordedAt : fs.statSync(lease).mtimeMs);
123
+ if (age <= Math.max(config.timeoutMs * 4, 60_000))
124
+ return false;
125
+ fs.unlinkSync(lease);
126
+ fs.writeFileSync(lease, `${process.pid} ${now.toISOString()}\n`, { flag: "wx", mode: 0o600 });
127
+ return true;
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
133
+ }
134
+ export function releaseScheduledUpdateCheck(stateHome) {
135
+ try {
136
+ fs.unlinkSync(checkLeasePath(stateHome));
137
+ }
138
+ catch (error) {
139
+ if (error.code !== "ENOENT")
140
+ throw error;
141
+ }
142
+ }
143
+ function isRecord(value) {
144
+ return value !== null && typeof value === "object" && !Array.isArray(value);
145
+ }
146
+ function validateConfig(value) {
147
+ if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.policy))
148
+ return null;
149
+ const candidate = value;
150
+ if (typeof candidate.enabled !== "boolean" ||
151
+ typeof candidate.offline !== "boolean" ||
152
+ typeof candidate.metadataBaseUrl !== "string" ||
153
+ typeof candidate.trustedRootPath !== "string" ||
154
+ !SHA256.test(candidate.trustedRootSha256) ||
155
+ typeof candidate.targetPathTemplate !== "string" ||
156
+ !SAFE_TARGET_TEMPLATE.test(candidate.targetPathTemplate) ||
157
+ !candidate.targetPathTemplate.includes("{version}") ||
158
+ typeof candidate.channel !== "string" ||
159
+ !/^[a-z][a-z0-9-]{0,31}$/.test(candidate.channel) ||
160
+ !Number.isFinite(candidate.checkIntervalHours) ||
161
+ candidate.checkIntervalHours < 1 ||
162
+ !Number.isInteger(candidate.timeoutMs) ||
163
+ candidate.timeoutMs < 100 ||
164
+ candidate.timeoutMs > MAX_TIMEOUT_MS ||
165
+ !["notify-only", "download-verify-only", "apply-patch", "apply-minor"].includes(candidate.policy.mode) ||
166
+ !Number.isFinite(candidate.policy.minimumReleaseAgeHours) ||
167
+ candidate.policy.minimumReleaseAgeHours < 0 ||
168
+ !Array.isArray(candidate.policy.approvedChannels) ||
169
+ !candidate.policy.approvedChannels.every((channel) => typeof channel === "string")) {
170
+ return null;
171
+ }
172
+ try {
173
+ targetPath(candidate.targetPathTemplate, "0.0.0");
174
+ const base = new URL(candidate.metadataBaseUrl);
175
+ if (base.protocol !== "https:" ||
176
+ !base.pathname.endsWith("/") ||
177
+ base.username !== "" ||
178
+ base.password !== "" ||
179
+ base.search !== "" ||
180
+ base.hash !== "")
181
+ return null;
182
+ const approved = new Set(candidate.approvedMirrorOrigins ?? []);
183
+ if (candidate.enterpriseMirrorOnly && !approved.has(base.origin))
184
+ return null;
185
+ if ((candidate.crossCheckMetadataBaseUrls?.length ?? 0) > MAX_CROSS_CHECK_MIRRORS)
186
+ return null;
187
+ for (const mirror of candidate.crossCheckMetadataBaseUrls ?? []) {
188
+ const parsed = new URL(mirror);
189
+ if (parsed.protocol !== "https:" ||
190
+ !parsed.pathname.endsWith("/") ||
191
+ parsed.username !== "" ||
192
+ parsed.password !== "" ||
193
+ parsed.search !== "" ||
194
+ parsed.hash !== "")
195
+ return null;
196
+ if (candidate.enterpriseMirrorOnly && !approved.has(parsed.origin))
197
+ return null;
198
+ }
199
+ for (const window of candidate.policy.maintenanceWindows ?? []) {
200
+ if (!Array.isArray(window.days) ||
201
+ window.days.length === 0 ||
202
+ !window.days.every((day) => Number.isInteger(day) && day >= 0 && day <= 6) ||
203
+ !/^([01]\d|2[0-3]):[0-5]\d$/.test(window.startUtc) ||
204
+ !/^([01]\d|2[0-3]):[0-5]\d$/.test(window.endUtc) ||
205
+ window.startUtc >= window.endUtc)
206
+ return null;
207
+ }
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ return candidate;
213
+ }
214
+ function loadConfig(options) {
215
+ if (options.config)
216
+ return validateConfig(options.config);
217
+ // The policy file is deliberately inert until the release team completes the
218
+ // offline root ceremony and the independently reviewed root + pin are embedded
219
+ // in the shipped package. Trusting a root path and its digest from the same
220
+ // mutable user file would collapse the trust boundary. Keep reading bounded so
221
+ // malformed files are harmless, but do not promote them to trust configuration.
222
+ safeJson(configPath(options.configHome));
223
+ return null;
224
+ }
225
+ function installMethod(options) {
226
+ return options.installMethod ?? "unknown";
227
+ }
228
+ function baseResult(options, status) {
229
+ return {
230
+ schemaVersion: 1,
231
+ status,
232
+ installedVersion: options.currentVersion,
233
+ installMethod: installMethod(options),
234
+ networkUsed: false,
235
+ };
236
+ }
237
+ function parseVersion(version) {
238
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
239
+ if (!match)
240
+ return null;
241
+ const prerelease = match[4]?.split(".") ?? null;
242
+ if (prerelease?.some((part) => part === "" || (/^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))))
243
+ return null;
244
+ return {
245
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
246
+ prerelease,
247
+ };
248
+ }
249
+ function compareVersions(left, right) {
250
+ const a = parseVersion(left);
251
+ const b = parseVersion(right);
252
+ if (!a || !b)
253
+ return compareStrings(left, right);
254
+ const core = compareCore(a, b);
255
+ return core === 0 ? comparePrereleases(a.prerelease, b.prerelease) : core;
256
+ }
257
+ function compareStrings(left, right) {
258
+ if (left === right)
259
+ return 0;
260
+ return left < right ? -1 : 1;
261
+ }
262
+ function compareCore(left, right) {
263
+ for (let index = 0; index < 3; index++) {
264
+ const difference = (left.core[index] ?? 0) - (right.core[index] ?? 0);
265
+ if (difference !== 0)
266
+ return difference;
267
+ }
268
+ return 0;
269
+ }
270
+ function comparePrereleases(left, right) {
271
+ if (left === null)
272
+ return right === null ? 0 : 1;
273
+ if (right === null)
274
+ return -1;
275
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
276
+ const difference = compareOptionalPrereleaseParts(left[index], right[index]);
277
+ if (difference !== 0)
278
+ return difference;
279
+ }
280
+ return 0;
281
+ }
282
+ function compareOptionalPrereleaseParts(left, right) {
283
+ if (left === undefined)
284
+ return right === undefined ? 0 : -1;
285
+ if (right === undefined)
286
+ return 1;
287
+ if (left === right)
288
+ return 0;
289
+ return comparePrereleaseParts(left, right);
290
+ }
291
+ function comparePrereleaseParts(left, right) {
292
+ const leftNumeric = /^\d+$/.test(left);
293
+ const rightNumeric = /^\d+$/.test(right);
294
+ if (leftNumeric && rightNumeric)
295
+ return Number(left) - Number(right);
296
+ if (leftNumeric !== rightNumeric)
297
+ return leftNumeric ? -1 : 1;
298
+ return compareStrings(left, right);
299
+ }
300
+ function targetPath(template, version) {
301
+ if (!parseVersion(version))
302
+ throw new Error("signed target version is not valid SemVer");
303
+ const rendered = template.replaceAll("{version}", version);
304
+ if (rendered.startsWith("/") ||
305
+ rendered.includes("\\") ||
306
+ rendered.split("/").some((part) => part === "" || part === "." || part === "..")) {
307
+ throw new Error("configured target template produced an unsafe path");
308
+ }
309
+ return rendered;
310
+ }
311
+ function selectCandidate(bundle, config) {
312
+ const candidates = Object.entries(bundle.targets.signed.targets)
313
+ .filter(([, target]) => target.custom.channel === config.channel)
314
+ .filter(([, target]) => parseVersion(target.custom.version) !== null)
315
+ .filter(([name, target]) => name === targetPath(config.targetPathTemplate, target.custom.version))
316
+ .sort(([, left], [, right]) => compareVersions(right.custom.version, left.custom.version));
317
+ const selected = candidates[0];
318
+ if (!selected)
319
+ throw new Error(`signed targets contain no ${config.channel} release`);
320
+ return selected[0];
321
+ }
322
+ async function defaultFetchBytes(url, options) {
323
+ const response = await fetch(url, {
324
+ signal: options.signal,
325
+ redirect: "error",
326
+ headers: { accept: "application/octet-stream, application/json" },
327
+ });
328
+ if (!response.ok)
329
+ throw new Error(`update source returned HTTP ${response.status}`);
330
+ const declared = Number(response.headers.get("content-length"));
331
+ if (Number.isFinite(declared) && declared > options.maxBytes)
332
+ throw new Error("update response exceeds byte limit");
333
+ if (!response.body)
334
+ return new Uint8Array();
335
+ const chunks = [];
336
+ let total = 0;
337
+ const reader = response.body.getReader();
338
+ while (true) {
339
+ const { done, value } = await reader.read();
340
+ if (done)
341
+ break;
342
+ total += value.byteLength;
343
+ if (total > options.maxBytes) {
344
+ await reader.cancel();
345
+ throw new Error("update response exceeds byte limit");
346
+ }
347
+ chunks.push(value);
348
+ }
349
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
350
+ }
351
+ function safeUrl(base, relative) {
352
+ const root = new URL(base);
353
+ const result = new URL(relative, root);
354
+ if (result.protocol !== "https:" ||
355
+ result.origin !== root.origin ||
356
+ !result.pathname.startsWith(root.pathname))
357
+ throw new Error("update URL escaped the approved HTTPS origin/path");
358
+ return result.href;
359
+ }
360
+ async function fetchWithDeadline(options, config, url, maxBytes) {
361
+ const controller = new AbortController();
362
+ let timeout;
363
+ try {
364
+ const deadline = new Promise((_resolve, reject) => {
365
+ timeout = setTimeout(() => {
366
+ controller.abort();
367
+ reject(new Error("update request exceeded its timeout"));
368
+ }, config.timeoutMs || DEFAULT_TIMEOUT_MS);
369
+ });
370
+ const bytes = await Promise.race([
371
+ (options.fetchBytes ?? defaultFetchBytes)(url, { signal: controller.signal, maxBytes }),
372
+ deadline,
373
+ ]);
374
+ if (bytes.byteLength > maxBytes)
375
+ throw new Error("update response exceeds byte limit");
376
+ return bytes;
377
+ }
378
+ finally {
379
+ if (timeout)
380
+ clearTimeout(timeout);
381
+ }
382
+ }
383
+ function parseEnvelope(bytes, label) {
384
+ try {
385
+ return JSON.parse(Buffer.from(bytes).toString("utf-8"));
386
+ }
387
+ catch {
388
+ throw new Error(`${label} is not valid JSON`);
389
+ }
390
+ }
391
+ async function fetchBundleAt(options, config, base) {
392
+ const records = {};
393
+ const digests = {};
394
+ for (const role of ["timestamp", "snapshot", "targets", "root"]) {
395
+ const bytes = await fetchWithDeadline(options, config, safeUrl(base, `${role}.json`), MAX_METADATA_BYTES);
396
+ records[role] = parseEnvelope(bytes, `${role}.json`);
397
+ digests[role] = createHash("sha256").update(bytes).digest("hex");
398
+ }
399
+ return { bundle: records, digests };
400
+ }
401
+ function rootVersion(value, label) {
402
+ if (!isRecord(value) || !isRecord(value.signed))
403
+ throw new Error(`${label} has no signed root`);
404
+ const version = value.signed.version;
405
+ if (!Number.isSafeInteger(version) || Number(version) <= 0)
406
+ throw new Error(`${label} has an invalid root version`);
407
+ return Number(version);
408
+ }
409
+ async function fetchRootChainAt(options, config, base, initialRoot, terminalRoot) {
410
+ const initialVersion = rootVersion(initialRoot, "trusted root");
411
+ const terminalVersion = rootVersion(terminalRoot, "root.json");
412
+ const gap = terminalVersion - initialVersion;
413
+ if (gap > MAX_ROOT_ROTATIONS_PER_CHECK) {
414
+ throw new Error(`root rotation gap ${gap} exceeds the per-check limit ${MAX_ROOT_ROTATIONS_PER_CHECK}`);
415
+ }
416
+ const roots = [];
417
+ const digests = {};
418
+ for (let version = initialVersion + 1; version <= terminalVersion; version++) {
419
+ const name = `${version}.root.json`;
420
+ const bytes = await fetchWithDeadline(options, config, safeUrl(base, name), MAX_METADATA_BYTES);
421
+ const root = parseEnvelope(bytes, name);
422
+ if (rootVersion(root, name) !== version)
423
+ throw new Error(`${name} has the wrong root version`);
424
+ roots.push(root);
425
+ digests[name] = createHash("sha256").update(bytes).digest("hex");
426
+ }
427
+ if (terminalVersion === initialVersion) {
428
+ const terminalDigest = createHash("sha256")
429
+ .update(canonicalizeUpdateMetadata(terminalRoot))
430
+ .digest("hex");
431
+ const initialDigest = createHash("sha256")
432
+ .update(canonicalizeUpdateMetadata(initialRoot))
433
+ .digest("hex");
434
+ if (terminalDigest !== initialDigest)
435
+ throw new Error("root.json disagrees with the pinned root at the same version");
436
+ }
437
+ else if (createHash("sha256")
438
+ .update(canonicalizeUpdateMetadata(roots.at(-1)))
439
+ .digest("hex") !==
440
+ createHash("sha256").update(canonicalizeUpdateMetadata(terminalRoot)).digest("hex")) {
441
+ throw new Error(`root.json disagrees with ${terminalVersion}.root.json`);
442
+ }
443
+ return { roots, digests };
444
+ }
445
+ async function fetchBundle(options, config, initialRoot, initialRootSha256, now) {
446
+ const primary = await fetchBundleAt(options, config, config.metadataBaseUrl);
447
+ const primaryRoots = await fetchRootChainAt(options, config, config.metadataBaseUrl, initialRoot, primary.bundle.root);
448
+ Object.assign(primary.digests, primaryRoots.digests);
449
+ for (const mirror of config.crossCheckMetadataBaseUrls ?? []) {
450
+ const checked = await fetchBundleAt(options, config, mirror);
451
+ const checkedRoots = await fetchRootChainAt(options, config, mirror, initialRoot, checked.bundle.root);
452
+ Object.assign(checked.digests, checkedRoots.digests);
453
+ const names = new Set([...Object.keys(primary.digests), ...Object.keys(checked.digests)]);
454
+ for (const name of names) {
455
+ if (checked.digests[name] !== primary.digests[name]) {
456
+ throw new Error(`cross-channel metadata mismatch for ${name}`);
457
+ }
458
+ }
459
+ }
460
+ const recoveredRoot = verifyUpdateRootChain(initialRoot, initialRootSha256, primaryRoots.roots, now);
461
+ return { bundle: primary.bundle, recoveredRoot, rootChain: primaryRoots.roots };
462
+ }
463
+ function trustedRoot(config) {
464
+ const value = safeJson(path.resolve(config.trustedRootPath));
465
+ if (!value)
466
+ throw new Error("trusted root is missing or unreadable");
467
+ return value;
468
+ }
469
+ function releaseAge(bundle, now) {
470
+ return Math.max(0, (now.getTime() - Date.parse(bundle.targets.signed.generatedAt)) / 3_600_000);
471
+ }
472
+ async function checkedUpdate(options) {
473
+ const config = loadConfig(options);
474
+ if (!config)
475
+ return baseResult(options, "trust-unconfigured");
476
+ const env = options.env ?? process.env;
477
+ if (!config.enabled || env.RECKON_UPDATE_CHECK === "0")
478
+ return baseResult(options, "disabled");
479
+ if (config.offline || env.RECKON_OFFLINE === "1")
480
+ return baseResult(options, "offline");
481
+ const now = options.now?.() ?? new Date();
482
+ const previous = readTrustedUpdateState(options.stateHome);
483
+ try {
484
+ const initialRoot = trustedRoot(config);
485
+ const { bundle, recoveredRoot, rootChain } = await fetchBundle(options, config, initialRoot, config.trustedRootSha256, now);
486
+ const selectedPath = selectCandidate(bundle, config);
487
+ const verified = verifyUpdateMetadataBundle(bundle, {
488
+ trustedRoot: recoveredRoot,
489
+ trustedRootSha256: createHash("sha256")
490
+ .update(canonicalizeUpdateMetadata(recoveredRoot))
491
+ .digest("hex"),
492
+ targetPath: selectedPath,
493
+ previous: previous?.trustedMetadata,
494
+ now,
495
+ });
496
+ const age = releaseAge(bundle, now);
497
+ const latest = verified.target.custom.version;
498
+ const state = {
499
+ schemaVersion: 1,
500
+ installedVersion: options.currentVersion,
501
+ latestTrustedVersion: latest,
502
+ channel: verified.target.custom.channel,
503
+ checkedAt: now.toISOString(),
504
+ releaseGeneratedAt: bundle.targets.signed.generatedAt,
505
+ targetPath: selectedPath,
506
+ trustedMetadata: verified.state,
507
+ trustedRoot: verified.rootEnvelope,
508
+ rollback: previous?.rollback,
509
+ lastApplied: previous?.lastApplied,
510
+ };
511
+ privateWrite(statePath(options.stateHome), state);
512
+ return {
513
+ config,
514
+ bundle,
515
+ recoveredRoot,
516
+ rootChain,
517
+ target: verified.target,
518
+ targetPath: selectedPath,
519
+ state,
520
+ result: {
521
+ ...baseResult(options, compareVersions(latest, options.currentVersion) > 0 ? "update-available" : "up-to-date"),
522
+ latestTrustedVersion: latest,
523
+ channel: verified.target.custom.channel,
524
+ releaseAgeHours: age,
525
+ checkedAt: now.toISOString(),
526
+ networkUsed: true,
527
+ acknowledged: config.acknowledgedVersion === latest,
528
+ },
529
+ };
530
+ }
531
+ catch (error) {
532
+ return {
533
+ ...baseResult(options, "check-failed"),
534
+ latestTrustedVersion: previous?.latestTrustedVersion,
535
+ channel: previous?.channel,
536
+ checkedAt: now.toISOString(),
537
+ networkUsed: true,
538
+ error: {
539
+ code: error instanceof Error && "code" in error ? String(error.code) : "verification-failed",
540
+ message: error instanceof Error ? error.message : String(error),
541
+ },
542
+ };
543
+ }
544
+ }
545
+ export function trustedUpdateStatus(options) {
546
+ const config = loadConfig(options);
547
+ if (!config)
548
+ return baseResult(options, "trust-unconfigured");
549
+ if (!config.enabled)
550
+ return baseResult(options, "disabled");
551
+ if (config.offline)
552
+ return baseResult(options, "offline");
553
+ const state = readTrustedUpdateState(options.stateHome);
554
+ if (!state)
555
+ return baseResult(options, "never-checked");
556
+ const now = options.now?.() ?? new Date();
557
+ return {
558
+ ...baseResult(options, compareVersions(state.latestTrustedVersion, options.currentVersion) > 0
559
+ ? "update-available"
560
+ : "up-to-date"),
561
+ latestTrustedVersion: state.latestTrustedVersion,
562
+ channel: state.channel,
563
+ releaseAgeHours: Math.max(0, (now.getTime() - Date.parse(state.releaseGeneratedAt)) / 3_600_000),
564
+ checkedAt: state.checkedAt,
565
+ acknowledged: config.acknowledgedVersion === state.latestTrustedVersion,
566
+ };
567
+ }
568
+ export async function checkTrustedUpdate(options) {
569
+ const checked = await checkedUpdate(options);
570
+ return "result" in checked ? checked.result : checked;
571
+ }
572
+ export function explainTrustedUpdate(options) {
573
+ const config = loadConfig(options);
574
+ return {
575
+ ...trustedUpdateStatus(options),
576
+ networkUsed: false,
577
+ policy: config?.policy ?? {
578
+ mode: "notify-only",
579
+ minimumReleaseAgeHours: 0,
580
+ approvedChannels: [],
581
+ },
582
+ metadataBaseUrl: config ? new URL(config.metadataBaseUrl).origin : null,
583
+ enterpriseMirrorOnly: config?.enterpriseMirrorOnly === true,
584
+ guarantees: [
585
+ "unsigned sources are never eligible",
586
+ "metadata and artifacts are bounded, signed, and digest-verified",
587
+ "repository identity, source paths, and usage data are never sent",
588
+ "manager-owned files are changed only through their owning package manager",
589
+ ],
590
+ limitations: [
591
+ "production use remains unavailable until the offline root ceremony installs a trust anchor",
592
+ "Homebrew activation is recommendation-only until a signed bottle rollback adapter is certified",
593
+ ],
594
+ };
595
+ }
596
+ function policyAllows(config, current, latest, age, now) {
597
+ if (!config.policy.approvedChannels.includes(config.channel))
598
+ return "release channel is not approved";
599
+ if (age < config.policy.minimumReleaseAgeHours)
600
+ return "release has not reached the minimum age";
601
+ if (config.policy.mode === "notify-only")
602
+ return "policy is notify-only";
603
+ if (config.policy.mode === "download-verify-only")
604
+ return null;
605
+ const from = parseVersion(current);
606
+ const to = parseVersion(latest);
607
+ if (!from || !to || compareVersions(latest, current) <= 0)
608
+ return "release is not a monotonic semantic version update";
609
+ if (from.core[0] !== to.core[0])
610
+ return "major updates require manual approval";
611
+ if (config.policy.mode === "apply-patch" && from.core[1] !== to.core[1])
612
+ return "policy permits patch releases only";
613
+ const windows = config.policy.maintenanceWindows;
614
+ if (windows && windows.length > 0) {
615
+ const hhmm = `${String(now.getUTCHours()).padStart(2, "0")}:${String(now.getUTCMinutes()).padStart(2, "0")}`;
616
+ if (!windows.some((window) => window.days.includes(now.getUTCDay()) && hhmm >= window.startUtc && hhmm < window.endUtc))
617
+ return "outside the configured maintenance window";
618
+ }
619
+ return null;
620
+ }
621
+ function requiredReleaseEvidenceDigests(custom) {
622
+ const candidates = {
623
+ provenance: custom.provenanceSha256,
624
+ sbom: custom.sbomSha256,
625
+ sbomAttestation: custom.sbomAttestationSha256,
626
+ releaseAttestation: custom.releaseAttestationSha256,
627
+ };
628
+ for (const [label, value] of Object.entries(candidates)) {
629
+ if (!value)
630
+ throw new Error(`signed ${label} digest is required before apply`);
631
+ }
632
+ return candidates;
633
+ }
634
+ function verifyReleaseEvidenceDigests(evidence, expected) {
635
+ for (const label of Object.keys(expected)) {
636
+ const actual = createHash("sha256").update(evidence[label]).digest("hex");
637
+ if (actual !== expected[label])
638
+ throw new Error(`${label} digest mismatch`);
639
+ }
640
+ }
641
+ function validateStoredReleasePaths(paths, quarantineRoot) {
642
+ const resolvedRoot = path.resolve(quarantineRoot);
643
+ const canonicalRoot = fs.realpathSync(resolvedRoot);
644
+ for (const [label, storedPath] of Object.entries(paths)) {
645
+ if (!storedPath.startsWith(`${resolvedRoot}${path.sep}`))
646
+ throw new Error(`${label} rollback evidence is outside quarantine`);
647
+ const expectedCanonical = path.join(canonicalRoot, path.relative(resolvedRoot, storedPath));
648
+ if (fs.realpathSync(storedPath) !== expectedCanonical)
649
+ throw new Error(`${label} rollback evidence traverses a quarantine symlink`);
650
+ const stat = fs.lstatSync(storedPath);
651
+ if (!stat.isFile() || stat.isSymbolicLink())
652
+ throw new Error(`${label} rollback evidence is not a regular file`);
653
+ const maximum = label === "artifact" ? MAX_ARTIFACT_BYTES : MAX_RELEASE_EVIDENCE_BYTES;
654
+ if (stat.size === 0 || stat.size > maximum)
655
+ throw new Error(`${label} rollback evidence exceeds its byte budget`);
656
+ }
657
+ }
658
+ async function quarantine(options, config, bundle, verifiedRoot, version) {
659
+ const releasePath = targetPath(config.targetPathTemplate, version);
660
+ const verified = verifyUpdateMetadataBundle(bundle, {
661
+ trustedRoot: verifiedRoot,
662
+ trustedRootSha256: createHash("sha256")
663
+ .update(canonicalizeUpdateMetadata(verifiedRoot))
664
+ .digest("hex"),
665
+ targetPath: releasePath,
666
+ now: options.now?.() ?? new Date(),
667
+ });
668
+ const requiredDigests = requiredReleaseEvidenceDigests(verified.target.custom);
669
+ const bytes = await fetchWithDeadline(options, config, safeUrl(config.metadataBaseUrl, releasePath), MAX_ARTIFACT_BYTES);
670
+ verifyTargetArtifact(bytes, verified.target);
671
+ const evidencePaths = {
672
+ provenance: `${releasePath}.provenance.json`,
673
+ sbom: `${releasePath}.sbom.cdx.json`,
674
+ sbomAttestation: `${releasePath}.sbom.attestation.json`,
675
+ releaseAttestation: `${releasePath}.release-attestation.json`,
676
+ };
677
+ const evidence = {
678
+ provenance: await fetchWithDeadline(options, config, safeUrl(config.metadataBaseUrl, evidencePaths.provenance), MAX_RELEASE_EVIDENCE_BYTES),
679
+ sbom: await fetchWithDeadline(options, config, safeUrl(config.metadataBaseUrl, evidencePaths.sbom), MAX_RELEASE_EVIDENCE_BYTES),
680
+ sbomAttestation: await fetchWithDeadline(options, config, safeUrl(config.metadataBaseUrl, evidencePaths.sbomAttestation), MAX_RELEASE_EVIDENCE_BYTES),
681
+ releaseAttestation: await fetchWithDeadline(options, config, safeUrl(config.metadataBaseUrl, evidencePaths.releaseAttestation), MAX_RELEASE_EVIDENCE_BYTES),
682
+ };
683
+ verifyReleaseEvidenceDigests(evidence, requiredDigests);
684
+ const attested = verifyReleaseAttestation(parseEnvelope(evidence.releaseAttestation, "release attestation"), {
685
+ artifact: bytes,
686
+ provenance: evidence.provenance,
687
+ sbom: evidence.sbom,
688
+ sbomAttestation: evidence.sbomAttestation,
689
+ });
690
+ if (attested.status !== "verified")
691
+ throw new Error(`release attestation is ${attested.status}; apply requires verified`);
692
+ if (attested.version !== verified.target.custom.version ||
693
+ attested.sourceCommit !== verified.target.custom.sourceCommit)
694
+ throw new Error("release attestation version or source commit mismatch");
695
+ if (attested.provenance.path !== path.basename(evidencePaths.provenance) ||
696
+ attested.sbom.path !== path.basename(evidencePaths.sbom) ||
697
+ attested.sbom.attestationPath !== path.basename(evidencePaths.sbomAttestation))
698
+ throw new Error("release attestation evidence path mismatch");
699
+ for (const mirror of config.crossCheckMetadataBaseUrls ?? []) {
700
+ const mirroredArtifact = await fetchWithDeadline(options, config, safeUrl(mirror, releasePath), MAX_ARTIFACT_BYTES);
701
+ verifyTargetArtifact(mirroredArtifact, verified.target);
702
+ for (const [label, relativePath] of Object.entries(evidencePaths)) {
703
+ const mirrored = await fetchWithDeadline(options, config, safeUrl(mirror, relativePath), MAX_RELEASE_EVIDENCE_BYTES);
704
+ const primaryDigest = createHash("sha256")
705
+ .update(evidence[label])
706
+ .digest("hex");
707
+ if (createHash("sha256").update(mirrored).digest("hex") !== primaryDigest)
708
+ throw new Error(`cross-channel ${label} mismatch`);
709
+ }
710
+ }
711
+ const root = path.join(path.dirname(statePath(options.stateHome)), "quarantine");
712
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
713
+ const rootStat = fs.lstatSync(root);
714
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
715
+ throw new Error("quarantine root is not a private directory");
716
+ try {
717
+ fs.chmodSync(root, 0o700);
718
+ }
719
+ catch {
720
+ // Best effort on filesystems without POSIX modes (exclusive writes remain mandatory).
721
+ }
722
+ const directory = fs.mkdtempSync(path.join(root, `${version}-`));
723
+ try {
724
+ fs.chmodSync(directory, 0o700);
725
+ }
726
+ catch {
727
+ // Best effort on filesystems without POSIX modes (exclusive writes remain mandatory).
728
+ }
729
+ const artifactPath = path.join(directory, path.basename(releasePath));
730
+ const provenancePath = `${artifactPath}.provenance.json`;
731
+ const sbomPath = `${artifactPath}.sbom.cdx.json`;
732
+ const sbomAttestationPath = `${artifactPath}.sbom.attestation.json`;
733
+ const releaseAttestationPath = `${artifactPath}.release-attestation.json`;
734
+ fs.writeFileSync(artifactPath, bytes, { mode: 0o600, flag: "wx" });
735
+ fs.writeFileSync(provenancePath, evidence.provenance, { mode: 0o600, flag: "wx" });
736
+ fs.writeFileSync(sbomPath, evidence.sbom, { mode: 0o600, flag: "wx" });
737
+ fs.writeFileSync(sbomAttestationPath, evidence.sbomAttestation, { mode: 0o600, flag: "wx" });
738
+ fs.writeFileSync(releaseAttestationPath, evidence.releaseAttestation, {
739
+ mode: 0o600,
740
+ flag: "wx",
741
+ });
742
+ return {
743
+ artifactPath,
744
+ provenancePath,
745
+ sbomPath,
746
+ sbomAttestationPath,
747
+ releaseAttestationPath,
748
+ targetPath: releasePath,
749
+ attestedRollback: attested.rollback,
750
+ };
751
+ }
752
+ function validateAttestedRollback(candidate, expectedVersion, rollback) {
753
+ const actualDigest = createHash("sha256")
754
+ .update(fs.readFileSync(rollback.artifactPath))
755
+ .digest("hex");
756
+ if (candidate.attestedRollback.version !== expectedVersion ||
757
+ candidate.attestedRollback.targetPath !== rollback.targetPath ||
758
+ candidate.attestedRollback.artifactSha256 !== actualDigest)
759
+ throw new Error("release attestation rollback target mismatch");
760
+ }
761
+ function managerArgv(method, artifactPath) {
762
+ if (method === "npm")
763
+ return ["npm", "install", "--global", "--ignore-scripts", artifactPath];
764
+ return null;
765
+ }
766
+ async function restoreWithManager(options, rollbackArgv, failureMessage) {
767
+ const runManager = options.runManager;
768
+ if (!runManager)
769
+ throw new Error("owning package manager adapter is unavailable");
770
+ const restored = await runManager(rollbackArgv);
771
+ if (restored.status !== 0)
772
+ throw new Error(failureMessage);
773
+ if (!(await (options.healthCheck ?? (async () => false))()))
774
+ throw new Error("automatic rollback completed but the restored installation is unhealthy");
775
+ return "rolled-back";
776
+ }
777
+ async function activateAndValidate(options, activationArgv, rollbackArgv) {
778
+ const runManager = options.runManager;
779
+ if (!runManager)
780
+ throw new Error("owning package manager adapter is unavailable");
781
+ const activation = await runManager(activationArgv);
782
+ if (activation.status !== 0)
783
+ return restoreWithManager(options, rollbackArgv, "package manager activation and automatic rollback both failed");
784
+ const healthy = await (options.healthCheck ?? (async () => false))();
785
+ if (healthy)
786
+ return "applied";
787
+ return restoreWithManager(options, rollbackArgv, "health check failed and automatic rollback failed");
788
+ }
789
+ export async function applyTrustedUpdate(options) {
790
+ const checked = await checkedUpdate(options);
791
+ if (!("result" in checked))
792
+ return checked;
793
+ if (checked.result.status !== "update-available")
794
+ return checked.result;
795
+ const now = options.now?.() ?? new Date();
796
+ const blocked = policyAllows(checked.config, options.currentVersion, checked.target.custom.version, checked.result.releaseAgeHours ?? 0, now);
797
+ if (blocked)
798
+ return { ...checked.result, status: "policy-blocked", remediation: blocked };
799
+ try {
800
+ const candidate = await quarantine(options, checked.config, checked.bundle, checked.recoveredRoot, checked.target.custom.version);
801
+ if (checked.config.policy.mode === "download-verify-only")
802
+ return { ...checked.result, status: "downloaded", artifactPath: candidate.artifactPath };
803
+ const rollback = await quarantine(options, checked.config, checked.bundle, checked.recoveredRoot, options.currentVersion);
804
+ validateAttestedRollback(candidate, options.currentVersion, rollback);
805
+ if (!(await (options.smokeTest ?? (async () => false))(candidate.artifactPath)))
806
+ return {
807
+ ...checked.result,
808
+ status: "policy-blocked",
809
+ error: { code: "smoke-failed", message: "quarantined artifact failed its smoke test" },
810
+ };
811
+ const argv = managerArgv(installMethod(options), candidate.artifactPath);
812
+ if (!argv || !options.runManager)
813
+ return {
814
+ ...checked.result,
815
+ status: "manager-action-required",
816
+ artifactPath: candidate.artifactPath,
817
+ remediation: "Use the owning package manager with the exact verified quarantined artifact.",
818
+ };
819
+ const { attestedRollback: _attestedRollback, ...rollbackState } = rollback;
820
+ const state = {
821
+ ...checked.state,
822
+ rollback: {
823
+ version: options.currentVersion,
824
+ ...rollbackState,
825
+ bundle: checked.bundle,
826
+ rootChain: checked.rootChain,
827
+ },
828
+ };
829
+ privateWrite(statePath(options.stateHome), state);
830
+ const rollbackArgv = managerArgv(installMethod(options), rollback.artifactPath);
831
+ if (!rollbackArgv)
832
+ throw new Error("owning package manager cannot consume rollback artifact");
833
+ const activationStatus = await activateAndValidate(options, argv, rollbackArgv);
834
+ if (activationStatus === "rolled-back")
835
+ return { ...checked.result, status: "rolled-back", artifactPath: rollback.artifactPath };
836
+ privateWrite(statePath(options.stateHome), {
837
+ ...state,
838
+ installedVersion: checked.target.custom.version,
839
+ lastApplied: {
840
+ version: checked.target.custom.version,
841
+ artifactPath: candidate.artifactPath,
842
+ appliedAt: now.toISOString(),
843
+ },
844
+ });
845
+ return { ...checked.result, status: "applied", artifactPath: candidate.artifactPath };
846
+ }
847
+ catch (error) {
848
+ return {
849
+ ...checked.result,
850
+ status: "policy-blocked",
851
+ error: {
852
+ code: "apply-refused",
853
+ message: error instanceof Error ? error.message : String(error),
854
+ },
855
+ };
856
+ }
857
+ }
858
+ function verifyStoredRollback(options, config, state) {
859
+ const rollback = state.rollback;
860
+ if (!rollback)
861
+ return false;
862
+ const quarantineRoot = path.join(path.dirname(statePath(options.stateHome)), "quarantine");
863
+ try {
864
+ const storedPaths = {
865
+ artifact: path.resolve(rollback.artifactPath),
866
+ provenance: path.resolve(rollback.provenancePath),
867
+ sbom: path.resolve(rollback.sbomPath),
868
+ sbomAttestation: path.resolve(rollback.sbomAttestationPath),
869
+ releaseAttestation: path.resolve(rollback.releaseAttestationPath),
870
+ };
871
+ validateStoredReleasePaths(storedPaths, quarantineRoot);
872
+ const initialRoot = trustedRoot(config);
873
+ const recoveredRoot = verifyUpdateRootChain(initialRoot, config.trustedRootSha256, rollback.rootChain, new Date(state.checkedAt));
874
+ const verified = verifyUpdateMetadataBundle(rollback.bundle, {
875
+ trustedRoot: recoveredRoot,
876
+ trustedRootSha256: createHash("sha256")
877
+ .update(canonicalizeUpdateMetadata(recoveredRoot))
878
+ .digest("hex"),
879
+ targetPath: rollback.targetPath,
880
+ // Rollback is an offline recovery operation. Revalidate the exact
881
+ // metadata at the time it was originally accepted, then validate the
882
+ // stored bytes again; a network freeze check belongs to update check.
883
+ now: new Date(state.checkedAt),
884
+ });
885
+ if (verified.target.custom.version !== rollback.version)
886
+ return false;
887
+ const bytes = {
888
+ artifact: fs.readFileSync(storedPaths.artifact),
889
+ provenance: fs.readFileSync(storedPaths.provenance),
890
+ sbom: fs.readFileSync(storedPaths.sbom),
891
+ sbomAttestation: fs.readFileSync(storedPaths.sbomAttestation),
892
+ releaseAttestation: fs.readFileSync(storedPaths.releaseAttestation),
893
+ };
894
+ verifyTargetArtifact(bytes.artifact, verified.target);
895
+ const expected = requiredReleaseEvidenceDigests(verified.target.custom);
896
+ verifyReleaseEvidenceDigests({
897
+ provenance: bytes.provenance,
898
+ sbom: bytes.sbom,
899
+ sbomAttestation: bytes.sbomAttestation,
900
+ releaseAttestation: bytes.releaseAttestation,
901
+ }, expected);
902
+ const attested = verifyReleaseAttestation(parseEnvelope(bytes.releaseAttestation, "release attestation"), {
903
+ artifact: bytes.artifact,
904
+ provenance: bytes.provenance,
905
+ sbom: bytes.sbom,
906
+ sbomAttestation: bytes.sbomAttestation,
907
+ });
908
+ return (attested.status === "verified" &&
909
+ attested.version === verified.target.custom.version &&
910
+ attested.sourceCommit === verified.target.custom.sourceCommit);
911
+ }
912
+ catch {
913
+ return false;
914
+ }
915
+ }
916
+ export async function rollbackTrustedUpdate(options) {
917
+ const state = readTrustedUpdateState(options.stateHome);
918
+ const config = loadConfig(options);
919
+ if (!config)
920
+ return baseResult(options, "trust-unconfigured");
921
+ if (!state?.rollback || !verifyStoredRollback(options, config, state))
922
+ return baseResult(options, "rollback-unavailable");
923
+ const argv = managerArgv(installMethod(options), state.rollback.artifactPath);
924
+ if (!argv || !options.runManager)
925
+ return {
926
+ ...baseResult(options, "manager-action-required"),
927
+ artifactPath: state.rollback.artifactPath,
928
+ };
929
+ const action = await options.runManager(argv);
930
+ if (action.status !== 0 || !(await (options.healthCheck ?? (async () => false))())) {
931
+ return {
932
+ ...baseResult(options, "rollback-unavailable"),
933
+ error: {
934
+ code: "rollback-failed",
935
+ message: "owning package manager or health check rejected rollback",
936
+ },
937
+ };
938
+ }
939
+ privateWrite(statePath(options.stateHome), {
940
+ ...state,
941
+ installedVersion: state.rollback.version,
942
+ });
943
+ return { ...baseResult(options, "rolled-back"), artifactPath: state.rollback.artifactPath };
944
+ }