@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,374 @@
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
+ import { hexToBytes, isHexField, sha256Hex } from "./crypto.js";
45
+ import { resolveTraceEvents } from "./evidence.js";
46
+ import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
47
+ import { isFilePart } from "./message-parts.js";
48
+ import { sideContentEntry } from "./side-content.js";
49
+ import { jcsParse, verifyEnvelope } from "./verifier.js";
50
+ /** The one sentence every surface prints with the reading (5.7). */
51
+ export const FILE_MATCH_SCOPE = "A match proves that a signed record in the pack committed to these exact " +
52
+ "bytes, and nothing more.";
53
+ /** Why a record's commitments could not be read, in the order they are tested. */
54
+ export const NOT_READ_REASONS = [
55
+ "no_records",
56
+ "record_unreadable",
57
+ "record_not_verified",
58
+ "record_withheld",
59
+ "no_content_hash",
60
+ "handoff_record_malformed",
61
+ "message_withheld",
62
+ ];
63
+ const REASON_TEXT = {
64
+ no_records: "this bundle carries no records this reading can read",
65
+ record_unreadable: "its record bytes cannot be read",
66
+ record_not_verified: "its envelope does not verify",
67
+ record_withheld: "its record bytes are withheld",
68
+ no_content_hash: "it commits to no content_hash",
69
+ handoff_record_malformed: "it is a hand-off record that commits to no message a reader can bind to",
70
+ message_withheld: "its message is not in hand, so its file parts cannot be read",
71
+ };
72
+ // The accept-set minus the quarantined `sanning.mlflow/v1` (DL-63), the set
73
+ // both kernels read a case under.
74
+ const RECORD_PROFILES = ["sanning.agent/v1", "sanning.events/v2"];
75
+ function isObject(v) {
76
+ return v !== null && typeof v === "object" && !Array.isArray(v);
77
+ }
78
+ function strOrNull(v) {
79
+ return typeof v === "string" ? v : null;
80
+ }
81
+ function own(o, key) {
82
+ return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
83
+ }
84
+ // The envelope's key and signature in the hex charset at their lengths, the
85
+ // reading every body verifier applies inside a bundle (proof#142).
86
+ function envelopeSpelled(envelope) {
87
+ for (const [name, nibbles] of [
88
+ ["public_key", 64],
89
+ ["signature", 128],
90
+ ]) {
91
+ const value = own(envelope, name);
92
+ if (typeof value === "string" && !isHexField(value, nibbles))
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+ function decode(value) {
98
+ if (value instanceof Uint8Array)
99
+ return value;
100
+ if (typeof value === "string") {
101
+ try {
102
+ return hexToBytes(value);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
108
+ return undefined;
109
+ }
110
+ const ABSENT = Symbol("absent");
111
+ async function messageOf(ev, eventId, messageHash, content) {
112
+ const candidates = [decode(own(ev, "content"))];
113
+ if (eventId !== null)
114
+ candidates.push(decode(sideContentEntry(content, eventId).value));
115
+ for (const raw of candidates) {
116
+ if (raw === undefined || (await sha256Hex(raw)) !== messageHash)
117
+ continue;
118
+ try {
119
+ return jcsParse(raw);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ }
125
+ return ABSENT;
126
+ }
127
+ async function readPack(bundle, content, packRef) {
128
+ const commitments = [];
129
+ const notRead = [];
130
+ const events = resolveTraceEvents(bundle);
131
+ if (events === undefined) {
132
+ notRead.push({ packRef, eventId: null, reason: "no_records" });
133
+ return { commitments, notRead };
134
+ }
135
+ for (const ev of events) {
136
+ if (!isObject(ev)) {
137
+ notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
138
+ continue;
139
+ }
140
+ const envelope = own(ev, "envelope");
141
+ const eventId = isObject(envelope) ? strOrNull(own(envelope, "event_id")) : null;
142
+ const rawBytes = own(ev, "event_bytes");
143
+ let eventBytes;
144
+ if (rawBytes !== undefined && rawBytes !== null) {
145
+ eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
146
+ if (eventBytes === undefined) {
147
+ notRead.push({ packRef, eventId, reason: "record_unreadable" });
148
+ continue;
149
+ }
150
+ }
151
+ const result = await verifyEnvelope(envelope, {
152
+ ...(eventBytes !== undefined ? { payloadBytes: eventBytes } : {}),
153
+ acceptedSpecMajors: RECORD_PROFILES,
154
+ });
155
+ if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
156
+ notRead.push({ packRef, eventId, reason: "record_not_verified" });
157
+ continue;
158
+ }
159
+ if (eventBytes === undefined) {
160
+ notRead.push({ packRef, eventId, reason: "record_withheld" });
161
+ continue;
162
+ }
163
+ if (result.payloadHashOk !== true) {
164
+ notRead.push({ packRef, eventId, reason: "record_not_verified" });
165
+ continue;
166
+ }
167
+ let record;
168
+ try {
169
+ record = jcsParse(eventBytes);
170
+ }
171
+ catch {
172
+ record = undefined;
173
+ }
174
+ if (!isObject(record)) {
175
+ notRead.push({ packRef, eventId, reason: "record_unreadable" });
176
+ continue;
177
+ }
178
+ const key = own(envelope, "public_key");
179
+ const signer = typeof key === "string" ? key : "";
180
+ const signedAt = strOrNull(own(envelope, "signed_at"));
181
+ const section = own(record, "event");
182
+ const event = isObject(section) ? section : {};
183
+ const held = {
184
+ publicKey: signer,
185
+ record: record,
186
+ ref: "",
187
+ };
188
+ if (isHandoffNamespace(held)) {
189
+ const messageHash = own(event, "message_hash");
190
+ if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
191
+ notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
192
+ continue;
193
+ }
194
+ const message = await messageOf(ev, eventId, messageHash, content);
195
+ if (message === ABSENT) {
196
+ notRead.push({ packRef, eventId, reason: "message_withheld" });
197
+ continue;
198
+ }
199
+ const parts = isObject(message) ? own(message, "parts") : undefined;
200
+ if (Array.isArray(parts)) {
201
+ parts.forEach((part, index) => {
202
+ if (!isFilePart(part))
203
+ return;
204
+ commitments.push({
205
+ packRef,
206
+ eventId,
207
+ kind: "file_part",
208
+ partIndex: index,
209
+ contentHash: (part.content_hash).toLowerCase(),
210
+ signer,
211
+ signedAt,
212
+ });
213
+ });
214
+ }
215
+ continue;
216
+ }
217
+ const contentHash = own(event, "content_hash");
218
+ if (!isHexField(contentHash, 64)) {
219
+ notRead.push({ packRef, eventId, reason: "no_content_hash" });
220
+ continue;
221
+ }
222
+ commitments.push({
223
+ packRef,
224
+ eventId,
225
+ kind: "record",
226
+ partIndex: null,
227
+ contentHash: contentHash.toLowerCase(),
228
+ signer,
229
+ signedAt,
230
+ });
231
+ }
232
+ return { commitments, notRead };
233
+ }
234
+ // A malformed file entry is the CALLER's mistake, not the evidence's, so it
235
+ // throws rather than reading as a file that matched nothing.
236
+ async function offeredDigests(files) {
237
+ const out = [];
238
+ for (const entry of files) {
239
+ if (!isObject(entry))
240
+ throw new TypeError("matchFiles: each file is an object {name, bytes}");
241
+ const { name, bytes } = entry;
242
+ if (typeof name !== "string")
243
+ throw new TypeError("matchFiles: a file's name is a string");
244
+ if (!(bytes instanceof Uint8Array)) {
245
+ throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
246
+ }
247
+ out.push([name, await sha256Hex(bytes)]);
248
+ }
249
+ return out;
250
+ }
251
+ async function match(commitments, notRead, files) {
252
+ const offered = await offeredDigests(files);
253
+ const matched = [];
254
+ const unmatched = [];
255
+ const used = new Set();
256
+ for (const [name, digest] of offered) {
257
+ const hits = [];
258
+ commitments.forEach((c, i) => {
259
+ if (c.contentHash === digest)
260
+ hits.push(i);
261
+ });
262
+ if (hits.length > 0) {
263
+ for (const i of hits)
264
+ used.add(i);
265
+ matched.push({ name, sha256: digest, records: hits.map((i) => commitments[i]) });
266
+ }
267
+ else {
268
+ unmatched.push({ name, sha256: digest });
269
+ }
270
+ }
271
+ return {
272
+ offered: offered.length,
273
+ matched,
274
+ unmatched,
275
+ notOffered: commitments.filter((_, i) => !used.has(i)),
276
+ notRead,
277
+ scope: FILE_MATCH_SCOPE,
278
+ };
279
+ }
280
+ /**
281
+ * Match each offered file against the commitments one pack's signed records
282
+ * make. `content` is the side content handed in beside the pack, read only to
283
+ * find a hand-off message that binds to its record's `message_hash`; it is
284
+ * never an offered file.
285
+ */
286
+ export async function matchFiles(bundle, files, content) {
287
+ const { commitments, notRead } = await readPack(bundle, content, null);
288
+ return match(commitments, notRead, files);
289
+ }
290
+ /** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
291
+ export async function matchCaseFiles(packs, files) {
292
+ const commitments = [];
293
+ const notRead = [];
294
+ for (const pack of packs) {
295
+ if (!isObject(pack))
296
+ throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
297
+ if (typeof pack.ref !== "string")
298
+ throw new TypeError("matchCaseFiles: a pack's ref is a string");
299
+ const r = await readPack(pack.bundle, pack.content, pack.ref);
300
+ commitments.push(...r.commitments);
301
+ notRead.push(...r.notRead);
302
+ }
303
+ return match(commitments, notRead, files);
304
+ }
305
+ function commitmentJson(c) {
306
+ return {
307
+ pack_ref: c.packRef,
308
+ event_id: c.eventId,
309
+ kind: c.kind,
310
+ part_index: c.partIndex,
311
+ content_hash: c.contentHash,
312
+ signer: c.signer,
313
+ signed_at: c.signedAt,
314
+ };
315
+ }
316
+ /** The reading as the `files` member of `--json`, identical in both kernels. */
317
+ export function fileMatchToJson(reading) {
318
+ return {
319
+ offered: reading.offered,
320
+ matched: reading.matched.map((m) => ({
321
+ name: m.name,
322
+ sha256: m.sha256,
323
+ records: m.records.map(commitmentJson),
324
+ })),
325
+ unmatched: reading.unmatched.map((u) => ({ name: u.name, sha256: u.sha256 })),
326
+ not_offered: reading.notOffered.map(commitmentJson),
327
+ not_read: reading.notRead.map((n) => ({
328
+ pack_ref: n.packRef,
329
+ event_id: n.eventId,
330
+ reason: n.reason,
331
+ rendered: renderNotRead(n),
332
+ })),
333
+ scope: reading.scope,
334
+ };
335
+ }
336
+ function recordName(eventId) {
337
+ return eventId !== null ? `record ${eventId}` : "a record with no event_id";
338
+ }
339
+ function renderCommitment(c) {
340
+ const what = c.kind === "record"
341
+ ? recordName(c.eventId)
342
+ : `file part ${c.partIndex} of the message in ${recordName(c.eventId)}`;
343
+ const where = c.packRef !== null ? ` in pack ${c.packRef}` : "";
344
+ const when = c.signedAt !== null ? c.signedAt : "no stated time";
345
+ return `${what}${where}, signed by ${c.signer}, signed_at ${when}`;
346
+ }
347
+ function renderNotRead(n) {
348
+ const where = n.packRef !== null ? ` in pack ${n.packRef}` : "";
349
+ if (n.reason === "no_records")
350
+ return `no record read${where}: ${REASON_TEXT[n.reason]}`;
351
+ return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
352
+ }
353
+ /** The text report's lines for the reading, identical in both kernels. */
354
+ export function renderFileMatch(reading) {
355
+ const lines = [
356
+ `files ${reading.offered} offered: ${reading.matched.length} vouched for, ` +
357
+ `${reading.unmatched.length} match no record; ` +
358
+ `${reading.notOffered.length} commitment(s) had no file offered`,
359
+ ];
360
+ for (const m of reading.matched) {
361
+ lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
362
+ for (const c of m.records)
363
+ lines.push(` by ${renderCommitment(c)}`);
364
+ }
365
+ for (const u of reading.unmatched)
366
+ lines.push(` no match ${u.name} sha256 ${u.sha256}`);
367
+ for (const c of reading.notOffered) {
368
+ lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
369
+ }
370
+ for (const n of reading.notRead)
371
+ lines.push(` not read ${renderNotRead(n)}`);
372
+ lines.push(` ${reading.scope}`);
373
+ return lines;
374
+ }
package/dist/grading.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { DeclarationCheck, DeclarationContentKind, DeclarationDeviationKind, DeclarationVerdict } from "./declaration.js";
2
2
  import { type SideContentMap } from "./side-content.js";
3
- export type PackVerdict = "verified" | "partial" | "pending" | "not_found" | "mixed" | "failed" | "malformed" | "incomplete";
3
+ export declare const PACK_VERDICTS: readonly ["verified", "partial", "pending", "not_found", "mixed", "failed", "malformed", "incomplete"];
4
+ export type PackVerdict = (typeof PACK_VERDICTS)[number];
4
5
  /**
5
6
  * The minimum this module reads out of a verification result.
6
7
  *
@@ -12,9 +13,16 @@ export type PackVerdict = "verified" | "partial" | "pending" | "not_found" | "mi
12
13
  */
13
14
  export interface GradeInput {
14
15
  status: string | null | undefined;
16
+ /**
17
+ * `eventId` and `payloadBindingOk` are read by rule version 6 alone (A22):
18
+ * an input that does not carry both is never withheld by its declaration,
19
+ * so an adapter written for rule version 5 keeps the stricter reading.
20
+ */
15
21
  events: {
16
22
  ok: boolean;
17
23
  contentOk: boolean | null;
24
+ eventId?: string;
25
+ payloadBindingOk?: boolean | null;
18
26
  }[];
19
27
  }
20
28
  /**
@@ -56,13 +64,8 @@ export type CompletenessNote = {
56
64
  presented: null;
57
65
  kind: "unreadable";
58
66
  };
59
- export type PackGradeCauseKind = "records-omitted" | "records-surplus" | "leaf-count-unreadable" | "no-disclosure" | "missing-disclosure" | "unreadable-disclosure" | "unaccounted-disclosure" | "declaration-deviation"
60
- /**
61
- * The pack and the UNSIGNED content handed in beside it do not line up
62
- * (proof#147). A cause, so the pack cannot read clean over it and a reader
63
- * is told which record; NOT a deviation, so it never reaches `failed`.
64
- */
65
- | "declaration-content-dependent";
67
+ export declare const PACK_GRADE_CAUSE_KINDS: readonly ["records_omitted", "records_surplus", "leaf_count_unreadable", "no_disclosure", "missing_disclosure", "unreadable_disclosure", "unaccounted_disclosure", "declaration_deviation", "declaration_content_dependent"];
68
+ export type PackGradeCauseKind = (typeof PACK_GRADE_CAUSE_KINDS)[number];
66
69
  /**
67
70
  * One reason a pack is not `verified`, as data.
68
71
  *
@@ -89,8 +92,8 @@ export interface PackGradeCause {
89
92
  reason?: string;
90
93
  /**
91
94
  * Which deviation from the pack's own declaration, for
92
- * `declaration-deviation`, or which content-dependent finding, for
93
- * `declaration-content-dependent`.
95
+ * `declaration_deviation`, or which content-dependent finding, for
96
+ * `declaration_content_dependent`.
94
97
  */
95
98
  deviation?: DeclarationDeviationKind | DeclarationContentKind;
96
99
  }
@@ -107,6 +110,17 @@ export interface PackGrade {
107
110
  * check was passed, which is the pre-§5.4 call.
108
111
  */
109
112
  declaration?: DeclarationVerdict;
113
+ /**
114
+ * Records WITHHELD BY THEIR DECLARATION (rule version 6, evidence-bundle
115
+ * 5.4.5 v1.20, A22): a readable declaration lists them in
116
+ * `declaredWithheld`, their `ok` is true, their `contentOk` is `null` and
117
+ * their `payloadBindingOk` is true. Never counted in `recordsVerified`: no
118
+ * byte of their content was hashed, and a surface MUST NOT print them as
119
+ * verified records.
120
+ */
121
+ recordsWithheld: number;
122
+ /** Their `event_id`s, in bundle order. */
123
+ withheldEventIds: string[];
110
124
  }
111
125
  /**
112
126
  * Account for the disclosed bytes a pack handed us against the records its
@@ -152,13 +166,14 @@ export declare function completenessNotes(bundle: unknown): CompletenessNote[];
152
166
  * Every reason this pack is not `verified`, in the order a reader should hear
153
167
  * them: what is missing from the whole, then what is missing from the parts.
154
168
  */
155
- export declare function gradeCauses(account: DisclosureAccount, notes: readonly CompletenessNote[], declaration?: DeclarationCheck | null): PackGradeCause[];
169
+ export declare function gradeCauses(account: DisclosureAccount, notes: readonly CompletenessNote[], declaration?: DeclarationCheck | null, withheld?: readonly string[]): PackGradeCause[];
156
170
  /**
157
171
  * The verdict a surface may print, and the counts it may print beside it.
158
172
  *
159
173
  * The invariant, stated so a mutation test can kill it: `verified` is reachable
160
- * ONLY when every record's supplied bytes bound to its own commitment and
161
- * nothing is missing, unreadable, surplus or omitted.
174
+ * ONLY when every record's supplied bytes bound to its own commitment, or
175
+ * (rule version 6) the record is withheld by its declaration, and nothing is
176
+ * missing, unreadable, surplus or omitted.
162
177
  *
163
178
  * Precedence: malformed > failed > incomplete > the kernel's own headline.
164
179
  * A hard verdict wins — "the bytes are absent" is never reported as failure