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,503 @@
1
+ import { createHash, createPublicKey, verify as cryptoVerify, timingSafeEqual, } from "node:crypto";
2
+ export class UpdateTrustError extends Error {
3
+ code;
4
+ role;
5
+ constructor(code, message, role) {
6
+ super(message);
7
+ this.name = "UpdateTrustError";
8
+ this.code = code;
9
+ this.role = role;
10
+ }
11
+ }
12
+ const ROLES = ["root", "targets", "snapshot", "timestamp"];
13
+ const SHA256 = /^[a-f0-9]{64}$/;
14
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
15
+ const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
16
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
17
+ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1_000;
18
+ const MAX_LIFETIME_MS = {
19
+ root: 366 * 24 * 60 * 60 * 1_000,
20
+ targets: 93 * 24 * 60 * 60 * 1_000,
21
+ snapshot: 32 * 24 * 60 * 60 * 1_000,
22
+ timestamp: 8 * 24 * 60 * 60 * 1_000,
23
+ };
24
+ function invalid(message, role) {
25
+ throw new UpdateTrustError("invalid-schema", message, role);
26
+ }
27
+ function canonicalize(value, seen) {
28
+ if (value === null)
29
+ return "null";
30
+ if (typeof value === "string" || typeof value === "boolean")
31
+ return JSON.stringify(value);
32
+ if (typeof value === "number") {
33
+ if (!Number.isFinite(value))
34
+ invalid("metadata numbers must be finite");
35
+ return JSON.stringify(value);
36
+ }
37
+ if (Array.isArray(value)) {
38
+ if (seen.has(value))
39
+ invalid("metadata must not contain cycles");
40
+ seen.add(value);
41
+ const serialized = `[${value.map((item) => canonicalize(item, seen)).join(",")}]`;
42
+ seen.delete(value);
43
+ return serialized;
44
+ }
45
+ if (typeof value !== "object" || value === undefined) {
46
+ invalid(`metadata contains unsupported ${typeof value} value`);
47
+ }
48
+ if (seen.has(value))
49
+ invalid("metadata must not contain cycles");
50
+ const prototype = Object.getPrototypeOf(value);
51
+ if (prototype !== Object.prototype && prototype !== null)
52
+ invalid("metadata objects must be plain");
53
+ seen.add(value);
54
+ const object = value;
55
+ const serialized = `{${Object.keys(object)
56
+ // ECMAScript's default code-unit ordering is locale-independent, which is
57
+ // required because these bytes are signed on one host and verified on another.
58
+ // A localeCompare comparator would make trust bytes depend on host ICU data.
59
+ .sort() // NOSONAR: cryptographic canonicalization must not use locale collation.
60
+ .map((key) => {
61
+ if (object[key] === undefined)
62
+ invalid(`metadata field ${key} must not be undefined`);
63
+ return `${JSON.stringify(key)}:${canonicalize(object[key], seen)}`;
64
+ })
65
+ .join(",")}}`;
66
+ seen.delete(value);
67
+ return serialized;
68
+ }
69
+ /** Deterministic JSON used for every signed payload and metadata digest. */
70
+ export function canonicalizeUpdateMetadata(value) {
71
+ return canonicalize(value, new Set());
72
+ }
73
+ function digest(value) {
74
+ return createHash("sha256").update(canonicalizeUpdateMetadata(value)).digest("hex");
75
+ }
76
+ export function computeUpdateKeyId(key) {
77
+ return digest(key);
78
+ }
79
+ export function metadataDescriptor(envelope) {
80
+ const serialized = canonicalizeUpdateMetadata(envelope);
81
+ return {
82
+ version: envelope.signed.version,
83
+ length: Buffer.byteLength(serialized),
84
+ hashes: { sha256: createHash("sha256").update(serialized).digest("hex") },
85
+ };
86
+ }
87
+ function assertRecord(value, label, role) {
88
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
89
+ invalid(`${label} must be an object`, role);
90
+ }
91
+ return value;
92
+ }
93
+ function assertOnlyKeys(record, allowed, label, role) {
94
+ const extras = Object.keys(record).filter((key) => !allowed.includes(key));
95
+ if (extras.length > 0)
96
+ invalid(`${label} contains unsupported fields: ${extras.join(", ")}`, role);
97
+ }
98
+ function validateEnvelopeShape(envelope, role) {
99
+ const record = assertRecord(envelope, `${role} envelope`, role);
100
+ assertOnlyKeys(record, ["signed", "signatures"], `${role} envelope`, role);
101
+ assertRecord(record.signed, `${role} signed payload`, role);
102
+ if (!Array.isArray(record.signatures) || record.signatures.length > 64) {
103
+ invalid(`${role} signatures must be a bounded array`, role);
104
+ }
105
+ }
106
+ function assertPositiveInteger(value, label, role) {
107
+ if (!Number.isSafeInteger(value) || value < 1) {
108
+ invalid(`${label} must be a positive safe integer`, role);
109
+ }
110
+ return value;
111
+ }
112
+ function assertNonNegativeInteger(value, label, role) {
113
+ if (!Number.isSafeInteger(value) || value < 0) {
114
+ invalid(`${label} must be a non-negative safe integer`, role);
115
+ }
116
+ return value;
117
+ }
118
+ function parseInstant(value, label, role) {
119
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value)) {
120
+ invalid(`${label} must be an ISO-8601 instant`, role);
121
+ }
122
+ const milliseconds = Date.parse(value);
123
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
124
+ invalid(`${label} must be a canonical UTC ISO-8601 instant`, role);
125
+ }
126
+ return milliseconds;
127
+ }
128
+ function validateCommon(value, expected, now) {
129
+ const record = assertRecord(value, `${expected} signed payload`, expected);
130
+ if (record._type !== expected)
131
+ invalid(`${expected} metadata has the wrong _type`, expected);
132
+ if (record.specVersion !== "1.0")
133
+ invalid(`${expected} metadata requires specVersion 1.0`, expected);
134
+ assertPositiveInteger(record.version, `${expected}.version`, expected);
135
+ const generatedAt = parseInstant(record.generatedAt, `${expected}.generatedAt`, expected);
136
+ const expires = parseInstant(record.expires, `${expected}.expires`, expected);
137
+ if (generatedAt > now + MAX_CLOCK_SKEW_MS) {
138
+ throw new UpdateTrustError("future-metadata", `${expected} metadata is generated in the future`, expected);
139
+ }
140
+ if (expires <= now) {
141
+ throw new UpdateTrustError("expired", `${expected} metadata expired at ${record.expires}`, expected);
142
+ }
143
+ if (expires <= generatedAt || expires - generatedAt > MAX_LIFETIME_MS[expected]) {
144
+ invalid(`${expected} metadata lifetime exceeds its role policy`, expected);
145
+ }
146
+ }
147
+ function validatePublicKey(key, keyid) {
148
+ const record = assertRecord(key, `key ${keyid}`);
149
+ const keyval = assertRecord(record.keyval, `key ${keyid}.keyval`);
150
+ assertOnlyKeys(record, ["keytype", "scheme", "keyval"], `key ${keyid}`);
151
+ assertOnlyKeys(keyval, ["public"], `key ${keyid}.keyval`);
152
+ if (record.keytype !== "ed25519" || record.scheme !== "ed25519") {
153
+ invalid(`key ${keyid} must use Ed25519`);
154
+ }
155
+ if (typeof keyval.public !== "string" ||
156
+ keyval.public.length < 32 ||
157
+ keyval.public.length > 8_192) {
158
+ invalid(`key ${keyid} has an invalid public key`);
159
+ }
160
+ let parsed;
161
+ try {
162
+ parsed = createPublicKey(keyval.public);
163
+ }
164
+ catch {
165
+ invalid(`key ${keyid} is not a valid public key`);
166
+ }
167
+ if (parsed.asymmetricKeyType !== "ed25519")
168
+ invalid(`key ${keyid} is not an Ed25519 key`);
169
+ if (computeUpdateKeyId(record) !== keyid) {
170
+ invalid(`key ${keyid} does not match its content-derived key id`);
171
+ }
172
+ }
173
+ function validateRootKeys(value) {
174
+ const keys = assertRecord(value, "root.keys", "root");
175
+ if (Object.keys(keys).length < 6 || Object.keys(keys).length > 64) {
176
+ invalid("root.keys must contain between 6 and 64 keys", "root");
177
+ }
178
+ for (const [keyid, key] of Object.entries(keys)) {
179
+ if (!SHA256.test(keyid))
180
+ invalid(`root key id ${keyid} is not a SHA-256 digest`, "root");
181
+ validatePublicKey(key, keyid);
182
+ }
183
+ return keys;
184
+ }
185
+ function validateRoleKey(keyid, role, keys, unique, assigned) {
186
+ if (typeof keyid !== "string" || !Object.hasOwn(keys, keyid)) {
187
+ invalid(`${role} references an unknown key`, "root");
188
+ }
189
+ if (unique.has(keyid))
190
+ invalid(`${role} contains a duplicate key id`, "root");
191
+ unique.add(keyid);
192
+ const previousRole = assigned.get(keyid);
193
+ if (previousRole !== undefined) {
194
+ throw new UpdateTrustError("role-separation", `key ${keyid} is assigned to both ${previousRole} and ${role}`, "root");
195
+ }
196
+ assigned.set(keyid, role);
197
+ }
198
+ function validateRoleDefinition(value, role, keys, assigned) {
199
+ const definition = assertRecord(value, `root.roles.${role}`, "root");
200
+ assertOnlyKeys(definition, ["keyids", "threshold"], `root.roles.${role}`, "root");
201
+ const threshold = assertPositiveInteger(definition.threshold, `root.roles.${role}.threshold`, "root");
202
+ if (!Array.isArray(definition.keyids) || definition.keyids.length === 0) {
203
+ invalid(`root.roles.${role}.keyids must be a non-empty array`, "root");
204
+ }
205
+ if (definition.keyids.length > 64)
206
+ invalid(`${role} contains too many key ids`, "root");
207
+ if (threshold > definition.keyids.length)
208
+ invalid(`${role} threshold exceeds its key count`, "root");
209
+ if ((role === "root" || role === "targets") && threshold < 2) {
210
+ invalid(`${role} requires a production threshold of at least two`, "root");
211
+ }
212
+ const unique = new Set();
213
+ for (const keyid of definition.keyids)
214
+ validateRoleKey(keyid, role, keys, unique, assigned);
215
+ }
216
+ function validateRoot(root, now) {
217
+ validateCommon(root, "root", now);
218
+ assertOnlyKeys(root, [
219
+ "_type",
220
+ "specVersion",
221
+ "version",
222
+ "generatedAt",
223
+ "expires",
224
+ "consistentSnapshot",
225
+ "keys",
226
+ "roles",
227
+ ], "root signed payload", "root");
228
+ if (root.consistentSnapshot !== true)
229
+ invalid("root must require consistent snapshots", "root");
230
+ const keys = validateRootKeys(root.keys);
231
+ const roles = assertRecord(root.roles, "root.roles", "root");
232
+ const assigned = new Map();
233
+ for (const role of ROLES)
234
+ validateRoleDefinition(roles[role], role, keys, assigned);
235
+ const extras = Object.keys(roles).filter((role) => !ROLES.includes(role));
236
+ if (extras.length > 0)
237
+ invalid(`root contains unsupported roles: ${extras.join(", ")}`, "root");
238
+ }
239
+ function validateSignatureRecord(signature, role) {
240
+ const record = assertRecord(signature, `${role} signature`, role);
241
+ assertOnlyKeys(record, ["keyid", "sig"], `${role} signature`, role);
242
+ if (typeof record.keyid !== "string" || typeof record.sig !== "string") {
243
+ invalid(`${role} signature requires keyid and sig strings`, role);
244
+ }
245
+ return record;
246
+ }
247
+ function verifyOneSignature(signature, root, role, authorized, payload) {
248
+ if (!authorized.has(signature.keyid))
249
+ return "ignored";
250
+ if (signature.sig.length !== 88 || !BASE64.test(signature.sig)) {
251
+ invalid(`${role} signature is not canonical Ed25519 base64`, role);
252
+ }
253
+ const signatureBytes = Buffer.from(signature.sig, "base64");
254
+ if (signatureBytes.byteLength !== 64)
255
+ invalid(`${role} Ed25519 signature must be 64 bytes`, role);
256
+ const key = root.keys[signature.keyid];
257
+ return cryptoVerify(null, payload, createPublicKey(key.keyval.public), signatureBytes)
258
+ ? "valid"
259
+ : "invalid";
260
+ }
261
+ function verifyRoleSignatures(envelope, root, role) {
262
+ validateEnvelopeShape(envelope, role);
263
+ const definition = root.roles[role];
264
+ const authorized = new Set(definition.keyids);
265
+ const seen = new Set();
266
+ const payload = Buffer.from(canonicalizeUpdateMetadata(envelope.signed));
267
+ let valid = 0;
268
+ let authorizedInvalid = false;
269
+ for (const candidate of envelope.signatures) {
270
+ const signature = validateSignatureRecord(candidate, role);
271
+ if (seen.has(signature.keyid))
272
+ invalid(`${role} contains a duplicate signature`, role);
273
+ seen.add(signature.keyid);
274
+ const outcome = verifyOneSignature(signature, root, role, authorized, payload);
275
+ if (outcome === "valid")
276
+ valid += 1;
277
+ if (outcome === "invalid")
278
+ authorizedInvalid = true;
279
+ }
280
+ if (valid < definition.threshold) {
281
+ if (authorizedInvalid) {
282
+ throw new UpdateTrustError("invalid-signature", `${role} contains an invalid signature`, role);
283
+ }
284
+ throw new UpdateTrustError("insufficient-signatures", `${role} has ${valid} valid signature(s); ${definition.threshold} required`, role);
285
+ }
286
+ }
287
+ function validateDescriptor(value, label, role) {
288
+ const record = assertRecord(value, label, role);
289
+ assertOnlyKeys(record, ["version", "length", "hashes"], label, role);
290
+ assertPositiveInteger(record.version, `${label}.version`, role);
291
+ assertNonNegativeInteger(record.length, `${label}.length`, role);
292
+ const hashes = assertRecord(record.hashes, `${label}.hashes`, role);
293
+ assertOnlyKeys(hashes, ["sha256"], `${label}.hashes`, role);
294
+ if (typeof hashes.sha256 !== "string" || !SHA256.test(hashes.sha256)) {
295
+ invalid(`${label}.hashes.sha256 must be a lowercase SHA-256 digest`, role);
296
+ }
297
+ }
298
+ function validateTarget(target, targetPath) {
299
+ const record = assertRecord(target, `target ${targetPath}`, "targets");
300
+ assertOnlyKeys(record, ["length", "hashes", "custom"], `target ${targetPath}`, "targets");
301
+ assertNonNegativeInteger(record.length, `target ${targetPath}.length`, "targets");
302
+ const hashes = assertRecord(record.hashes, `target ${targetPath}.hashes`, "targets");
303
+ assertOnlyKeys(hashes, ["sha256"], `target ${targetPath}.hashes`, "targets");
304
+ if (typeof hashes.sha256 !== "string" || !SHA256.test(hashes.sha256)) {
305
+ invalid(`target ${targetPath} requires a lowercase SHA-256 digest`, "targets");
306
+ }
307
+ const custom = assertRecord(record.custom, `target ${targetPath}.custom`, "targets");
308
+ assertOnlyKeys(custom, [
309
+ "version",
310
+ "channel",
311
+ "sourceCommit",
312
+ "provenanceSha256",
313
+ "sbomSha256",
314
+ "sbomAttestationSha256",
315
+ "releaseAttestationSha256",
316
+ ], `target ${targetPath}.custom`, "targets");
317
+ if (typeof custom.version !== "string" ||
318
+ custom.version.length > 128 ||
319
+ !SEMVER.test(custom.version)) {
320
+ invalid(`target ${targetPath} requires a semantic version`, "targets");
321
+ }
322
+ if (typeof custom.channel !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(custom.channel)) {
323
+ invalid(`target ${targetPath} has an invalid channel`, "targets");
324
+ }
325
+ if (typeof custom.sourceCommit !== "string" || !COMMIT.test(custom.sourceCommit)) {
326
+ invalid(`target ${targetPath} requires a full source commit`, "targets");
327
+ }
328
+ for (const field of [
329
+ "provenanceSha256",
330
+ "sbomSha256",
331
+ "sbomAttestationSha256",
332
+ "releaseAttestationSha256",
333
+ ]) {
334
+ if (custom[field] !== undefined &&
335
+ (typeof custom[field] !== "string" || !SHA256.test(custom[field]))) {
336
+ invalid(`target ${targetPath}.${field} must be a lowercase SHA-256 digest`, "targets");
337
+ }
338
+ }
339
+ }
340
+ function validateTargets(targets, now) {
341
+ validateCommon(targets, "targets", now);
342
+ assertOnlyKeys(targets, ["_type", "specVersion", "version", "generatedAt", "expires", "targets"], "targets signed payload", "targets");
343
+ const records = assertRecord(targets.targets, "targets.targets", "targets");
344
+ if (Object.keys(records).length === 0 || Object.keys(records).length > 1_000) {
345
+ invalid("targets metadata must contain between 1 and 1000 targets", "targets");
346
+ }
347
+ for (const [targetPath, target] of Object.entries(records)) {
348
+ if (targetPath.length === 0 ||
349
+ targetPath.length > 512 ||
350
+ targetPath.startsWith("/") ||
351
+ targetPath.includes("\\") ||
352
+ targetPath.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
353
+ invalid(`unsafe target path: ${targetPath}`, "targets");
354
+ }
355
+ validateTarget(target, targetPath);
356
+ }
357
+ }
358
+ function validateSnapshot(snapshot, now) {
359
+ validateCommon(snapshot, "snapshot", now);
360
+ assertOnlyKeys(snapshot, ["_type", "specVersion", "version", "generatedAt", "expires", "meta"], "snapshot signed payload", "snapshot");
361
+ const meta = assertRecord(snapshot.meta, "snapshot.meta", "snapshot");
362
+ if (Object.keys(meta).length !== 1 || meta["targets.json"] === undefined) {
363
+ invalid("snapshot must describe exactly targets.json", "snapshot");
364
+ }
365
+ validateDescriptor(meta["targets.json"], "snapshot.meta.targets.json", "snapshot");
366
+ }
367
+ function validateTimestamp(timestamp, now) {
368
+ validateCommon(timestamp, "timestamp", now);
369
+ assertOnlyKeys(timestamp, ["_type", "specVersion", "version", "generatedAt", "expires", "meta"], "timestamp signed payload", "timestamp");
370
+ const meta = assertRecord(timestamp.meta, "timestamp.meta", "timestamp");
371
+ if (Object.keys(meta).length !== 1 || meta["snapshot.json"] === undefined) {
372
+ invalid("timestamp must describe exactly snapshot.json", "timestamp");
373
+ }
374
+ validateDescriptor(meta["snapshot.json"], "timestamp.meta.snapshot.json", "timestamp");
375
+ }
376
+ function requireDescriptorMatch(descriptor, envelope, label) {
377
+ const actual = metadataDescriptor(envelope);
378
+ if (descriptor.version !== actual.version ||
379
+ descriptor.length !== actual.length ||
380
+ descriptor.hashes.sha256 !== actual.hashes.sha256) {
381
+ throw new UpdateTrustError("mix-and-match", `${label} does not match its signed descriptor`);
382
+ }
383
+ }
384
+ function stateEntry(envelope) {
385
+ return { version: envelope.signed.version, sha256: digest(envelope) };
386
+ }
387
+ function validateTrustedState(previous) {
388
+ const record = assertRecord(previous, "previous trusted state");
389
+ assertOnlyKeys(record, ROLES, "previous trusted state");
390
+ for (const role of ROLES) {
391
+ const entry = assertRecord(record[role], `previous trusted state.${role}`, role);
392
+ assertOnlyKeys(entry, ["version", "sha256"], `previous trusted state.${role}`, role);
393
+ assertPositiveInteger(entry.version, `previous trusted state.${role}.version`, role);
394
+ if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
395
+ invalid(`previous trusted state.${role}.sha256 must be a lowercase SHA-256 digest`, role);
396
+ }
397
+ }
398
+ }
399
+ function assertMonotonic(current, previous) {
400
+ if (!previous)
401
+ return;
402
+ validateTrustedState(previous);
403
+ for (const role of ROLES) {
404
+ if (current[role].version < previous[role].version) {
405
+ throw new UpdateTrustError("rollback", `${role} version moved backwards`, role);
406
+ }
407
+ if (current[role].version === previous[role].version &&
408
+ current[role].sha256 !== previous[role].sha256) {
409
+ throw new UpdateTrustError("equivocation", `${role} changed without increasing its version`, role);
410
+ }
411
+ }
412
+ }
413
+ function selectRoot(candidate, trusted, pinnedDigest, now) {
414
+ if (!SHA256.test(pinnedDigest))
415
+ invalid("trusted root pin must be a lowercase SHA-256 digest", "root");
416
+ validateEnvelopeShape(trusted, "root");
417
+ validateRoot(trusted.signed, now);
418
+ verifyRoleSignatures(trusted, trusted.signed, "root");
419
+ const trustedDigest = digest(trusted);
420
+ if (!timingSafeEqual(Buffer.from(trustedDigest, "hex"), Buffer.from(pinnedDigest, "hex"))) {
421
+ throw new UpdateTrustError("untrusted-root", "trusted root does not match its independent pin", "root");
422
+ }
423
+ validateEnvelopeShape(candidate, "root");
424
+ validateRoot(candidate.signed, now);
425
+ const candidateDigest = digest(candidate);
426
+ if (candidate.signed.version < trusted.signed.version) {
427
+ throw new UpdateTrustError("rollback", "root version moved backwards", "root");
428
+ }
429
+ if (candidate.signed.version === trusted.signed.version) {
430
+ if (candidateDigest !== trustedDigest) {
431
+ throw new UpdateTrustError("equivocation", "trusted root changed without rotation", "root");
432
+ }
433
+ return candidate.signed;
434
+ }
435
+ if (candidate.signed.version !== trusted.signed.version + 1) {
436
+ throw new UpdateTrustError("root-rotation", "root rotations must be applied one version at a time", "root");
437
+ }
438
+ verifyRoleSignatures(candidate, trusted.signed, "root");
439
+ verifyRoleSignatures(candidate, candidate.signed, "root");
440
+ return candidate.signed;
441
+ }
442
+ /**
443
+ * Advance an independently pinned root through a bounded caller-supplied chain.
444
+ * Every candidate must be exactly one version newer and satisfy both the old
445
+ * and new root thresholds; no fetched root becomes trusted merely by appearing
446
+ * later in the array.
447
+ */
448
+ export function verifyUpdateRootChain(initialRoot, initialRootSha256, candidates, now = new Date()) {
449
+ const verificationTime = now.getTime();
450
+ if (!Number.isFinite(verificationTime))
451
+ invalid("verification time must be valid");
452
+ let trusted = initialRoot;
453
+ let trustedDigest = initialRootSha256;
454
+ selectRoot(trusted, trusted, trustedDigest, verificationTime);
455
+ for (const candidate of candidates) {
456
+ selectRoot(candidate, trusted, trustedDigest, verificationTime);
457
+ trusted = candidate;
458
+ trustedDigest = digest(candidate);
459
+ }
460
+ return trusted;
461
+ }
462
+ /**
463
+ * Verify a complete TUF-inspired metadata chain without network access or mutation.
464
+ * The caller owns fetching and persistence; this function fails closed on every
465
+ * trust, freshness, rollback, or binding error.
466
+ */
467
+ export function verifyUpdateMetadataBundle(bundle, options) {
468
+ const now = (options.now ?? new Date()).getTime();
469
+ if (!Number.isFinite(now))
470
+ invalid("verification time must be valid");
471
+ const root = selectRoot(bundle.root, options.trustedRoot, options.trustedRootSha256, now);
472
+ validateTargets(bundle.targets.signed, now);
473
+ validateSnapshot(bundle.snapshot.signed, now);
474
+ validateTimestamp(bundle.timestamp.signed, now);
475
+ verifyRoleSignatures(bundle.targets, root, "targets");
476
+ verifyRoleSignatures(bundle.snapshot, root, "snapshot");
477
+ verifyRoleSignatures(bundle.timestamp, root, "timestamp");
478
+ requireDescriptorMatch(bundle.timestamp.signed.meta["snapshot.json"], bundle.snapshot, "snapshot metadata");
479
+ requireDescriptorMatch(bundle.snapshot.signed.meta["targets.json"], bundle.targets, "targets metadata");
480
+ const state = {
481
+ root: stateEntry(bundle.root),
482
+ targets: stateEntry(bundle.targets),
483
+ snapshot: stateEntry(bundle.snapshot),
484
+ timestamp: stateEntry(bundle.timestamp),
485
+ };
486
+ assertMonotonic(state, options.previous);
487
+ const target = bundle.targets.signed.targets[options.targetPath];
488
+ if (!target) {
489
+ throw new UpdateTrustError("target-not-found", `targets metadata does not authorize ${options.targetPath}`, "targets");
490
+ }
491
+ return { targetPath: options.targetPath, target, root, rootEnvelope: bundle.root, state };
492
+ }
493
+ /** Verify downloaded bytes before quarantine or any package-manager action. */
494
+ export function verifyTargetArtifact(artifact, target) {
495
+ if (artifact.byteLength !== target.length) {
496
+ throw new UpdateTrustError("artifact-mismatch", `artifact length ${artifact.byteLength} does not match signed length ${target.length}`);
497
+ }
498
+ const actual = createHash("sha256").update(artifact).digest();
499
+ const expected = Buffer.from(target.hashes.sha256, "hex");
500
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected)) {
501
+ throw new UpdateTrustError("artifact-mismatch", "artifact SHA-256 does not match signed metadata");
502
+ }
503
+ }
@@ -0,0 +1,13 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const require = createRequire(import.meta.url);
6
+ let packageRoot = path.dirname(fileURLToPath(import.meta.url));
7
+ while (!fs.existsSync(path.join(packageRoot, "package.json")) &&
8
+ packageRoot !== path.parse(packageRoot).root) {
9
+ packageRoot = path.dirname(packageRoot);
10
+ }
11
+ const packageMetadata = require(path.join(packageRoot, "package.json"));
12
+ /** Installed package version, shared by CLI health and the MCP server. */
13
+ export const KNODIN_VERSION = typeof packageMetadata.version === "string" ? packageMetadata.version : "0.0.0";
@@ -0,0 +1,162 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ const DEFAULT_BYTE_BUDGET = 32_768;
6
+ const MIN_BYTE_BUDGET = 4_096;
7
+ const MAX_CALL_FLOW_EDGES = 20;
8
+ function html(value) {
9
+ return value
10
+ .replaceAll("&", "&amp;")
11
+ .replaceAll("<", "&lt;")
12
+ .replaceAll(">", "&gt;")
13
+ .replaceAll('"', "&quot;");
14
+ }
15
+ function sourceCommit(repo) {
16
+ try {
17
+ return execFileSync("git", ["rev-parse", "HEAD"], {
18
+ cwd: repo,
19
+ encoding: "utf8",
20
+ stdio: ["ignore", "pipe", "ignore"],
21
+ }).trim();
22
+ }
23
+ catch {
24
+ return "unavailable";
25
+ }
26
+ }
27
+ function safeOutput(repo, output) {
28
+ if (!output || path.extname(output).toLowerCase() !== ".html")
29
+ throw new Error("knodin visualize: --output must be a repository-relative .html path");
30
+ const absolute = path.resolve(repo, output);
31
+ if (absolute === repo || !absolute.startsWith(`${repo}${path.sep}`))
32
+ throw new Error("knodin visualize: output path must stay inside the repository");
33
+ const targetStat = fs.lstatSync(absolute, { throwIfNoEntry: false });
34
+ if (targetStat?.isSymbolicLink())
35
+ throw new Error("knodin visualize: output path may not be a symlink");
36
+ if (targetStat) {
37
+ const realTarget = fs.realpathSync(absolute);
38
+ if (!realTarget.startsWith(`${repo}${path.sep}`))
39
+ throw new Error("knodin visualize: output path may not follow a symlink outside the repository");
40
+ }
41
+ let parent = path.dirname(absolute);
42
+ while (!fs.existsSync(parent) && parent !== repo)
43
+ parent = path.dirname(parent);
44
+ const realParent = fs.realpathSync(parent);
45
+ if (realParent !== repo && !realParent.startsWith(`${repo}${path.sep}`))
46
+ throw new Error("knodin visualize: output path may not traverse a symlink outside the repository");
47
+ return { absolute, relative: path.relative(repo, absolute).replaceAll(path.sep, "/") };
48
+ }
49
+ function render(input) {
50
+ const communityRows = input.communities
51
+ .map((community) => {
52
+ const files = (community.files ?? []).slice(0, 12).join(", ") || "no file detail returned";
53
+ const symbols = (community.symbols ?? []).slice(0, 12).join(", ") || "no symbol detail returned";
54
+ return `<details><summary>${html(community.name)} <span>${community.size} symbols</span></summary><p>Cohesion: ${community.cohesion.toFixed(3)}</p><p><strong>Files:</strong> ${html(files)}</p><p><strong>Symbols:</strong> ${html(symbols)}</p></details>`;
55
+ })
56
+ .join("\n");
57
+ const hotspot = (kind, rows) => rows
58
+ .map((node) => {
59
+ const metric = "degree" in node ? `degree ${node.degree}` : `betweenness ${node.betweenness.toFixed(3)}`;
60
+ return `<li><strong>${kind}</strong> ${html(node.symbol)} — ${html(metric)}; ${html(node.filePath || "unresolved file")} (${html(node.communityId ?? "unassigned")})</li>`;
61
+ })
62
+ .join("\n");
63
+ const edgeRows = (edges) => edges
64
+ .map((edge) => `<li data-evidence="${edge.label}"><code>${html(edge.from)}</code> → <code>${html(edge.to)}</code> <span>${html(edge.kind)}</span><br><small>${edge.label === "exact" ? "Source evidence" : "Heuristic source evidence"}: ${html(edge.evidence)}</small></li>`)
65
+ .join("\n") || "<li>No bounded source-evidenced relationships were returned.</li>";
66
+ return `<!doctype html>
67
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>knodin architecture visualization</title><style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:74rem;color:#172033;background:#f6f8fb;line-height:1.45}section{background:#fff;border:1px solid #d9dee7;border-radius:.55rem;padding:1rem 1.25rem;margin:1rem 0}details{border-top:1px solid #e5e9ef;padding:.6rem 0}summary{cursor:pointer;font-weight:650}summary span,small{color:#526170}li{margin:.55rem 0}code{background:#edf1f6;padding:.1rem .3rem;border-radius:.2rem;overflow-wrap:anywhere}[data-evidence=heuristic]{border-left:3px solid #d18a00;padding-left:.55rem}</style></head>
68
+ <body><h1>knodin local architecture visualization</h1><p>Generated locally from the persisted knodin graph. Source commit <code>${html(input.commit)}</code>; Index freshness <code>${html(input.freshness)}</code>; ${input.truncated ? "some lists are bounded by the artifact budget." : "all displayed lists fit the requested artifact budget."}</p>
69
+ <section><h2>Subsystem drill-down</h2>${communityRows || "<p>No communities were returned.</p>"}</section>
70
+ <section><h2>Hub and bridge inspection</h2><ul>${hotspot("Hub", input.hubs)}${hotspot("Bridge", input.bridges)}</ul></section>
71
+ <section><h2>Architecture relationships</h2><ul>${edgeRows(input.architectureEdges)}</ul></section>
72
+ <section><h2>Call-flow navigation</h2><p>Static downstream traversal from <code>${html(input.entry)}</code>, depth ${input.depth}; this is not a runtime trace.</p><ul>${edgeRows(input.callFlowEdges)}</ul></section>
73
+ </body></html>\n`;
74
+ }
75
+ export async function writeVisualization(engine, repoPath, request) {
76
+ const repo = fs.realpathSync(repoPath);
77
+ const output = safeOutput(repo, request.outputPath);
78
+ const entry = request.entry.trim();
79
+ if (!entry)
80
+ throw new Error("knodin visualize: an entry selector is required");
81
+ const depth = request.depth ?? 3;
82
+ if (!Number.isInteger(depth) || depth < 1 || depth > 6)
83
+ throw new Error("knodin visualize: --depth must be an integer from 1 through 6");
84
+ const byteBudget = request.byteBudget ?? DEFAULT_BYTE_BUDGET;
85
+ if (!Number.isInteger(byteBudget) || byteBudget < MIN_BYTE_BUDGET || byteBudget > 65_536)
86
+ throw new Error("knodin visualize: --max-bytes must be an integer from 4096 through 65536");
87
+ const [map, traversal] = await Promise.all([
88
+ engine.map(repo, "standard", { topN: 12, sort: "name" }),
89
+ engine.query("traverse", entry, repo, undefined, MAX_CALL_FLOW_EDGES, depth, "standard", request.selector, undefined, { direction: "downstream", relationKinds: ["call"], includeDataFlow: true }),
90
+ ]);
91
+ if (traversal.ambiguity || traversal.count === 0)
92
+ throw new Error("knodin visualize: entry selector did not resolve to a traversable symbol");
93
+ const architectureEdges = map.edges
94
+ .filter((edge) => Boolean(edge.sourceEvidence))
95
+ .slice(0, MAX_CALL_FLOW_EDGES)
96
+ .map((edge) => ({
97
+ from: edge.from,
98
+ to: edge.to,
99
+ kind: edge.kind,
100
+ evidence: edge.sourceEvidence ?? "",
101
+ label: edge.confidenceLabel === "heuristic" || edge.provenance !== "EXTRACTED"
102
+ ? "heuristic"
103
+ : "exact",
104
+ }));
105
+ const callFlowEdges = (traversal.edges ?? []).slice(0, MAX_CALL_FLOW_EDGES).map((edge) => ({
106
+ from: edge.from,
107
+ to: edge.to,
108
+ kind: edge.kind ?? "call",
109
+ evidence: edge.dataFlow?.evidence ??
110
+ `${edge.fromFile ?? "unresolved file"}:${edge.line ?? "unknown line"}`,
111
+ label: edge.dataFlow?.heuristic || edge.provenance === "INFERRED"
112
+ ? "heuristic"
113
+ : "exact",
114
+ }));
115
+ let communities = map.communities.slice(0, 12);
116
+ let hubs = map.hubs.slice(0, 10);
117
+ let bridges = map.bridges.slice(0, 10);
118
+ let architecture = architectureEdges;
119
+ let callFlow = callFlowEdges;
120
+ let truncated = Boolean(traversal.truncated);
121
+ let artifact = "";
122
+ for (;;) {
123
+ artifact = render({
124
+ commit: sourceCommit(repo),
125
+ freshness: map.staleness ?? "unknown",
126
+ communities,
127
+ hubs,
128
+ bridges,
129
+ architectureEdges: architecture,
130
+ callFlowEdges: callFlow,
131
+ entry,
132
+ depth,
133
+ truncated,
134
+ });
135
+ if (Buffer.byteLength(artifact) <= byteBudget)
136
+ break;
137
+ truncated = true;
138
+ if (architecture.length)
139
+ architecture = architecture.slice(0, -1);
140
+ else if (callFlow.length)
141
+ callFlow = callFlow.slice(0, -1);
142
+ else if (communities.length)
143
+ communities = communities.slice(0, -1);
144
+ else if (hubs.length)
145
+ hubs = hubs.slice(0, -1);
146
+ else if (bridges.length)
147
+ bridges = bridges.slice(0, -1);
148
+ else
149
+ throw new Error("knodin visualize: budget is too small for visualization metadata");
150
+ }
151
+ fs.mkdirSync(path.dirname(output.absolute), { recursive: true });
152
+ fs.writeFileSync(output.absolute, artifact);
153
+ return {
154
+ outputPath: output.relative,
155
+ bytes: Buffer.byteLength(artifact),
156
+ sha256: crypto.createHash("sha256").update(artifact).digest("hex"),
157
+ byteBudget,
158
+ truncated,
159
+ indexFreshness: map.staleness ?? "unknown",
160
+ callFlow: { entry, depth, edgeCount: callFlow.length, truncated: Boolean(traversal.truncated) },
161
+ };
162
+ }