@sanning/proof 0.19.1 → 0.20.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 (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
@@ -0,0 +1,527 @@
1
+ // Reading a `sanning.agent` record: agent-events.md §10, steps 1 to 7, as the
2
+ // pinned specs release states them (vendor/specs/agent-events.md).
3
+ //
4
+ // A record reaches this layer after the envelope layer verified its signature
5
+ // and payload hash. Steps 1 to 6 read the record on its own against the schemas
6
+ // bundled in agent-schemas.generated.ts; step 7 reads it with the records the
7
+ // verifier holds beside it. Nothing here reads a file or the network.
8
+ import { AGENT_SCHEMA_VERSIONS } from "./agent-schemas.generated.js";
9
+ import { sha256Hex, utf8 } from "./crypto.js";
10
+ import { deepEqual, escapeToken, has, isObject, SchemaSet, unescapeToken } from "./json-schema.js";
11
+ import { jcs } from "./verifier.js";
12
+ /** The `sanning.events/v2` types outside the agent vocabulary (§3), read by the profile and run-records.md. */
13
+ export const RECORD_TYPES_OUTSIDE_AGENT = Object.freeze([
14
+ "event",
15
+ "checkpoint",
16
+ "declaration",
17
+ "runner.run_opened",
18
+ "runner.run_closed",
19
+ "runner.egress",
20
+ "runner.relay",
21
+ ]);
22
+ /** The `agent.*` types of each vocabulary version this kernel knows, keyed by `extras.event_schema`. With RECORD_TYPES_OUTSIDE_AGENT they are that version's closed set. */
23
+ export const AGENT_EVENT_TYPES = Object.freeze(Object.fromEntries(Object.entries(AGENT_SCHEMA_VERSIONS).map(([version, v]) => [version, Object.freeze(Object.keys(v.types))])));
24
+ // Step 2: sanning.agent/<major>[.<minor>], each 1 to 9 ASCII digits with no
25
+ // leading zero, minor never 0. No `m` flag: `$` is the end of the string.
26
+ const EVENT_SCHEMA_RE = /^sanning\.agent\/[1-9][0-9]{0,8}(\.[1-9][0-9]{0,8})?$/;
27
+ const OUTSIDE = new Set(RECORD_TYPES_OUTSIDE_AGENT);
28
+ const SALT_RE = /^[0-9a-f]{32}$/;
29
+ // The two reasons that read incomplete: a check that could not be completed. Every
30
+ // other reason reads failed, with the reason kind nonconforming.
31
+ export const INCOMPLETE_REASONS = new Set(["event_schema_unknown", "listed_record_absent"]);
32
+ const OPENER = {
33
+ "agent.step.ended": "agent.step.started",
34
+ "agent.model_call.ended": "agent.model_call.started",
35
+ "agent.tool_call.ended": "agent.tool_call.started",
36
+ "agent.retrieval.ended": "agent.retrieval.started",
37
+ "agent.approval.resolved": "agent.approval.requested",
38
+ };
39
+ const CLOSER = Object.fromEntries(Object.entries(OPENER).map(([c, o]) => [o, c]));
40
+ // Step 7a: the checks on a candidate start, worst first.
41
+ const PAIR_REASONS = ["pair_type_mismatch", "pair_run_mismatch", "pair_call_mismatch", "pair_field_mismatch"];
42
+ // §9.1: the fields both halves of one operation agree on, in the order they are checked.
43
+ const PAIR_FIELDS = [
44
+ "gen_ai.operation.name",
45
+ "gen_ai.provider.name",
46
+ "gen_ai.tool.name",
47
+ "approval.subject_event_id",
48
+ "approval.actor",
49
+ "approval.policy_hash",
50
+ ];
51
+ // Step 7h: the approval verdicts that tell the adapter not to run the tool.
52
+ const REFUSALS = new Set(["rejected", "responded"]);
53
+ // §10.1: of several schema errors, the reason first here is reported, then the smallest pointer.
54
+ const SCHEMA_REASONS = ["unknown_field", "missing_field", "bad_value"];
55
+ const schemaSet = new SchemaSet(Object.values(AGENT_SCHEMA_VERSIONS).flatMap((v) => [v.common, ...Object.values(v.types)]));
56
+ const TYPES = new Map(Object.entries(AGENT_SCHEMA_VERSIONS).map(([version, v]) => [
57
+ version,
58
+ new Map(Object.entries(v.types).map(([type, s]) => [type, { id: s.$id, schema: s }])),
59
+ ]));
60
+ function revealableOf(schema) {
61
+ return schema.properties.context.properties.revealed?.items.enum ?? [];
62
+ }
63
+ /**
64
+ * One reading in the words both kernels print: the verdict, every reason and
65
+ * where, then the findings, which are reported beside the verdict and never
66
+ * move it. Byte-identical to `render_agent_reading` in the Python kernel.
67
+ */
68
+ export function renderAgentReading(reading) {
69
+ let text = reading.verdict ?? "no verdict";
70
+ if (reading.reasons !== undefined && reading.reasons.length > 0) {
71
+ text += `: ${reading.reasons.map((r) => `${r.reason} at ${r.at}`).join(", ")}`;
72
+ }
73
+ if (reading.findings.length > 0)
74
+ text += `; findings: ${reading.findings.join(", ")}`;
75
+ return text;
76
+ }
77
+ /** Steps 1 to 6 of agent-events.md §10: one record on its own. */
78
+ export function readAgentRecord(record) {
79
+ const r = record;
80
+ const type = isObject(r) ? r.event_type : undefined;
81
+ const extras = isObject(r) ? r.extras : undefined;
82
+ const hasSchema = isObject(extras) && has(extras, "event_schema");
83
+ // Step 1: the namespace.
84
+ if (typeof type === "string" && OUTSIDE.has(type)) {
85
+ if (hasSchema)
86
+ return failed("event_schema_misplaced", "/extras/event_schema");
87
+ // The salt is defined for every record; on these types only its form is read.
88
+ const ctx = r.context;
89
+ if (isObject(ctx) && has(ctx, "salt") && !(typeof ctx.salt === "string" && SALT_RE.test(ctx.salt))) {
90
+ return failed("bad_value", "/context/salt");
91
+ }
92
+ return { verdict: null, findings: ["not_an_agent_record"] };
93
+ }
94
+ if (typeof type !== "string" || !type.startsWith("agent.")) {
95
+ return failed("event_type_not_in_set", "/event_type");
96
+ }
97
+ // Step 2: the version, before the type, so a type a later minor adds reads incomplete.
98
+ if (!hasSchema)
99
+ return failed("event_schema_missing", "/extras/event_schema");
100
+ const version = extras.event_schema;
101
+ if (typeof version !== "string" || !EVENT_SCHEMA_RE.test(version)) {
102
+ return failed("event_schema_invalid", "/extras/event_schema");
103
+ }
104
+ const types = TYPES.get(version);
105
+ if (types === undefined) {
106
+ const at = "/extras/event_schema";
107
+ return {
108
+ verdict: "incomplete",
109
+ reason: "event_schema_unknown",
110
+ at,
111
+ reasons: [{ reason: "event_schema_unknown", at }],
112
+ findings: [],
113
+ };
114
+ }
115
+ // Step 3: the type, in the closed set of that version.
116
+ const entry = types.get(type);
117
+ if (entry === undefined)
118
+ return failed("event_type_not_in_set", "/event_type");
119
+ // Step 4: the schema.
120
+ const errors = schemaSet.validate(entry.id, r);
121
+ if (errors.length > 0) {
122
+ const e = pickError(r, errors);
123
+ return failed(e.reason, e.at);
124
+ }
125
+ // Step 5: the rules the schema cannot state, in this order.
126
+ const ctx = r.context;
127
+ const meta = r.metadata;
128
+ for (const path of Object.keys(ctx.hidden ?? {}).sort()) {
129
+ if (present(meta, path))
130
+ return failed("hidden_field_present", pointer(path));
131
+ }
132
+ const revealed = new Set(ctx.revealed ?? []);
133
+ for (const path of [...revealed].sort()) {
134
+ if (!present(meta, path))
135
+ return failed("revealed_field_absent", pointer(path));
136
+ }
137
+ for (const path of [...revealableOf(entry.schema)].sort()) {
138
+ if (present(meta, path) && !revealed.has(path))
139
+ return failed("revealed_field_unlisted", pointer(path));
140
+ }
141
+ if (meta.capture !== undefined && meta.run.seq !== 0)
142
+ return failed("capture_not_at_head", "/metadata/capture");
143
+ if (type.startsWith("agent.handoff.")) {
144
+ const h = meta.handoff;
145
+ if (h.file_parts > h.parts)
146
+ return failed("file_parts_exceed_parts", "/metadata/handoff/file_parts");
147
+ if (type === "agent.handoff.received") {
148
+ const indexes = h.unchecked_held_parts;
149
+ const count = ctx.unchecked_held_part_count;
150
+ if (indexes !== undefined && count === undefined) {
151
+ return failed("unchecked_held_parts_missing", "/context/unchecked_held_part_count");
152
+ }
153
+ if (indexes === undefined && count !== undefined) {
154
+ return failed("unchecked_held_parts_missing", "/metadata/handoff/unchecked_held_parts");
155
+ }
156
+ if (count !== undefined && count !== indexes.length) {
157
+ return failed("unchecked_held_parts_mismatch", "/context/unchecked_held_part_count");
158
+ }
159
+ if (count !== undefined && count > h.file_parts) {
160
+ return failed("unchecked_held_parts_exceed_file_parts", "/context/unchecked_held_part_count");
161
+ }
162
+ for (const [i, index] of (indexes ?? []).entries()) {
163
+ if (index >= h.parts)
164
+ return failed("part_index_out_of_range", `/metadata/handoff/unchecked_held_parts/${i}`);
165
+ }
166
+ }
167
+ }
168
+ if (type === "agent.run_closed" && meta.closing.record_ids.length !== meta.closing.record_count) {
169
+ return failed("closing_count_mismatch", "/metadata/closing/record_count");
170
+ }
171
+ if (type === "agent.file") {
172
+ const unhashed = meta.outcome.status === "failed";
173
+ if (unhashed !== (meta.artifact?.reason !== undefined)) {
174
+ return failed("artifact_reason_mismatch", "/metadata/artifact/reason");
175
+ }
176
+ }
177
+ // Step 6: verified, with the findings a reader shows.
178
+ const findings = [];
179
+ if (meta.run.plumbing === true)
180
+ findings.push("plumbing");
181
+ if (ctx.content_scrubbed === true)
182
+ findings.push("content_scrubbed");
183
+ return { verdict: "verified", findings };
184
+ }
185
+ /**
186
+ * Steps 1 to 7 of agent-events.md §10: `subject` read with the records the
187
+ * verifier holds beside it. Asynchronous only because 7a hashes a field to
188
+ * compare it with a hidden one.
189
+ */
190
+ export async function readAgentRecordWithHeld(subject, held = []) {
191
+ const one = readAgentRecord(isObject(subject) ? subject.record : undefined);
192
+ if (one.verdict !== "verified")
193
+ return one;
194
+ // The held records are a set. An entry that is not an object with a string
195
+ // event_id is no record and is skipped. Step 7 reads only the held records
196
+ // that read verified at steps 1 to 6.
197
+ const all = distinct(held.filter((h) => isObject(h) && typeof h.event_id === "string"));
198
+ const peers = all.filter((h) => readAgentRecord(h.record).verdict === "verified");
199
+ return readBeside(subject, one, peers, await commitmentsOf([subject, ...peers]));
200
+ }
201
+ /**
202
+ * Read a pile: each item through steps 1 to 7, with every item held beside it.
203
+ * Each item is a `sanning.events/v2` record whose envelope verified and whose
204
+ * record bytes bound to it. Step 1 runs on every item, so a type outside the
205
+ * closed set reads `failed` whatever its namespace. `null` for an item
206
+ * that is not an object and for one of the seven types outside this vocabulary
207
+ * with no `event_schema`, which step 1 gives no verdict unless its salt is in
208
+ * the wrong form. The item is set aside from its own pile: a record is never
209
+ * one of its own held records. The one reading `verify`, `case` and the bundle
210
+ * verifiers wire in.
211
+ */
212
+ export async function readAgentRecords(items) {
213
+ const all = distinct(items.filter((h) => isObject(h) && typeof h.event_id === "string"));
214
+ const peers = all.filter((h) => readAgentRecord(h.record).verdict === "verified");
215
+ const readable = items.filter((h) => isObject(h) && readAgentRecord(h.record).verdict === "verified");
216
+ const commitments = await commitmentsOf(readable);
217
+ return items.map((item) => {
218
+ if (!isObject(item))
219
+ return null;
220
+ const one = readAgentRecord(item.record);
221
+ if (one.verdict === null)
222
+ return null;
223
+ if (one.verdict !== "verified")
224
+ return one;
225
+ return readBeside(item, one, peers, commitments);
226
+ });
227
+ }
228
+ /**
229
+ * Step 7: `subject`, which read `one` (verified) on its own, with the held
230
+ * records. A held copy of `subject` (`sameRecord`) is set aside first: a record
231
+ * is never one of its own held records. A held record with the subject's
232
+ * event_id and other bytes is another record and stays.
233
+ */
234
+ function readBeside(subject, one, held, commitments) {
235
+ const peers = held.filter((h) => !sameRecord(h, subject));
236
+ const record = subject.record;
237
+ const type = record.event_type;
238
+ const run = record.metadata.run;
239
+ const key = keyOf(subject.public_key);
240
+ const findings = [...one.findings];
241
+ // Every sub-step of step 7 runs, and every reason it gives is kept, in step order.
242
+ const reasons = [];
243
+ const absentIds = [];
244
+ const mine = distinct([subject, ...peers]).filter((h) => keyOf(h.public_key) === key && h.record.metadata.run.root_run_id === run.root_run_id);
245
+ const grade = (end, start, opener) => gradePair(end, start, opener, commitments);
246
+ const endsOf = (start) => {
247
+ const s = start.record;
248
+ const closer = CLOSER[s.event_type];
249
+ return peers.filter((h) => {
250
+ const e = h.record;
251
+ return (e.event_type === closer &&
252
+ keyOf(h.public_key) === keyOf(start.public_key) &&
253
+ e.metadata.run.started_event_id === start.event_id &&
254
+ grade(e, s, s.event_type) === undefined);
255
+ });
256
+ };
257
+ // Step 7a: an end and the record it names.
258
+ const opener = OPENER[type];
259
+ if (opener !== undefined && run.started_event_id === null) {
260
+ // The invocation's first record is its one record at run.seq 0.
261
+ const heads = mine.filter((h) => h.record.metadata.run.seq === 0);
262
+ const declared = heads.length === 1 && (heads[0].record.metadata.capture?.not_captured ?? []).includes(opener);
263
+ findings.push(declared ? "start_not_captured" : "start_undeclared");
264
+ }
265
+ else if (opener !== undefined) {
266
+ // A candidate is a record this key signed: a record another key signed is never this end's start.
267
+ const candidates = peers.filter((h) => h.event_id === run.started_event_id && keyOf(h.public_key) === key);
268
+ if (candidates.length === 0)
269
+ findings.push("start_absent");
270
+ if (candidates.length > 1)
271
+ findings.push("start_ambiguous");
272
+ let worst;
273
+ for (const c of candidates) {
274
+ const g = grade(record, c.record, opener);
275
+ if (g !== undefined && (worst === undefined || compareGrade(g, worst) < 0))
276
+ worst = g;
277
+ }
278
+ if (worst !== undefined)
279
+ reasons.push({ reason: worst.reason, at: worst.at });
280
+ }
281
+ // Step 7b: a start and the records that end it.
282
+ if (CLOSER[type] !== undefined) {
283
+ const ends = endsOf(subject);
284
+ if (ends.length === 0)
285
+ findings.push("unended");
286
+ if (ends.length > 1)
287
+ findings.push("ended_more_than_once");
288
+ }
289
+ // Step 7c: a closing record, judged only on every record it lists.
290
+ if (type === "agent.run_closed") {
291
+ const ids = record.metadata.closing.record_ids;
292
+ let absent = 0;
293
+ for (const [i, id] of ids.entries()) {
294
+ if (!peers.some((h) => h.event_id === id && keyOf(h.public_key) === key)) {
295
+ reasons.push({ reason: "listed_record_absent", at: `/metadata/closing/record_ids/${i}` });
296
+ absentIds.push(id);
297
+ absent++;
298
+ }
299
+ }
300
+ if (absent === 0 && record.metadata.closing.outcome.observed.status === "completed") {
301
+ const listed = new Set(ids);
302
+ const open = peers.some((h) => listed.has(h.event_id) &&
303
+ keyOf(h.public_key) === key &&
304
+ CLOSER[h.record.event_type] !== undefined &&
305
+ !endsOf(h).some((e) => listed.has(e.event_id)));
306
+ if (open)
307
+ findings.push("completed_with_open_operation");
308
+ }
309
+ }
310
+ // Step 7d: the two records of one hand-off message agree on its parts.
311
+ if (type.startsWith("agent.handoff.")) {
312
+ const other = type === "agent.handoff.sent" ? "agent.handoff.received" : "agent.handoff.sent";
313
+ const named = keyOf(record.event.counterparty_public_key);
314
+ const hash = String(record.event.message_hash).toLowerCase();
315
+ // Candidates for each other: each is signed by the key the other names.
316
+ const counterparts = peers.filter((h) => {
317
+ const c = h.record;
318
+ return (c.event_type === other &&
319
+ keyOf(h.public_key) === named &&
320
+ keyOf(c.event.counterparty_public_key) === key &&
321
+ String(c.event.message_hash).toLowerCase() === hash);
322
+ });
323
+ // A finding on both records, never a verdict: two keys signed them, so a verdict
324
+ // would let one side's count make the other side's honest record read failed.
325
+ const counts = (r) => `${r.metadata.handoff.parts}/${r.metadata.handoff.file_parts}`;
326
+ if (counterparts.some((h) => counts(h.record) !== counts(record)))
327
+ findings.push("handoff_parts_mismatch");
328
+ }
329
+ // Step 7e is not in sanning.agent/1: the runner cross-check waits for the runner's fields.
330
+ // Step 7f: one invocation, one data mode.
331
+ const mixed = peers.some((h) => {
332
+ const p = h.record;
333
+ return (keyOf(h.public_key) === key &&
334
+ p.metadata.run.root_run_id === run.root_run_id &&
335
+ p.context.data_mode !== record.context.data_mode);
336
+ });
337
+ if (mixed)
338
+ findings.push("data_mode_mixed");
339
+ // Step 7g: one record at each position of the invocation's chain.
340
+ if (mine.some((h) => !sameRecord(h, subject) && h.record.metadata.run.seq === run.seq)) {
341
+ findings.push("seq_duplicate");
342
+ }
343
+ // Step 7h: a tool call that ran after its approval refused it. A refusal is an
344
+ // answer, under this key, whose subject is this call's start and whose verdict
345
+ // is rejected or responded.
346
+ if (type === "agent.tool_call.ended" && run.started_event_id !== null) {
347
+ const refused = peers.some((h) => {
348
+ const a = h.record;
349
+ return (a.event_type === "agent.approval.resolved" &&
350
+ keyOf(h.public_key) === key &&
351
+ a.metadata.approval.subject_event_id === run.started_event_id &&
352
+ REFUSALS.has(a.metadata.approval.verdict));
353
+ });
354
+ const status = record.metadata.outcome.status;
355
+ if (refused && (status === "completed" || status === "failed"))
356
+ findings.push("ran_after_refusal");
357
+ }
358
+ // Step 7i: the position directly before this record is not held, and an earlier
359
+ // one of this invocation is. The verifier cannot tell a missing record from one
360
+ // never written.
361
+ const seqs = mine.map((h) => h.record.metadata.run.seq);
362
+ if (run.seq > 0 && seqs.some((n) => n < run.seq) && !seqs.some((n) => n === run.seq - 1)) {
363
+ findings.push("seq_gap");
364
+ }
365
+ // Step 7j: the run promised a closing record, and none of this invocation is held.
366
+ if (type === "agent.run_opened" && !mine.some((h) => h.record.event_type === "agent.run_closed")) {
367
+ findings.push("closing_not_held");
368
+ }
369
+ return reading(reasons, findings, absentIds);
370
+ }
371
+ /**
372
+ * Step 7: the reading from every reason step 7 gave. The verdict is the worst
373
+ * (failed, then incomplete); `reason` and `at` are the first reason of that verdict.
374
+ */
375
+ function reading(reasons, findings, absent) {
376
+ if (reasons.length === 0)
377
+ return { verdict: "verified", findings };
378
+ const failed = reasons.some((r) => !INCOMPLETE_REASONS.has(r.reason));
379
+ const first = reasons.find((r) => INCOMPLETE_REASONS.has(r.reason) !== failed);
380
+ const out = {
381
+ verdict: failed ? "failed" : "incomplete",
382
+ reason: first.reason,
383
+ at: first.at,
384
+ reasons,
385
+ findings,
386
+ };
387
+ // Carried for the claims and never serialized: not part of the reading `expected.json`
388
+ // spells, as Python's `to_dict` leaves it out.
389
+ if (absent.length > 0)
390
+ Object.defineProperty(out, "absent", { value: absent, enumerable: false });
391
+ return out;
392
+ }
393
+ /** Step 7a: one candidate start, under the end's key, graded against the end. undefined when it is the end's start. */
394
+ function gradePair(end, start, opener, commitments) {
395
+ const at = "/metadata/run/started_event_id";
396
+ if (start.event_type !== opener)
397
+ return grade("pair_type_mismatch", at);
398
+ const a = end.metadata;
399
+ const b = start.metadata;
400
+ if (a.run.run_id !== b.run.run_id || a.run.root_run_id !== b.run.root_run_id) {
401
+ // The one exception: an approval answered in a later invocation of the same
402
+ // conversation, where both ids differ.
403
+ const approval = end.event_type === "agent.approval.resolved";
404
+ const conv = a.gen_ai?.conversation?.id;
405
+ const both = a.run.run_id !== b.run.run_id && a.run.root_run_id !== b.run.root_run_id;
406
+ if (!approval || !both || conv === undefined || conv !== b.gen_ai?.conversation?.id) {
407
+ return grade("pair_run_mismatch", at);
408
+ }
409
+ }
410
+ if (end.event_type === "agent.tool_call.ended" && a.gen_ai.tool.call.id !== b.gen_ai.tool.call.id) {
411
+ return grade("pair_call_mismatch", at);
412
+ }
413
+ for (const [i, path] of PAIR_FIELDS.entries()) {
414
+ const x = commitments.get(end)?.get(path);
415
+ const y = commitments.get(start)?.get(path);
416
+ if (x !== undefined && y !== undefined && x !== y)
417
+ return grade("pair_field_mismatch", pointer(path), i);
418
+ }
419
+ return undefined;
420
+ }
421
+ function grade(reason, at, field = -1) {
422
+ return { reason, at, rank: [PAIR_REASONS.indexOf(reason), field] };
423
+ }
424
+ function compareGrade(a, b) {
425
+ return a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1];
426
+ }
427
+ /**
428
+ * What each record commits to for each field of §9.1: SHA-256 of the JCS bytes
429
+ * of its value, or its hidden hash. A value JCS cannot encode commits to
430
+ * nothing a hidden hash can equal, and still equals the same value in clear.
431
+ */
432
+ async function commitmentsOf(items) {
433
+ const out = new Map();
434
+ for (const h of items) {
435
+ const record = h.record;
436
+ if (out.has(record))
437
+ continue;
438
+ const m = new Map();
439
+ for (const path of PAIR_FIELDS) {
440
+ if (present(record.metadata, path)) {
441
+ const value = lookup(record.metadata, path);
442
+ const bytes = jcsOrUndefined(value);
443
+ m.set(path, bytes === undefined ? `unencodable:${String(value)}` : await sha256Hex(utf8(bytes)));
444
+ }
445
+ else {
446
+ m.set(path, record.context.hidden?.[path]);
447
+ }
448
+ }
449
+ out.set(record, m);
450
+ }
451
+ return out;
452
+ }
453
+ function jcsOrUndefined(value) {
454
+ try {
455
+ return jcs(value);
456
+ }
457
+ catch {
458
+ return undefined;
459
+ }
460
+ }
461
+ /**
462
+ * Two held records are one when their event ids, their keys and their JCS bytes
463
+ * are equal. Equal JSON values have equal JCS bytes, so this compares values:
464
+ * a copy JCS refuses to encode is still a copy.
465
+ */
466
+ function sameRecord(x, y) {
467
+ if (x.event_id !== y.event_id || keyOf(x.public_key) !== keyOf(y.public_key))
468
+ return false;
469
+ return deepEqual(x.record, y.record);
470
+ }
471
+ /** The held records as a set: the first of each group of copies. */
472
+ function distinct(list) {
473
+ return list.filter((h, i) => list.findIndex((x) => sameRecord(x, h)) === i);
474
+ }
475
+ /** A key compared as a key: 64 hex characters, case ignored. */
476
+ function keyOf(k) {
477
+ return String(k).toLowerCase();
478
+ }
479
+ /** Steps 1 to 6 stop at the first failure: one reason. */
480
+ function failed(reason, at) {
481
+ return { verdict: "failed", reason, at, reasons: [{ reason, at }], findings: [] };
482
+ }
483
+ /** A member is present when its key is there, whatever its value: a null value is present. */
484
+ function present(obj, dotted) {
485
+ let cur = obj;
486
+ for (const k of dotted.split(".")) {
487
+ if (!isObject(cur) || !has(cur, k))
488
+ return false;
489
+ cur = cur[k];
490
+ }
491
+ return true;
492
+ }
493
+ function lookup(obj, dotted) {
494
+ let cur = obj;
495
+ for (const k of dotted.split("."))
496
+ cur = cur[k];
497
+ return cur;
498
+ }
499
+ function pointer(dotted) {
500
+ return "/metadata/" + dotted.split(".").map(escapeToken).join("/");
501
+ }
502
+ /**
503
+ * §10.1: of several schema errors, `unknown_field` before `missing_field`
504
+ * before `bad_value`, then the smallest pointer. Pointers compare token by
505
+ * token, an array index as a number and any other token by UTF-16 code units; a
506
+ * pointer comes before every pointer it is a prefix of.
507
+ */
508
+ function pickError(record, errors) {
509
+ return [...errors].sort((a, b) => SCHEMA_REASONS.indexOf(a.reason) - SCHEMA_REASONS.indexOf(b.reason) || comparePointers(record, a.at, b.at))[0];
510
+ }
511
+ function tokens(p) {
512
+ return p === "" ? [] : p.slice(1).split("/").map(unescapeToken);
513
+ }
514
+ function comparePointers(record, a, b) {
515
+ const x = tokens(a);
516
+ const y = tokens(b);
517
+ let container = record;
518
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
519
+ if (x[i] !== y[i]) {
520
+ if (Array.isArray(container))
521
+ return Number(x[i]) - Number(y[i]);
522
+ return x[i] < y[i] ? -1 : 1;
523
+ }
524
+ container = container?.[x[i]];
525
+ }
526
+ return x.length - y.length;
527
+ }
@@ -0,0 +1,15 @@
1
+ import { type JsonSchema } from "./json-schema.js";
2
+ /** The specs release the schemas below are pinned from. */
3
+ export declare const AGENT_SCHEMA_PIN: Readonly<{
4
+ readonly source: "sanning-io/specs";
5
+ readonly tag: "89feb06";
6
+ readonly commit: "89feb067bdd88871a0264bdede331f64ad36a179";
7
+ }>;
8
+ /**
9
+ * Each pinned vocabulary version: its shared definitions and one schema per event type.
10
+ * Frozen, so no code in the process can loosen what a record is validated against.
11
+ */
12
+ export declare const AGENT_SCHEMA_VERSIONS: Readonly<Record<string, {
13
+ readonly common: JsonSchema;
14
+ readonly types: Readonly<Record<string, JsonSchema>>;
15
+ }>>;