@sanning/proof 0.12.0 → 0.14.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 (59) hide show
  1. package/README.md +25 -12
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +353 -0
  6. package/dist/case.d.ts +217 -26
  7. package/dist/case.js +535 -124
  8. package/dist/cli.d.ts +64 -8
  9. package/dist/cli.js +799 -219
  10. package/dist/compose.js +6 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/crypto.js +5 -1
  13. package/dist/declaration.d.ts +58 -52
  14. package/dist/declaration.js +256 -33
  15. package/dist/evidence.d.ts +38 -0
  16. package/dist/evidence.js +238 -22
  17. package/dist/exit-codes.d.ts +17 -0
  18. package/dist/exit-codes.js +50 -0
  19. package/dist/file-match.d.ts +105 -0
  20. package/dist/file-match.js +374 -0
  21. package/dist/grading.d.ts +28 -13
  22. package/dist/grading.js +90 -31
  23. package/dist/index.d.ts +14 -7
  24. package/dist/index.js +20 -6
  25. package/dist/message-parts.d.ts +18 -10
  26. package/dist/message-parts.js +25 -15
  27. package/dist/types.d.ts +1 -0
  28. package/dist/verifier.d.ts +63 -4
  29. package/dist/verifier.js +223 -15
  30. package/dist/witness-cache.d.ts +85 -0
  31. package/dist/witness-cache.js +264 -0
  32. package/dist/zip-reader.d.ts +50 -0
  33. package/dist/zip-reader.js +220 -0
  34. package/package.json +2 -2
  35. package/src/agent-proof.ts +4 -4
  36. package/src/case-file.ts +422 -0
  37. package/src/case.ts +671 -131
  38. package/src/cli.ts +932 -217
  39. package/src/compose.ts +8 -1
  40. package/src/crypto.ts +5 -1
  41. package/src/declaration.ts +248 -52
  42. package/src/evidence.ts +257 -23
  43. package/src/exit-codes.ts +54 -0
  44. package/src/file-match.ts +461 -0
  45. package/src/grading.ts +109 -52
  46. package/src/index.ts +101 -2
  47. package/src/message-parts.ts +25 -15
  48. package/src/types.ts +5 -0
  49. package/src/verifier.ts +225 -16
  50. package/src/witness-cache.ts +306 -0
  51. package/src/zip-reader.ts +229 -0
  52. package/vectors/README.md +66 -8
  53. package/vectors/case-file-index.json +668 -0
  54. package/vectors/case-split.json +218 -9
  55. package/vectors/file-match.json +1808 -0
  56. package/vectors/jcs-reader.json +82 -3
  57. package/vectors/pack-grading.json +68 -4
  58. package/vectors/stamp-on-chain.json +20 -5
  59. package/vectors/witness-cache.json +1304 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Files handed in beside a pack, matched against what its records signed (A29).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/file_match.py`. Both kernels are
5
+ * held to one table, `vectors/file-match.json`, so neither can pass by agreeing
6
+ * with the other, and `cross-kernel/run_file_match.sh` holds the two readings,
7
+ * and the two commands' `--files` output, to the same bytes.
8
+ *
9
+ * WHAT THIS ANSWERS (evidence-bundle.md 5.7 v1.24, proof#92, decided by Phil
10
+ * 2026-09-23 as option 2). A reader holds a pack and some files beside it and
11
+ * asks whether the pack vouches for them. Each offered file is hashed, SHA-256
12
+ * over its exact bytes, and the digest is looked for among the commitments the
13
+ * pack's SIGNED records make. The reading says which offered files a record
14
+ * commits to (naming the record, its signer and its `signed_at`), which match
15
+ * no record, and which commitments had no file offered.
16
+ *
17
+ * THE RULES THAT CARRY THE WEIGHT:
18
+ *
19
+ * - By hash, and by nothing else. A file's name is carried through as the
20
+ * caller's label and is never compared with an `event_id`, a part's name or
21
+ * a `logs/` path. There is no layout convention.
22
+ * - Unmatched is reported, never guessed.
23
+ * - Only what a record's own key signed: a verified envelope whose
24
+ * `event_bytes` bind to its `payload_hash`, or a hand-off message whose
25
+ * bytes bind to that record's `message_hash`. No wrapper field is read.
26
+ * - The verdict does not move. A match proves that a signed record in the
27
+ * pack committed to those exact bytes, and nothing more (`FILE_MATCH_SCOPE`).
28
+ *
29
+ * WHAT COUNTS AS A COMMITMENT, in record order and then part order:
30
+ *
31
+ * 1. `record`: a record outside envelope-spec.md 4.1 commits through
32
+ * `event.content_hash`, 64 hex characters in either case.
33
+ * 2. `file_part`: a record 4.1 governs commits to its MESSAGE through
34
+ * `event.message_hash` (5.1, A20), never through a `content_hash` it also
35
+ * writes. Where the message's JCS bytes are in hand (in the body, or in
36
+ * the side content) and bind to it, each file part of that message (A18)
37
+ * commits by its `content_hash`.
38
+ *
39
+ * Side content is read for ONE thing, to find a hand-off message that binds to
40
+ * a signed `message_hash`. It is never an offered file.
41
+ *
42
+ * Pure: a bundle, the files and the optional side content in, a reading out.
43
+ */
44
+
45
+ import { hexToBytes, isHexField, sha256Hex } from "./crypto.js";
46
+ import { resolveTraceEvents } from "./evidence.js";
47
+ import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
48
+ import type { HeldRecord } from "./handoff.js";
49
+ import { isFilePart } from "./message-parts.js";
50
+ import { sideContentEntry } from "./side-content.js";
51
+ import type { SideContentMap } from "./side-content.js";
52
+ import type { Envelope } from "./types.js";
53
+ import { jcsParse, verifyEnvelope } from "./verifier.js";
54
+
55
+ /** The one sentence every surface prints with the reading (5.7). */
56
+ export const FILE_MATCH_SCOPE =
57
+ "A match proves that a signed record in the pack committed to these exact " +
58
+ "bytes, and nothing more.";
59
+
60
+ /** Why a record's commitments could not be read, in the order they are tested. */
61
+ export const NOT_READ_REASONS = [
62
+ "no_records",
63
+ "record_unreadable",
64
+ "record_not_verified",
65
+ "record_withheld",
66
+ "no_content_hash",
67
+ "handoff_record_malformed",
68
+ "message_withheld",
69
+ ] as const;
70
+
71
+ export type NotReadReason = (typeof NOT_READ_REASONS)[number];
72
+
73
+ const REASON_TEXT: Record<NotReadReason, string> = {
74
+ no_records: "this bundle carries no records this reading can read",
75
+ record_unreadable: "its record bytes cannot be read",
76
+ record_not_verified: "its envelope does not verify",
77
+ record_withheld: "its record bytes are withheld",
78
+ no_content_hash: "it commits to no content_hash",
79
+ handoff_record_malformed:
80
+ "it is a hand-off record that commits to no message a reader can bind to",
81
+ message_withheld: "its message is not in hand, so its file parts cannot be read",
82
+ };
83
+
84
+ // The accept-set minus the quarantined `sanning.mlflow/v1` (DL-63), the set
85
+ // both kernels read a case under.
86
+ const RECORD_PROFILES = ["sanning.agent/v1", "sanning.events/v2"] as const;
87
+
88
+ /** One commitment a signed record makes to a file's bytes. */
89
+ export interface FileCommitment {
90
+ packRef: string | null;
91
+ eventId: string | null;
92
+ kind: "record" | "file_part";
93
+ partIndex: number | null;
94
+ contentHash: string;
95
+ signer: string;
96
+ signedAt: string | null;
97
+ }
98
+
99
+ export interface MatchedFile {
100
+ name: string;
101
+ sha256: string;
102
+ records: FileCommitment[];
103
+ }
104
+
105
+ export interface UnmatchedFile {
106
+ name: string;
107
+ sha256: string;
108
+ }
109
+
110
+ export interface NotRead {
111
+ packRef: string | null;
112
+ eventId: string | null;
113
+ reason: NotReadReason;
114
+ }
115
+
116
+ export interface FileMatchReading {
117
+ offered: number;
118
+ matched: MatchedFile[];
119
+ unmatched: UnmatchedFile[];
120
+ notOffered: FileCommitment[];
121
+ notRead: NotRead[];
122
+ scope: string;
123
+ }
124
+
125
+ /** A file handed in beside a pack. `name` is a label and is never compared. */
126
+ export interface OfferedFile {
127
+ name: string;
128
+ bytes: Uint8Array;
129
+ }
130
+
131
+ /** One pack of a case, for `matchCaseFiles`. */
132
+ export interface FileMatchPack {
133
+ ref: string;
134
+ bundle: unknown;
135
+ content?: SideContentMap | null;
136
+ }
137
+
138
+ function isObject(v: unknown): v is Record<string, unknown> {
139
+ return v !== null && typeof v === "object" && !Array.isArray(v);
140
+ }
141
+
142
+ function strOrNull(v: unknown): string | null {
143
+ return typeof v === "string" ? v : null;
144
+ }
145
+
146
+ function own(o: Record<string, unknown>, key: string): unknown {
147
+ return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
148
+ }
149
+
150
+ // The envelope's key and signature in the hex charset at their lengths, the
151
+ // reading every body verifier applies inside a bundle (proof#142).
152
+ function envelopeSpelled(envelope: Record<string, unknown>): boolean {
153
+ for (const [name, nibbles] of [
154
+ ["public_key", 64],
155
+ ["signature", 128],
156
+ ] as const) {
157
+ const value = own(envelope, name);
158
+ if (typeof value === "string" && !isHexField(value, nibbles)) return false;
159
+ }
160
+ return true;
161
+ }
162
+
163
+ function decode(value: unknown): Uint8Array | undefined {
164
+ if (value instanceof Uint8Array) return value;
165
+ if (typeof value === "string") {
166
+ try {
167
+ return hexToBytes(value);
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ const ABSENT = Symbol("absent");
176
+
177
+ async function messageOf(
178
+ ev: Record<string, unknown>,
179
+ eventId: string | null,
180
+ messageHash: string,
181
+ content: SideContentMap | null | undefined,
182
+ ): Promise<unknown> {
183
+ const candidates = [decode(own(ev, "content"))];
184
+ if (eventId !== null) candidates.push(decode(sideContentEntry(content, eventId).value));
185
+ for (const raw of candidates) {
186
+ if (raw === undefined || (await sha256Hex(raw)) !== messageHash) continue;
187
+ try {
188
+ return jcsParse(raw);
189
+ } catch {
190
+ continue;
191
+ }
192
+ }
193
+ return ABSENT;
194
+ }
195
+
196
+ async function readPack(
197
+ bundle: unknown,
198
+ content: SideContentMap | null | undefined,
199
+ packRef: string | null,
200
+ ): Promise<{ commitments: FileCommitment[]; notRead: NotRead[] }> {
201
+ const commitments: FileCommitment[] = [];
202
+ const notRead: NotRead[] = [];
203
+ const events = resolveTraceEvents(bundle) as unknown[] | undefined;
204
+ if (events === undefined) {
205
+ notRead.push({ packRef, eventId: null, reason: "no_records" });
206
+ return { commitments, notRead };
207
+ }
208
+ for (const ev of events) {
209
+ if (!isObject(ev)) {
210
+ notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
211
+ continue;
212
+ }
213
+ const envelope = own(ev, "envelope");
214
+ const eventId = isObject(envelope) ? strOrNull(own(envelope, "event_id")) : null;
215
+
216
+ const rawBytes = own(ev, "event_bytes");
217
+ let eventBytes: Uint8Array | undefined;
218
+ if (rawBytes !== undefined && rawBytes !== null) {
219
+ eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
220
+ if (eventBytes === undefined) {
221
+ notRead.push({ packRef, eventId, reason: "record_unreadable" });
222
+ continue;
223
+ }
224
+ }
225
+
226
+ const result = await verifyEnvelope(envelope as Envelope, {
227
+ ...(eventBytes !== undefined ? { payloadBytes: eventBytes } : {}),
228
+ acceptedSpecMajors: RECORD_PROFILES,
229
+ });
230
+ if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
231
+ notRead.push({ packRef, eventId, reason: "record_not_verified" });
232
+ continue;
233
+ }
234
+ if (eventBytes === undefined) {
235
+ notRead.push({ packRef, eventId, reason: "record_withheld" });
236
+ continue;
237
+ }
238
+ if (result.payloadHashOk !== true) {
239
+ notRead.push({ packRef, eventId, reason: "record_not_verified" });
240
+ continue;
241
+ }
242
+ let record: unknown;
243
+ try {
244
+ record = jcsParse(eventBytes);
245
+ } catch {
246
+ record = undefined;
247
+ }
248
+ if (!isObject(record)) {
249
+ notRead.push({ packRef, eventId, reason: "record_unreadable" });
250
+ continue;
251
+ }
252
+
253
+ const key = own(envelope, "public_key");
254
+ const signer = typeof key === "string" ? key : "";
255
+ const signedAt = strOrNull(own(envelope, "signed_at"));
256
+ const section = own(record, "event");
257
+ const event = isObject(section) ? section : {};
258
+
259
+ const held: HeldRecord = {
260
+ publicKey: signer,
261
+ record: record as HeldRecord["record"],
262
+ ref: "",
263
+ };
264
+ if (isHandoffNamespace(held)) {
265
+ const messageHash = own(event, "message_hash");
266
+ if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
267
+ notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
268
+ continue;
269
+ }
270
+ const message = await messageOf(ev, eventId, messageHash, content);
271
+ if (message === ABSENT) {
272
+ notRead.push({ packRef, eventId, reason: "message_withheld" });
273
+ continue;
274
+ }
275
+ const parts = isObject(message) ? own(message, "parts") : undefined;
276
+ if (Array.isArray(parts)) {
277
+ parts.forEach((part, index) => {
278
+ if (!isFilePart(part)) return;
279
+ commitments.push({
280
+ packRef,
281
+ eventId,
282
+ kind: "file_part",
283
+ partIndex: index,
284
+ contentHash: ((part as { content_hash: string }).content_hash).toLowerCase(),
285
+ signer,
286
+ signedAt,
287
+ });
288
+ });
289
+ }
290
+ continue;
291
+ }
292
+
293
+ const contentHash = own(event, "content_hash");
294
+ if (!isHexField(contentHash, 64)) {
295
+ notRead.push({ packRef, eventId, reason: "no_content_hash" });
296
+ continue;
297
+ }
298
+ commitments.push({
299
+ packRef,
300
+ eventId,
301
+ kind: "record",
302
+ partIndex: null,
303
+ contentHash: contentHash.toLowerCase(),
304
+ signer,
305
+ signedAt,
306
+ });
307
+ }
308
+ return { commitments, notRead };
309
+ }
310
+
311
+ // A malformed file entry is the CALLER's mistake, not the evidence's, so it
312
+ // throws rather than reading as a file that matched nothing.
313
+ async function offeredDigests(files: readonly OfferedFile[]): Promise<[string, string][]> {
314
+ const out: [string, string][] = [];
315
+ for (const entry of files) {
316
+ if (!isObject(entry)) throw new TypeError("matchFiles: each file is an object {name, bytes}");
317
+ const { name, bytes } = entry as { name?: unknown; bytes?: unknown };
318
+ if (typeof name !== "string") throw new TypeError("matchFiles: a file's name is a string");
319
+ if (!(bytes instanceof Uint8Array)) {
320
+ throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
321
+ }
322
+ out.push([name, await sha256Hex(bytes)]);
323
+ }
324
+ return out;
325
+ }
326
+
327
+ async function match(
328
+ commitments: FileCommitment[],
329
+ notRead: NotRead[],
330
+ files: readonly OfferedFile[],
331
+ ): Promise<FileMatchReading> {
332
+ const offered = await offeredDigests(files);
333
+ const matched: MatchedFile[] = [];
334
+ const unmatched: UnmatchedFile[] = [];
335
+ const used = new Set<number>();
336
+ for (const [name, digest] of offered) {
337
+ const hits: number[] = [];
338
+ commitments.forEach((c, i) => {
339
+ if (c.contentHash === digest) hits.push(i);
340
+ });
341
+ if (hits.length > 0) {
342
+ for (const i of hits) used.add(i);
343
+ matched.push({ name, sha256: digest, records: hits.map((i) => commitments[i]!) });
344
+ } else {
345
+ unmatched.push({ name, sha256: digest });
346
+ }
347
+ }
348
+ return {
349
+ offered: offered.length,
350
+ matched,
351
+ unmatched,
352
+ notOffered: commitments.filter((_, i) => !used.has(i)),
353
+ notRead,
354
+ scope: FILE_MATCH_SCOPE,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Match each offered file against the commitments one pack's signed records
360
+ * make. `content` is the side content handed in beside the pack, read only to
361
+ * find a hand-off message that binds to its record's `message_hash`; it is
362
+ * never an offered file.
363
+ */
364
+ export async function matchFiles(
365
+ bundle: unknown,
366
+ files: readonly OfferedFile[],
367
+ content?: SideContentMap | null,
368
+ ): Promise<FileMatchReading> {
369
+ const { commitments, notRead } = await readPack(bundle, content, null);
370
+ return match(commitments, notRead, files);
371
+ }
372
+
373
+ /** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
374
+ export async function matchCaseFiles(
375
+ packs: readonly FileMatchPack[],
376
+ files: readonly OfferedFile[],
377
+ ): Promise<FileMatchReading> {
378
+ const commitments: FileCommitment[] = [];
379
+ const notRead: NotRead[] = [];
380
+ for (const pack of packs) {
381
+ if (!isObject(pack)) throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
382
+ if (typeof pack.ref !== "string") throw new TypeError("matchCaseFiles: a pack's ref is a string");
383
+ const r = await readPack(pack.bundle, pack.content, pack.ref);
384
+ commitments.push(...r.commitments);
385
+ notRead.push(...r.notRead);
386
+ }
387
+ return match(commitments, notRead, files);
388
+ }
389
+
390
+ function commitmentJson(c: FileCommitment): Record<string, unknown> {
391
+ return {
392
+ pack_ref: c.packRef,
393
+ event_id: c.eventId,
394
+ kind: c.kind,
395
+ part_index: c.partIndex,
396
+ content_hash: c.contentHash,
397
+ signer: c.signer,
398
+ signed_at: c.signedAt,
399
+ };
400
+ }
401
+
402
+ /** The reading as the `files` member of `--json`, identical in both kernels. */
403
+ export function fileMatchToJson(reading: FileMatchReading): Record<string, unknown> {
404
+ return {
405
+ offered: reading.offered,
406
+ matched: reading.matched.map((m) => ({
407
+ name: m.name,
408
+ sha256: m.sha256,
409
+ records: m.records.map(commitmentJson),
410
+ })),
411
+ unmatched: reading.unmatched.map((u) => ({ name: u.name, sha256: u.sha256 })),
412
+ not_offered: reading.notOffered.map(commitmentJson),
413
+ not_read: reading.notRead.map((n) => ({
414
+ pack_ref: n.packRef,
415
+ event_id: n.eventId,
416
+ reason: n.reason,
417
+ rendered: renderNotRead(n),
418
+ })),
419
+ scope: reading.scope,
420
+ };
421
+ }
422
+
423
+ function recordName(eventId: string | null): string {
424
+ return eventId !== null ? `record ${eventId}` : "a record with no event_id";
425
+ }
426
+
427
+ function renderCommitment(c: FileCommitment): string {
428
+ const what =
429
+ c.kind === "record"
430
+ ? recordName(c.eventId)
431
+ : `file part ${c.partIndex} of the message in ${recordName(c.eventId)}`;
432
+ const where = c.packRef !== null ? ` in pack ${c.packRef}` : "";
433
+ const when = c.signedAt !== null ? c.signedAt : "no stated time";
434
+ return `${what}${where}, signed by ${c.signer}, signed_at ${when}`;
435
+ }
436
+
437
+ function renderNotRead(n: NotRead): string {
438
+ const where = n.packRef !== null ? ` in pack ${n.packRef}` : "";
439
+ if (n.reason === "no_records") return `no record read${where}: ${REASON_TEXT[n.reason]}`;
440
+ return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
441
+ }
442
+
443
+ /** The text report's lines for the reading, identical in both kernels. */
444
+ export function renderFileMatch(reading: FileMatchReading): string[] {
445
+ const lines = [
446
+ `files ${reading.offered} offered: ${reading.matched.length} vouched for, ` +
447
+ `${reading.unmatched.length} match no record; ` +
448
+ `${reading.notOffered.length} commitment(s) had no file offered`,
449
+ ];
450
+ for (const m of reading.matched) {
451
+ lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
452
+ for (const c of m.records) lines.push(` by ${renderCommitment(c)}`);
453
+ }
454
+ for (const u of reading.unmatched) lines.push(` no match ${u.name} sha256 ${u.sha256}`);
455
+ for (const c of reading.notOffered) {
456
+ lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
457
+ }
458
+ for (const n of reading.notRead) lines.push(` not read ${renderNotRead(n)}`);
459
+ lines.push(` ${reading.scope}`);
460
+ return lines;
461
+ }