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,533 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { canonicalizeUpdateMetadata } from "./update-trust.js";
3
+ export const RELEASE_CHANNELS = [
4
+ "public-npm",
5
+ "docusign-artifactory",
6
+ "github-saas",
7
+ "homebrew",
8
+ "ghes",
9
+ ];
10
+ const SHA256 = /^[a-f0-9]{64}$/;
11
+ const SHA512 = /^[a-f0-9]{128}$/;
12
+ const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
13
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
14
+ const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,255}$/;
15
+ const REPOSITORY = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
16
+ const RUN_ID = /^\d{1,32}$/;
17
+ const CYCLONEDX_SPEC_VERSION = /^1\.[4-6]$/;
18
+ const MAX_RELEASE_ARTIFACT_BYTES = 512 * 1_024 * 1_024;
19
+ const MAX_RELEASE_EVIDENCE_BYTES = 16 * 1_024 * 1_024;
20
+ const RELEASE_CHANNEL_HOSTS = {
21
+ "public-npm": "registry.npmjs.org",
22
+ "docusign-artifactory": "artifactory-corp.docusignhq.com",
23
+ "github-saas": "github.com",
24
+ homebrew: "github.com",
25
+ ghes: "github.docusignhq.com",
26
+ };
27
+ function fail(message) {
28
+ throw new Error(`invalid release attestation: ${message}`);
29
+ }
30
+ function record(value, label) {
31
+ if (value === null || typeof value !== "object" || Array.isArray(value))
32
+ fail(`${label} must be an object`);
33
+ return value;
34
+ }
35
+ function onlyKeys(value, allowed, label) {
36
+ const permitted = new Set(allowed);
37
+ for (const key of Object.keys(value)) {
38
+ if (!permitted.has(key))
39
+ fail(`${label} contains unsupported field ${key}`);
40
+ }
41
+ }
42
+ function text(value, label, maximum = 1_024) {
43
+ if (typeof value !== "string" || value.length === 0 || value.length > maximum)
44
+ fail(`${label} must be a non-empty bounded string`);
45
+ return value;
46
+ }
47
+ function integer(value, label, maximum = Number.MAX_SAFE_INTEGER) {
48
+ if (!Number.isSafeInteger(value) || value < 0 || value > maximum)
49
+ fail(`${label} must be a bounded non-negative integer`);
50
+ return value;
51
+ }
52
+ function instant(value, label) {
53
+ const candidate = text(value, label, 64);
54
+ const parsed = new Date(candidate);
55
+ if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== candidate)
56
+ fail(`${label} must be a canonical UTC instant`);
57
+ return candidate;
58
+ }
59
+ function digest(algorithm, bytes) {
60
+ return createHash(algorithm).update(bytes).digest("hex");
61
+ }
62
+ function digestMatches(expected, actual) {
63
+ const left = Buffer.from(expected, "hex");
64
+ const right = Buffer.from(actual, "hex");
65
+ return left.byteLength === right.byteLength && timingSafeEqual(left, right);
66
+ }
67
+ function boundedBytes(bytes, label, maximum) {
68
+ if (!(bytes instanceof Uint8Array))
69
+ fail(`${label} must be bytes`);
70
+ if (bytes.byteLength === 0 || bytes.byteLength > maximum)
71
+ fail(`${label} exceeds its non-empty byte budget`);
72
+ }
73
+ function jsonRecord(bytes, label) {
74
+ try {
75
+ return record(JSON.parse(Buffer.from(bytes).toString("utf-8")), label);
76
+ }
77
+ catch (error) {
78
+ if (error instanceof Error && error.message.startsWith("invalid release attestation:"))
79
+ throw error;
80
+ return fail(`${label} must be valid JSON`);
81
+ }
82
+ }
83
+ function safeEvidencePath(value, label) {
84
+ if (!SAFE_FILE.test(value) || value === "." || value === "..")
85
+ fail(`${label} must be a safe file name`);
86
+ return value;
87
+ }
88
+ function httpsUrl(value, label) {
89
+ const candidate = text(value, label, 2_048);
90
+ let parsed;
91
+ try {
92
+ parsed = new URL(candidate);
93
+ }
94
+ catch {
95
+ return fail(`${label} must be an HTTPS URL`);
96
+ }
97
+ if (parsed.protocol !== "https:" ||
98
+ parsed.username !== "" ||
99
+ parsed.password !== "" ||
100
+ parsed.hash !== "" ||
101
+ parsed.port !== "")
102
+ fail(`${label} must be a canonical HTTPS URL without credentials, a fragment, or a port`);
103
+ if (parsed.search !== "")
104
+ fail(`${label} must not contain a query that could disclose credentials`);
105
+ return parsed.toString();
106
+ }
107
+ function validateIdentity(verification, build, label) {
108
+ const value = record(verification, `${label} verification`);
109
+ onlyKeys(value, ["verified", "attestationId", "attestationUrl", "issuer", "identity", "verifiedAt"], `${label} verification`);
110
+ if (typeof verification.verified !== "boolean")
111
+ fail(`${label} verified must be boolean`);
112
+ const attestationId = text(verification.attestationId, `${label} attestation ID`, 256);
113
+ const attestationUrl = new URL(httpsUrl(verification.attestationUrl, `${label} attestation URL`));
114
+ const expectedPath = `/${build.repository}/attestations/${encodeURIComponent(attestationId)}`;
115
+ if (attestationUrl.hostname !== "github.com" || attestationUrl.pathname !== expectedPath)
116
+ fail(`${label} attestation URL does not match its repository and attestation ID`);
117
+ if (verification.issuer !== "https://token.actions.githubusercontent.com")
118
+ fail(`${label} has an unapproved signature issuer`);
119
+ const expectedIdentity = `https://github.com/${build.repository}/${build.workflow}@${build.workflowRef}`;
120
+ if (verification.identity !== expectedIdentity)
121
+ fail(`${label} signature identity does not match build`);
122
+ instant(verification.verifiedAt, `${label} verification time`);
123
+ }
124
+ function validateBuild(build, tag) {
125
+ const value = record(build, "build");
126
+ onlyKeys(value, ["repository", "workflow", "workflowRef", "runId", "runAttempt"], "build");
127
+ if (!REPOSITORY.test(build.repository))
128
+ fail("build repository must be an owner/name slug");
129
+ if (!/^\.github\/workflows\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.ya?ml$/.test(build.workflow))
130
+ fail("build workflow must be a repository workflow path");
131
+ if (build.workflowRef !== `refs/tags/${tag}`)
132
+ fail("build workflow ref must equal the release tag");
133
+ if (!RUN_ID.test(build.runId))
134
+ fail("build run ID must be decimal");
135
+ integer(build.runAttempt, "build run attempt", 1_000);
136
+ if (build.runAttempt < 1)
137
+ fail("build run attempt must be positive");
138
+ return { ...build };
139
+ }
140
+ function artifactEvidence(name, bytes) {
141
+ safeEvidencePath(name, "artifact name");
142
+ boundedBytes(bytes, "artifact", MAX_RELEASE_ARTIFACT_BYTES);
143
+ const sha512 = digest("sha512", bytes);
144
+ return {
145
+ name,
146
+ length: bytes.byteLength,
147
+ sha256: digest("sha256", bytes),
148
+ sha512,
149
+ npmIntegrity: `sha512-${Buffer.from(sha512, "hex").toString("base64")}`,
150
+ };
151
+ }
152
+ function channelEvidence(observation, artifact, version, sourceCommit) {
153
+ if (!observation?.artifactBytes) {
154
+ return { evidence: { status: "missing" }, failure: "channel evidence is missing" };
155
+ }
156
+ boundedBytes(observation.artifactBytes, `${observation.channel} artifact`, MAX_RELEASE_ARTIFACT_BYTES);
157
+ const location = httpsUrl(observation.location, `${observation.channel} location`);
158
+ if (new URL(location).hostname !== RELEASE_CHANNEL_HOSTS[observation.channel])
159
+ fail(`${observation.channel} location must use its approved host`);
160
+ const observed = {
161
+ status: "verified",
162
+ artifactLength: observation.artifactBytes.byteLength,
163
+ artifactSha256: digest("sha256", observation.artifactBytes),
164
+ version: text(observation.version, `${observation.channel} version`, 128),
165
+ sourceCommit: text(observation.sourceCommit, `${observation.channel} source commit`, 64),
166
+ publishedAt: instant(observation.publishedAt, `${observation.channel} publication time`),
167
+ location,
168
+ };
169
+ if (!SEMVER.test(observed.version ?? ""))
170
+ fail(`${observation.channel} channel version must be a semantic version`);
171
+ if (!COMMIT.test(observed.sourceCommit ?? ""))
172
+ fail(`${observation.channel} channel source commit must be a full lowercase digest`);
173
+ if (observed.artifactLength !== artifact.length ||
174
+ !digestMatches(observed.artifactSha256 ?? "", artifact.sha256)) {
175
+ observed.status = "mismatch";
176
+ return { evidence: observed, failure: "artifact digest or length differs" };
177
+ }
178
+ if (observed.version !== version) {
179
+ observed.status = "mismatch";
180
+ return { evidence: observed, failure: "version differs" };
181
+ }
182
+ if (observed.sourceCommit !== sourceCommit) {
183
+ observed.status = "mismatch";
184
+ return { evidence: observed, failure: "source commit differs" };
185
+ }
186
+ return { evidence: observed };
187
+ }
188
+ function derivedStatus(channels, provenanceVerified, sbomVerified, artifact, version, sourceCommit) {
189
+ const failures = [];
190
+ if (!provenanceVerified)
191
+ failures.push("build provenance signature is not verified");
192
+ if (!sbomVerified)
193
+ failures.push("SBOM signature is not verified");
194
+ let mismatch = false;
195
+ for (const channel of RELEASE_CHANNELS) {
196
+ const evidence = channels[channel];
197
+ if (evidence.status === "missing")
198
+ failures.push(`${channel}: channel evidence is missing`);
199
+ if (evidence.status === "mismatch") {
200
+ mismatch = true;
201
+ let reason = "channel evidence is malformed";
202
+ if (evidence.artifactLength !== artifact.length ||
203
+ !evidence.artifactSha256 ||
204
+ !digestMatches(evidence.artifactSha256, artifact.sha256))
205
+ reason = "artifact digest or length differs";
206
+ else if (evidence.version !== version)
207
+ reason = "version differs";
208
+ else if (evidence.sourceCommit !== sourceCommit)
209
+ reason = "source commit differs";
210
+ failures.push(`${channel}: ${reason}`);
211
+ }
212
+ }
213
+ return {
214
+ status: releaseStatus(mismatch, failures),
215
+ failures,
216
+ };
217
+ }
218
+ function releaseStatus(mismatch, failures) {
219
+ if (mismatch)
220
+ return "quarantined";
221
+ if (failures.length === 0)
222
+ return "verified";
223
+ return "incomplete";
224
+ }
225
+ function validateBuildEvidenceInput(input) {
226
+ if (!SEMVER.test(input.version))
227
+ fail("version must be a semantic version");
228
+ if (input.tag !== `v${input.version}`)
229
+ fail("tag must equal v plus the semantic version");
230
+ if (!COMMIT.test(input.sourceCommit))
231
+ fail("source commit must be a full lowercase digest");
232
+ const generatedAt = instant(input.generatedAt, "generation time");
233
+ const build = validateBuild(input.build, input.tag);
234
+ const expectedArtifactName = `knodin-${input.version}.tgz`;
235
+ if (input.artifact.name !== expectedArtifactName)
236
+ fail(`artifact name must be ${expectedArtifactName}`);
237
+ const artifact = artifactEvidence(input.artifact.name, input.artifact.bytes);
238
+ boundedBytes(input.provenance.bytes, "build provenance", MAX_RELEASE_EVIDENCE_BYTES);
239
+ jsonRecord(input.provenance.bytes, "build provenance");
240
+ safeEvidencePath(input.provenance.path, "build provenance path");
241
+ validateIdentity(input.provenance.verification, build, "build provenance");
242
+ boundedBytes(input.sbom.bytes, "SBOM", MAX_RELEASE_EVIDENCE_BYTES);
243
+ const sbomJson = jsonRecord(input.sbom.bytes, "SBOM");
244
+ if (sbomJson.bomFormat !== "CycloneDX")
245
+ fail("SBOM must use CycloneDX");
246
+ const specVersion = text(sbomJson.specVersion, "SBOM specification version", 16);
247
+ if (!CYCLONEDX_SPEC_VERSION.test(specVersion))
248
+ fail("SBOM must use a supported CycloneDX specification version");
249
+ safeEvidencePath(input.sbom.path, "SBOM path");
250
+ boundedBytes(input.sbom.attestationBytes, "SBOM attestation", MAX_RELEASE_EVIDENCE_BYTES);
251
+ jsonRecord(input.sbom.attestationBytes, "SBOM attestation");
252
+ safeEvidencePath(input.sbom.attestationPath, "SBOM attestation path");
253
+ validateIdentity(input.sbom.verification, build, "SBOM");
254
+ return { generatedAt, build, artifact, specVersion };
255
+ }
256
+ function collectChannelEvidence(input, artifact) {
257
+ const observations = new Map();
258
+ for (const observation of input.channels) {
259
+ if (!RELEASE_CHANNELS.includes(observation.channel))
260
+ fail(`unknown release channel ${observation.channel}`);
261
+ if (observations.has(observation.channel))
262
+ fail(`duplicate release channel ${observation.channel}`);
263
+ observations.set(observation.channel, observation);
264
+ }
265
+ const channels = {};
266
+ const failures = new Map();
267
+ for (const channel of RELEASE_CHANNELS) {
268
+ const outcome = channelEvidence(observations.get(channel), artifact, input.version, input.sourceCommit);
269
+ channels[channel] = outcome.evidence;
270
+ if (outcome.failure)
271
+ failures.set(channel, outcome.failure);
272
+ }
273
+ return { channels, failures };
274
+ }
275
+ function validateRollbackInput(input) {
276
+ if (!SEMVER.test(input.rollback.version) || input.rollback.version === input.version)
277
+ fail("rollback version must be a different semantic version");
278
+ safeEvidencePath(input.rollback.targetPath, "rollback target path");
279
+ if (!SHA256.test(input.rollback.artifactSha256))
280
+ fail("rollback digest must be lowercase SHA-256");
281
+ }
282
+ export function buildReleaseAttestation(input) {
283
+ const { generatedAt, build, artifact, specVersion } = validateBuildEvidenceInput(input);
284
+ const { channels, failures: channelFailures } = collectChannelEvidence(input, artifact);
285
+ validateRollbackInput(input);
286
+ const derived = derivedStatus(channels, input.provenance.verification.verified, input.sbom.verification.verified, artifact, input.version, input.sourceCommit);
287
+ for (const [channel, reason] of channelFailures) {
288
+ const generic = derived.failures.findIndex((failure) => failure.startsWith(`${channel}:`));
289
+ if (generic >= 0)
290
+ derived.failures[generic] = `${channel}: ${reason}`;
291
+ }
292
+ return {
293
+ schemaVersion: 1,
294
+ status: derived.status,
295
+ version: input.version,
296
+ tag: input.tag,
297
+ sourceCommit: input.sourceCommit,
298
+ generatedAt,
299
+ build,
300
+ artifact,
301
+ provenance: {
302
+ path: input.provenance.path,
303
+ length: input.provenance.bytes.byteLength,
304
+ sha256: digest("sha256", input.provenance.bytes),
305
+ verification: { ...input.provenance.verification },
306
+ },
307
+ sbom: {
308
+ format: "CycloneDX",
309
+ specVersion,
310
+ path: input.sbom.path,
311
+ length: input.sbom.bytes.byteLength,
312
+ sha256: digest("sha256", input.sbom.bytes),
313
+ attestationPath: input.sbom.attestationPath,
314
+ attestationLength: input.sbom.attestationBytes.byteLength,
315
+ attestationSha256: digest("sha256", input.sbom.attestationBytes),
316
+ verification: { ...input.sbom.verification },
317
+ },
318
+ channels,
319
+ rollback: { ...input.rollback },
320
+ failures: derived.failures,
321
+ };
322
+ }
323
+ export function canonicalizeReleaseAttestation(value) {
324
+ return canonicalizeUpdateMetadata(value);
325
+ }
326
+ function validateDigest(value, label, expression) {
327
+ const candidate = text(value, label, expression === SHA512 ? 128 : 64);
328
+ if (!expression.test(candidate))
329
+ fail(`${label} must be a lowercase digest`);
330
+ return candidate;
331
+ }
332
+ function validateArtifact(value) {
333
+ const artifact = record(value, "artifact");
334
+ onlyKeys(artifact, ["name", "length", "sha256", "sha512", "npmIntegrity"], "artifact");
335
+ const sha512 = validateDigest(artifact.sha512, "artifact SHA-512", SHA512);
336
+ const npmIntegrity = text(artifact.npmIntegrity, "npm integrity", 256);
337
+ const expectedIntegrity = `sha512-${Buffer.from(sha512, "hex").toString("base64")}`;
338
+ if (npmIntegrity !== expectedIntegrity)
339
+ fail("npm integrity does not match artifact SHA-512");
340
+ return {
341
+ name: safeEvidencePath(text(artifact.name, "artifact name", 256), "artifact name"),
342
+ length: integer(artifact.length, "artifact length", MAX_RELEASE_ARTIFACT_BYTES),
343
+ sha256: validateDigest(artifact.sha256, "artifact SHA-256", SHA256),
344
+ sha512,
345
+ npmIntegrity,
346
+ };
347
+ }
348
+ function validateChannelEvidence(value, channel, artifact, version, sourceCommit) {
349
+ const evidence = record(value, `${channel} channel`);
350
+ onlyKeys(evidence, [
351
+ "status",
352
+ "artifactLength",
353
+ "artifactSha256",
354
+ "version",
355
+ "sourceCommit",
356
+ "publishedAt",
357
+ "location",
358
+ ], `${channel} channel`);
359
+ if (evidence.status === "missing") {
360
+ if (Object.keys(evidence).length !== 1)
361
+ fail(`${channel} missing evidence contains observations`);
362
+ return { status: "missing" };
363
+ }
364
+ if (evidence.status !== "verified" && evidence.status !== "mismatch")
365
+ fail(`${channel} has an invalid status`);
366
+ const location = httpsUrl(evidence.location, `${channel} location`);
367
+ if (new URL(location).hostname !== RELEASE_CHANNEL_HOSTS[channel])
368
+ fail(`${channel} location must use its approved host`);
369
+ const result = {
370
+ status: evidence.status,
371
+ artifactLength: integer(evidence.artifactLength, `${channel} artifact length`, MAX_RELEASE_ARTIFACT_BYTES),
372
+ artifactSha256: validateDigest(evidence.artifactSha256, `${channel} artifact SHA-256`, SHA256),
373
+ version: text(evidence.version, `${channel} version`, 128),
374
+ sourceCommit: text(evidence.sourceCommit, `${channel} source commit`, 64),
375
+ publishedAt: instant(evidence.publishedAt, `${channel} publication time`),
376
+ location,
377
+ };
378
+ if (!SEMVER.test(result.version ?? ""))
379
+ fail(`${channel} channel version must be semantic`);
380
+ if (!COMMIT.test(result.sourceCommit ?? ""))
381
+ fail(`${channel} channel source commit must be a full lowercase digest`);
382
+ const shouldMatch = result.artifactLength === artifact.length &&
383
+ digestMatches(result.artifactSha256 ?? "", artifact.sha256) &&
384
+ result.version === version &&
385
+ result.sourceCommit === sourceCommit;
386
+ if ((result.status === "verified") !== shouldMatch)
387
+ fail(`${channel} status does not match its artifact, version, and source evidence`);
388
+ return result;
389
+ }
390
+ function validateVerification(value, build, label) {
391
+ const verification = record(value, `${label} verification`);
392
+ validateIdentity(verification, build, label);
393
+ return { ...verification };
394
+ }
395
+ function validateAttestationHeader(attestation) {
396
+ if (attestation.schemaVersion !== 1)
397
+ fail("schema version must be 1");
398
+ const version = text(attestation.version, "version", 128);
399
+ if (!SEMVER.test(version))
400
+ fail("version must be a semantic version");
401
+ const tag = text(attestation.tag, "tag", 129);
402
+ if (tag !== `v${version}`)
403
+ fail("tag must equal v plus the semantic version");
404
+ const sourceCommit = text(attestation.sourceCommit, "source commit", 64);
405
+ if (!COMMIT.test(sourceCommit))
406
+ fail("source commit must be a full lowercase digest");
407
+ const generatedAt = instant(attestation.generatedAt, "generation time");
408
+ const build = validateBuild(record(attestation.build, "build"), tag);
409
+ const artifact = validateArtifact(attestation.artifact);
410
+ if (artifact.name !== `knodin-${version}.tgz`)
411
+ fail("artifact name does not match version");
412
+ return { version, tag, sourceCommit, generatedAt, build, artifact };
413
+ }
414
+ export function verifyReleaseAttestation(value, bytes) {
415
+ const attestation = record(value, "release attestation");
416
+ onlyKeys(attestation, [
417
+ "schemaVersion",
418
+ "status",
419
+ "version",
420
+ "tag",
421
+ "sourceCommit",
422
+ "generatedAt",
423
+ "build",
424
+ "artifact",
425
+ "provenance",
426
+ "sbom",
427
+ "channels",
428
+ "rollback",
429
+ "failures",
430
+ ], "release attestation");
431
+ const { version, tag, sourceCommit, generatedAt, build, artifact } = validateAttestationHeader(attestation);
432
+ const provenanceRecord = record(attestation.provenance, "provenance");
433
+ onlyKeys(provenanceRecord, ["path", "length", "sha256", "verification"], "provenance");
434
+ const provenance = {
435
+ path: safeEvidencePath(text(provenanceRecord.path, "provenance path", 256), "provenance path"),
436
+ length: integer(provenanceRecord.length, "provenance length", MAX_RELEASE_EVIDENCE_BYTES),
437
+ sha256: validateDigest(provenanceRecord.sha256, "provenance SHA-256", SHA256),
438
+ verification: validateVerification(provenanceRecord.verification, build, "build provenance"),
439
+ };
440
+ const sbomRecord = record(attestation.sbom, "SBOM");
441
+ onlyKeys(sbomRecord, [
442
+ "format",
443
+ "specVersion",
444
+ "path",
445
+ "length",
446
+ "sha256",
447
+ "attestationPath",
448
+ "attestationLength",
449
+ "attestationSha256",
450
+ "verification",
451
+ ], "SBOM");
452
+ if (sbomRecord.format !== "CycloneDX")
453
+ fail("SBOM format must be CycloneDX");
454
+ const sbom = {
455
+ format: "CycloneDX",
456
+ specVersion: text(sbomRecord.specVersion, "SBOM specification version", 16),
457
+ path: safeEvidencePath(text(sbomRecord.path, "SBOM path", 256), "SBOM path"),
458
+ length: integer(sbomRecord.length, "SBOM length", MAX_RELEASE_EVIDENCE_BYTES),
459
+ sha256: validateDigest(sbomRecord.sha256, "SBOM SHA-256", SHA256),
460
+ attestationPath: safeEvidencePath(text(sbomRecord.attestationPath, "SBOM attestation path", 256), "SBOM attestation path"),
461
+ attestationLength: integer(sbomRecord.attestationLength, "SBOM attestation length", MAX_RELEASE_EVIDENCE_BYTES),
462
+ attestationSha256: validateDigest(sbomRecord.attestationSha256, "SBOM attestation SHA-256", SHA256),
463
+ verification: validateVerification(sbomRecord.verification, build, "SBOM"),
464
+ };
465
+ if (!CYCLONEDX_SPEC_VERSION.test(sbom.specVersion))
466
+ fail("SBOM must use a supported CycloneDX specification version");
467
+ boundedBytes(bytes.artifact, "artifact", MAX_RELEASE_ARTIFACT_BYTES);
468
+ boundedBytes(bytes.provenance, "provenance", MAX_RELEASE_EVIDENCE_BYTES);
469
+ boundedBytes(bytes.sbom, "SBOM", MAX_RELEASE_EVIDENCE_BYTES);
470
+ boundedBytes(bytes.sbomAttestation, "SBOM attestation", MAX_RELEASE_EVIDENCE_BYTES);
471
+ if (bytes.artifact.byteLength !== artifact.length ||
472
+ !digestMatches(digest("sha256", bytes.artifact), artifact.sha256) ||
473
+ !digestMatches(digest("sha512", bytes.artifact), artifact.sha512))
474
+ fail("artifact digest or length mismatch");
475
+ if (bytes.provenance.byteLength !== provenance.length ||
476
+ !digestMatches(digest("sha256", bytes.provenance), provenance.sha256))
477
+ fail("provenance digest or length mismatch");
478
+ if (bytes.sbom.byteLength !== sbom.length ||
479
+ !digestMatches(digest("sha256", bytes.sbom), sbom.sha256))
480
+ fail("SBOM digest or length mismatch");
481
+ if (bytes.sbomAttestation.byteLength !== sbom.attestationLength ||
482
+ !digestMatches(digest("sha256", bytes.sbomAttestation), sbom.attestationSha256))
483
+ fail("SBOM attestation digest or length mismatch");
484
+ const sbomJson = jsonRecord(bytes.sbom, "SBOM");
485
+ if (sbomJson.bomFormat !== sbom.format || sbomJson.specVersion !== sbom.specVersion)
486
+ fail("SBOM format metadata does not match its bytes");
487
+ jsonRecord(bytes.provenance, "provenance");
488
+ jsonRecord(bytes.sbomAttestation, "SBOM attestation");
489
+ const channelRecord = record(attestation.channels, "channels");
490
+ onlyKeys(channelRecord, RELEASE_CHANNELS, "channels");
491
+ const channels = {};
492
+ for (const channel of RELEASE_CHANNELS) {
493
+ if (!(channel in channelRecord))
494
+ fail(`channels is missing ${channel}`);
495
+ channels[channel] = validateChannelEvidence(channelRecord[channel], channel, artifact, version, sourceCommit);
496
+ }
497
+ const rollbackRecord = record(attestation.rollback, "rollback");
498
+ onlyKeys(rollbackRecord, ["version", "targetPath", "artifactSha256"], "rollback");
499
+ const rollback = {
500
+ version: text(rollbackRecord.version, "rollback version", 128),
501
+ targetPath: safeEvidencePath(text(rollbackRecord.targetPath, "rollback target path", 256), "rollback target path"),
502
+ artifactSha256: validateDigest(rollbackRecord.artifactSha256, "rollback artifact SHA-256", SHA256),
503
+ };
504
+ if (!SEMVER.test(rollback.version) || rollback.version === version)
505
+ fail("rollback version must be a different semantic version");
506
+ if (!Array.isArray(attestation.failures) || attestation.failures.length > 100)
507
+ fail("failures must be a bounded array");
508
+ const failures = attestation.failures.map((failure, index) => text(failure, `failure ${index}`, 512));
509
+ const derived = derivedStatus(channels, provenance.verification.verified, sbom.verification.verified, artifact, version, sourceCommit);
510
+ const serializedStatus = attestation.status;
511
+ if (serializedStatus !== derived.status)
512
+ fail("attestation status does not match its evidence");
513
+ // Failure details are descriptive evidence. Require the same set so a producer
514
+ // cannot conceal an incomplete or quarantined channel behind a truthful status.
515
+ if (failures.length !== derived.failures.length ||
516
+ failures.some((failure, index) => failure !== derived.failures[index]))
517
+ fail("attestation failures do not match its evidence");
518
+ return {
519
+ schemaVersion: 1,
520
+ status: derived.status,
521
+ version,
522
+ tag,
523
+ sourceCommit,
524
+ generatedAt,
525
+ build,
526
+ artifact,
527
+ provenance,
528
+ sbom,
529
+ channels,
530
+ rollback,
531
+ failures,
532
+ };
533
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { clearLine as clearTerminalLine, moveCursor as moveTerminalCursor, cursorTo as moveTerminalCursorTo, } from "node:readline";
3
+ import ora from "ora";
4
+ import { normalizeTerminalDimension } from "./init-progress.js";
5
+ import { formatRepairProgress } from "./repair-progress.js";
6
+ const terminalEnabled = process.stderr.isTTY === true || process.env.RECKON_FORCE_PROGRESS_TTY === "1";
7
+ const progressStream = {
8
+ isTTY: terminalEnabled,
9
+ columns: normalizeTerminalDimension(process.stderr.columns, 80),
10
+ rows: normalizeTerminalDimension(process.stderr.rows, 24),
11
+ write: process.stderr.write.bind(process.stderr),
12
+ cursorTo: (x, y, callback) => moveTerminalCursorTo(process.stderr, x, y, callback),
13
+ moveCursor: (dx, dy, callback) => moveTerminalCursor(process.stderr, dx, dy, callback),
14
+ clearLine: (direction, callback) => clearTerminalLine(process.stderr, direction, callback),
15
+ once: process.stderr.once.bind(process.stderr),
16
+ removeListener: process.stderr.removeListener.bind(process.stderr),
17
+ };
18
+ const spinner = ora({
19
+ text: "[repair:starting] Opening local graph database (0s elapsed)",
20
+ stream: progressStream,
21
+ isEnabled: terminalEnabled,
22
+ discardStdin: false,
23
+ });
24
+ let buffer = "";
25
+ let stopped = false;
26
+ let exiting = false;
27
+ let startedAt = 0;
28
+ let current = "[repair:starting] Opening local graph database";
29
+ let clock;
30
+ let deferredStop;
31
+ const MINIMUM_VISIBLE_MS = 150;
32
+ function refreshText() {
33
+ const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1_000));
34
+ spinner.text = `${current} (${elapsed}s elapsed)`;
35
+ }
36
+ function start() {
37
+ if (spinner.isSpinning)
38
+ return;
39
+ startedAt = Date.now();
40
+ refreshText();
41
+ spinner.start();
42
+ // A healthy graph can finish before Ora's first interval tick. Render once
43
+ // synchronously so even fast repairs provide real interactive feedback.
44
+ spinner.render();
45
+ clock = setInterval(refreshText, 250);
46
+ }
47
+ function finishStop(exitAfter) {
48
+ if (!stopped) {
49
+ stopped = true;
50
+ if (deferredStop)
51
+ clearTimeout(deferredStop);
52
+ if (clock)
53
+ clearInterval(clock);
54
+ spinner.stop();
55
+ }
56
+ if (exitAfter && !exiting) {
57
+ exiting = true;
58
+ const exitTimer = setTimeout(() => process.exit(0), 100);
59
+ process.stderr.write("\r\x1b[2K", () => {
60
+ clearTimeout(exitTimer);
61
+ process.exit(0);
62
+ });
63
+ }
64
+ }
65
+ function stop(exitAfter = false) {
66
+ const remaining = MINIMUM_VISIBLE_MS - (Date.now() - startedAt);
67
+ if (exitAfter && spinner.isSpinning && remaining > 0) {
68
+ if (!deferredStop)
69
+ deferredStop = setTimeout(() => {
70
+ deferredStop = undefined;
71
+ finishStop(true);
72
+ }, remaining);
73
+ return;
74
+ }
75
+ finishStop(exitAfter);
76
+ }
77
+ function handle(line) {
78
+ if (!line)
79
+ return;
80
+ const message = JSON.parse(line);
81
+ if (message.type === "start")
82
+ start();
83
+ else if (message.type === "progress") {
84
+ if (!spinner.isSpinning)
85
+ start();
86
+ current = formatRepairProgress(message.event);
87
+ refreshText();
88
+ }
89
+ else
90
+ stop(true);
91
+ }
92
+ process.stdin.setEncoding("utf8");
93
+ process.stdin.on("data", (chunk) => {
94
+ buffer += chunk;
95
+ for (;;) {
96
+ const newline = buffer.indexOf("\n");
97
+ if (newline < 0)
98
+ break;
99
+ const line = buffer.slice(0, newline);
100
+ buffer = buffer.slice(newline + 1);
101
+ try {
102
+ handle(line);
103
+ }
104
+ catch {
105
+ // Progress transport is observational; malformed input cannot affect repair.
106
+ }
107
+ }
108
+ });
109
+ process.stdin.on("end", () => {
110
+ if (buffer) {
111
+ try {
112
+ handle(buffer);
113
+ }
114
+ catch { }
115
+ }
116
+ stop(true);
117
+ });
118
+ process.on("SIGTERM", () => {
119
+ stop();
120
+ process.exit(0);
121
+ });