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