@cygnuxlabs/tracekit 0.0.0-stage → 1.2.2
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 +294 -2
- package/bin/tracekit.mjs +30 -0
- package/dist/v1.d.ts +97 -0
- package/dist/v1.js +535 -0
- package/dist/v2/adapters/claude-agent-sdk.d.ts +45 -0
- package/dist/v2/adapters/claude-agent-sdk.js +142 -0
- package/dist/v2/adapters/gate.d.ts +62 -0
- package/dist/v2/adapters/gate.js +195 -0
- package/dist/v2/adapters/openai-agents.d.ts +85 -0
- package/dist/v2/adapters/openai-agents.js +114 -0
- package/dist/v2/adapters/vercel-ai.d.ts +51 -0
- package/dist/v2/adapters/vercel-ai.js +132 -0
- package/dist/v2/client.d.ts +159 -0
- package/dist/v2/client.js +749 -0
- package/dist/v2/executor.d.ts +49 -0
- package/dist/v2/executor.js +57 -0
- package/dist/v2/jcs.d.ts +5 -0
- package/dist/v2/jcs.js +12 -0
- package/dist/v2/launcher.d.ts +10 -0
- package/dist/v2/launcher.js +50 -0
- package/dist/v2/rpc_schema.d.ts +3 -0
- package/dist/v2/rpc_schema.js +43 -0
- package/dist/v2/validate.d.ts +6 -0
- package/dist/v2/validate.js +70 -0
- package/dist/verify/cli.d.ts +1 -0
- package/dist/verify/cli.js +46 -0
- package/dist/verify/detect.d.ts +41 -0
- package/dist/verify/detect.js +289 -0
- package/dist/verify/index.d.ts +34 -0
- package/dist/verify/index.js +930 -0
- package/dist/verify/jcs.d.ts +19 -0
- package/dist/verify/jcs.js +154 -0
- package/dist/verify/primitives.d.ts +62 -0
- package/dist/verify/primitives.js +298 -0
- package/dist/verify/schema.d.ts +11 -0
- package/dist/verify/schema.js +98 -0
- package/dist/verify/tracekit.event.v2.json +4588 -0
- package/dist/verify/zip.d.ts +17 -0
- package/dist/verify/zip.js +145 -0
- package/package.json +42 -4
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An independent verifier of `tracekit.bundle.v2` bundles, written from docs/format-v2.md (§11 is the algorithm) and
|
|
3
|
+
* checked against the reference (tracekit/verify/v2.py) by tests/test_ts_verifier.py: same checks, integrity, assurance
|
|
4
|
+
* and exit codes. Web Crypto and DecompressionStream only, so it runs in Node ≥ 20 and current browsers.
|
|
5
|
+
*
|
|
6
|
+
* import { verify } from "@cygnuxlabs/tracekit/verify";
|
|
7
|
+
* const { report, code } = await verify(bundleBytes, trustConfigBytes);
|
|
8
|
+
*
|
|
9
|
+
* Not implemented here, so never VERIFIED when a bundle relies on them (integrity `UNVERIFIABLE (...)` names them):
|
|
10
|
+
* SLH-DSA-SHA2-128s hybrid checkpoint lines (Web Crypto has no SLH-DSA), Rekor v2 anchors, record-key certificates,
|
|
11
|
+
* tracekit why findings and replay runs.
|
|
12
|
+
* Monitor reports, revocations and the v1 format bridge are inputs of the reference's CLI that this one doesn't take.
|
|
13
|
+
*/
|
|
14
|
+
import { canonicalize, strictParseBytes } from "./jcs.js";
|
|
15
|
+
import { HYBRID, NoteError, SIGNER_LEAVES, b64decode, bytes, equal, keyAlg, leafHash, openNote, parseLeaf, parseVkey, registryOrigin, runHash, sha256hex, spkiKid, unhex, verifyConsistency, verifyInclusion, verifyV2, vkey, } from "./primitives.js";
|
|
16
|
+
import { Detector } from "./detect.js";
|
|
17
|
+
import { same, validateEvent } from "./schema.js";
|
|
18
|
+
import { MAX_ENTRY, readZip } from "./zip.js";
|
|
19
|
+
export { MAX_ENTRIES, MAX_ENTRY, MAX_TOTAL, Unusable, readZip } from "./zip.js";
|
|
20
|
+
export { StrictJSONError, canonicalize, strictParse, strictParseBytes } from "./jcs.js";
|
|
21
|
+
/** The bundle format version this verifier implements: a bundle needing a later one is UNVERIFIABLE. */
|
|
22
|
+
export const VERSION = "1.2.2";
|
|
23
|
+
export const EXIT_OK = 0, EXIT_FAIL = 1, EXIT_BAD = 2, EXIT_WARN = 3;
|
|
24
|
+
const FORMAT = "tracekit.bundle.v2";
|
|
25
|
+
const MAX_LINE = 1 << 20;
|
|
26
|
+
const MAX_LINES = MAX_ENTRY / 256; // per JSON lines file: no signed record is shorter than 256 bytes
|
|
27
|
+
const CLASSES = ["public", "customer", "tracekit", "operator"];
|
|
28
|
+
const KEY_TYPES = ["signer.epoch", "key.retire"];
|
|
29
|
+
const ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
30
|
+
const MATRIX = ["model_calls", "tool_calls", "network", "processes", "services"];
|
|
31
|
+
const COVERED = ["none", "reported", "observed", "enforced"]; // weakest first
|
|
32
|
+
export class Report {
|
|
33
|
+
checks = [];
|
|
34
|
+
failures = [];
|
|
35
|
+
warnings = [];
|
|
36
|
+
notes = [];
|
|
37
|
+
integrity = "UNUSABLE BUNDLE";
|
|
38
|
+
assurance = "none";
|
|
39
|
+
/** Checks the bundle needs that this verifier does not implement. */
|
|
40
|
+
unsupported = [];
|
|
41
|
+
check(check, ok, detail = "", problems = [], warn = false) {
|
|
42
|
+
this.checks.push({ check, status: ok ? "pass" : warn ? "warn" : "fail", detail, problems });
|
|
43
|
+
if (!ok)
|
|
44
|
+
(warn ? this.warnings : this.failures).push(check);
|
|
45
|
+
}
|
|
46
|
+
unsupport(what) {
|
|
47
|
+
if (this.unsupported.includes(what))
|
|
48
|
+
return;
|
|
49
|
+
this.unsupported.push(what);
|
|
50
|
+
this.check(what, false, "not implemented by this verifier", [], true);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
54
|
+
/** The manifest check's detail: on a failure, the files that differ from the manifest (the first 5) and how. */
|
|
55
|
+
function manifestDetail(listed, hashes, bad) {
|
|
56
|
+
if (!isObj(listed))
|
|
57
|
+
return "the manifest lists no files";
|
|
58
|
+
if (bad.length === 0)
|
|
59
|
+
return "every file is listed with its SHA-256 and nothing else is in the bundle";
|
|
60
|
+
const how = bad.slice(0, 5).map((n) => `${n} (${Object.hasOwn(listed, n) && hashes.has(n) ? "changed"
|
|
61
|
+
: Object.hasOwn(listed, n) ? "missing" : "not listed"})`);
|
|
62
|
+
return "files differ from the manifest: " + how.join(", ") + (bad.length > 5 ? ` and ${bad.length - 5} more` : "");
|
|
63
|
+
}
|
|
64
|
+
const kind = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
|
|
65
|
+
function at(o, k) {
|
|
66
|
+
if (!isObj(o))
|
|
67
|
+
throw new TypeError(`${kind(o)} is not an object`);
|
|
68
|
+
if (!Object.hasOwn(o, k))
|
|
69
|
+
throw new TypeError(`missing ${JSON.stringify(k)}`);
|
|
70
|
+
return o[k];
|
|
71
|
+
}
|
|
72
|
+
function get(o, k, missing = null) {
|
|
73
|
+
if (!isObj(o))
|
|
74
|
+
throw new TypeError(`${kind(o)} is not an object`);
|
|
75
|
+
return Object.hasOwn(o, k) ? o[k] : missing;
|
|
76
|
+
}
|
|
77
|
+
function has(o, k) {
|
|
78
|
+
if (isObj(o))
|
|
79
|
+
return Object.hasOwn(o, k);
|
|
80
|
+
if (Array.isArray(o))
|
|
81
|
+
return o.some((x) => x === k);
|
|
82
|
+
if (typeof o === "string")
|
|
83
|
+
return o.includes(k);
|
|
84
|
+
throw new TypeError(`${kind(o)} is not a container`);
|
|
85
|
+
}
|
|
86
|
+
function list(v) {
|
|
87
|
+
if (!Array.isArray(v))
|
|
88
|
+
throw new TypeError(`${kind(v)} is not a list`);
|
|
89
|
+
return v;
|
|
90
|
+
}
|
|
91
|
+
function num(v) {
|
|
92
|
+
if (typeof v === "number")
|
|
93
|
+
return v;
|
|
94
|
+
if (typeof v === "boolean")
|
|
95
|
+
return Number(v);
|
|
96
|
+
throw new TypeError(`${kind(v)} is not a number`);
|
|
97
|
+
}
|
|
98
|
+
function text(v) {
|
|
99
|
+
if (typeof v !== "string")
|
|
100
|
+
throw new TypeError(`${kind(v)} is not a string`);
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
const truthy = (v) => Array.isArray(v) ? v.length > 0 : isObj(v) ? Object.keys(v).length > 0 : !!v;
|
|
104
|
+
const str = (v) => v === null ? "None" : v === true ? "True" : v === false ? "False"
|
|
105
|
+
: typeof v === "string" ? v : JSON.stringify(v);
|
|
106
|
+
const repr = (v) => typeof v === "string" ? `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'` : str(v);
|
|
107
|
+
const cut = (s, n) => [...s].slice(0, n).join("");
|
|
108
|
+
const count = (xs) => {
|
|
109
|
+
const m = new Map();
|
|
110
|
+
for (const x of xs)
|
|
111
|
+
m.set(x, (m.get(x) ?? 0) + 1);
|
|
112
|
+
return [...m].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
113
|
+
};
|
|
114
|
+
const fmt = (ts) => {
|
|
115
|
+
if (ts > 253402300799)
|
|
116
|
+
throw new RangeError("year is out of range");
|
|
117
|
+
return new Date(Number(ts) * 1000).toISOString().slice(0, 19) + "Z";
|
|
118
|
+
};
|
|
119
|
+
async function loadTrust(data) {
|
|
120
|
+
const floats = new WeakMap();
|
|
121
|
+
const t = strictParseBytes(data.subarray(0, MAX_LINE), floats);
|
|
122
|
+
const int = (o, k, d) => !Object.hasOwn(o, k) ? d
|
|
123
|
+
: Number.isInteger(o[k]) && !floats.get(o)?.has(k) ? o[k] : NaN;
|
|
124
|
+
const exactly = (o, ks) => isObj(o) && Object.keys(o).length === ks.length && ks.every((k) => Object.hasOwn(o, k));
|
|
125
|
+
const strings = (a) => Array.isArray(a) && a.length > 0 && a.every((x) => typeof x === "string");
|
|
126
|
+
const opt = (o, k) => Object.hasOwn(o, k) ? o[k] : [];
|
|
127
|
+
const r = isObj(t) ? get(t, "rekor", {}) : null;
|
|
128
|
+
const ok = isObj(t) && Object.keys(t).every((k) => ["logs", "witnesses", "algs", "witnesses_required", "rekor",
|
|
129
|
+
"monitors", "issuers"].includes(k))
|
|
130
|
+
&& isObj(r) && (!Object.keys(r).length || exactly(r, ["trusted_root", "publishing_key", "class"])
|
|
131
|
+
&& isObj(r.trusted_root) && typeof r.publishing_key === "string" && CLASSES.includes(r.class))
|
|
132
|
+
&& strings(t.logs)
|
|
133
|
+
&& Array.isArray(opt(t, "witnesses")) && opt(t, "witnesses").every((w) => exactly(w, ["vkey", "class"])
|
|
134
|
+
&& typeof w.vkey === "string" && CLASSES.includes(w.class))
|
|
135
|
+
&& strings(t.algs) && int(t, "witnesses_required", 0) >= 0
|
|
136
|
+
&& Array.isArray(opt(t, "monitors")) && opt(t, "monitors").every((m) => exactly(m, ["vkey", "class", "max_age_s"])
|
|
137
|
+
&& typeof m.vkey === "string" && CLASSES.includes(m.class) && int(m, "max_age_s", 0) > 0)
|
|
138
|
+
&& Array.isArray(opt(t, "issuers")) && opt(t, "issuers").every((i) => exactly(i, ["vkey", "issuance_log_vkey"])
|
|
139
|
+
&& Object.values(i).every((v) => typeof v === "string"));
|
|
140
|
+
if (!ok)
|
|
141
|
+
throw new Error("not a v2 trust config (logs, witnesses, algs, witnesses_required, rekor, monitors, issuers)");
|
|
142
|
+
for (const k of [...t.logs, ...opt(t, "witnesses").map((w) => w.vkey), ...opt(t, "monitors").map((m) => m.vkey),
|
|
143
|
+
...opt(t, "issuers").flatMap((i) => Object.values(i))])
|
|
144
|
+
await parseVkey(k);
|
|
145
|
+
t.witnesses ??= [];
|
|
146
|
+
t.witnesses_required ??= 0;
|
|
147
|
+
t.issuers ??= [];
|
|
148
|
+
return t;
|
|
149
|
+
}
|
|
150
|
+
function newer(need, have) {
|
|
151
|
+
const a = need.split(".").map(Number), b = have.split(".").map(Number);
|
|
152
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++)
|
|
153
|
+
if (a[i] !== b[i])
|
|
154
|
+
return a[i] > b[i];
|
|
155
|
+
return a.length > b.length;
|
|
156
|
+
}
|
|
157
|
+
const errorText = (e) => `${e?.constructor?.name ?? "Error"}: ${e?.message ?? e}`;
|
|
158
|
+
/** Verify a v2 bundle (zip bytes) against the verifier's own pinned trust config (JSON bytes). Never throws. */
|
|
159
|
+
export async function verify(bundle, trust) {
|
|
160
|
+
const rep = new Report();
|
|
161
|
+
let t, files, manifest;
|
|
162
|
+
try {
|
|
163
|
+
t = await loadTrust(trust);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
rep.check("trust config", false, errorText(e));
|
|
167
|
+
return { report: rep, code: EXIT_BAD };
|
|
168
|
+
}
|
|
169
|
+
const floats = new WeakMap();
|
|
170
|
+
try {
|
|
171
|
+
files = await readZip(bundle);
|
|
172
|
+
const m = files.get("manifest.json");
|
|
173
|
+
if (!m)
|
|
174
|
+
throw new Error("no manifest.json");
|
|
175
|
+
files.delete("manifest.json");
|
|
176
|
+
manifest = strictParseBytes(m, floats);
|
|
177
|
+
if (!isObj(manifest) || manifest.format !== FORMAT)
|
|
178
|
+
throw new Error(`format is not ${FORMAT}`);
|
|
179
|
+
const need = manifest.verifier_min_version;
|
|
180
|
+
if (!(typeof need === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(need)))
|
|
181
|
+
throw new Error("verifier_min_version is not a version number");
|
|
182
|
+
if (newer(need, VERSION)) {
|
|
183
|
+
rep.integrity = `UNVERIFIABLE (needs tracekit >= ${need})`;
|
|
184
|
+
rep.check("bundle readable", false, rep.integrity);
|
|
185
|
+
return { report: rep, code: EXIT_BAD };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
rep.check("bundle readable", false, `cannot read bundle: ${errorText(e)}`);
|
|
190
|
+
return { report: rep, code: EXIT_BAD };
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
await check(rep, manifest, files, t, floats);
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
rep.check("bundle structure", false, "", [`malformed and could not be fully checked: ${errorText(e)}`]);
|
|
197
|
+
}
|
|
198
|
+
if (rep.failures.length)
|
|
199
|
+
rep.integrity = "FAILED";
|
|
200
|
+
else if (rep.unsupported.length)
|
|
201
|
+
rep.integrity = `UNVERIFIABLE (not checked by this verifier: ${rep.unsupported.join(", ")})`;
|
|
202
|
+
return { report: rep, code: rep.failures.length ? EXIT_FAIL : rep.integrity.startsWith("UNVERIFIABLE") ? EXIT_BAD : EXIT_OK };
|
|
203
|
+
}
|
|
204
|
+
async function check(rep, manifest, files, trust, floats) {
|
|
205
|
+
const file = (name) => {
|
|
206
|
+
const f = files.get(name);
|
|
207
|
+
if (!f)
|
|
208
|
+
throw new TypeError(`no ${name}`);
|
|
209
|
+
return f;
|
|
210
|
+
};
|
|
211
|
+
const json = (name) => strictParseBytes(file(name), floats);
|
|
212
|
+
const jsonl = (name) => {
|
|
213
|
+
const data = file(name);
|
|
214
|
+
if (data.length && data.at(-1) !== 0x0a)
|
|
215
|
+
throw new Error("a JSON lines file must end in a newline");
|
|
216
|
+
const lines = [];
|
|
217
|
+
for (let i = 0, j; (j = data.indexOf(0x0a, i)) >= 0; i = j + 1) {
|
|
218
|
+
// checked before any is parsed: a run of tiny lines costs no memory
|
|
219
|
+
if (lines.length === MAX_LINES)
|
|
220
|
+
throw new Error("more lines than the bundle limits");
|
|
221
|
+
lines.push(data.subarray(i, j));
|
|
222
|
+
}
|
|
223
|
+
if (lines.some((l) => l.length > MAX_LINE))
|
|
224
|
+
throw new Error("line longer than the bundle limits");
|
|
225
|
+
return lines.map((l) => strictParseBytes(l, floats));
|
|
226
|
+
};
|
|
227
|
+
const listed = manifest.files, hashes = new Map();
|
|
228
|
+
for (const [n, b] of files)
|
|
229
|
+
hashes.set(n, await sha256hex(b));
|
|
230
|
+
const bad = isObj(listed) ? [...new Set([...Object.keys(listed), ...hashes.keys()])]
|
|
231
|
+
.filter((n) => !(Object.hasOwn(listed, n) && hashes.get(n) === listed[n])).sort() : [];
|
|
232
|
+
rep.check("manifest", isObj(listed) && bad.length === 0, manifestDetail(listed, hashes, bad));
|
|
233
|
+
// the checkpoint: the only thing the records' trust hangs on
|
|
234
|
+
const proofs = json("proofs/records.json");
|
|
235
|
+
const name = at(proofs, "checkpoint");
|
|
236
|
+
if (!(typeof name === "string" && name.startsWith("checkpoints/") && name.endsWith(".note")))
|
|
237
|
+
throw new Error("proofs name no checkpoint note");
|
|
238
|
+
const witnesses = new Map(trust.witnesses.map((w) => [w.vkey, w.class]));
|
|
239
|
+
let note;
|
|
240
|
+
try {
|
|
241
|
+
note = await openNote(file(name), trust.logs, [...witnesses.keys()]);
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
if (!(e instanceof NoteError))
|
|
245
|
+
throw e;
|
|
246
|
+
rep.check("checkpoint", false, e.message);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const { origin, size, root, cosigs } = note;
|
|
250
|
+
rep.check("checkpoint", same(size, at(proofs, "tree_size")), `${origin} at tree size ${size}, signed by its pinned log key`);
|
|
251
|
+
if (note.unchecked)
|
|
252
|
+
rep.unsupport("SLH-DSA-SHA2-128s checkpoint signature");
|
|
253
|
+
rep.check("witness quorum", cosigs.length >= trust.witnesses_required, `${cosigs.length} pinned cosignature(s), ${trust.witnesses_required} required`);
|
|
254
|
+
if (truthy(trust.rekor) && files.has(`rekor/${size}.json`))
|
|
255
|
+
rep.unsupport("rekor anchor");
|
|
256
|
+
const included = async (r) => {
|
|
257
|
+
const seq = at(at(r, "event"), "seq");
|
|
258
|
+
const path = get(at(proofs, "inclusion"), str(seq));
|
|
259
|
+
return path !== null && verifyInclusion(num(seq), size, await leafHash(unhex(text(at(r, "hash")).slice(7))), list(path).map(b64decode), root);
|
|
260
|
+
};
|
|
261
|
+
// keys: declared and retired by records the checkpointed tree includes, so the log key vouches for them
|
|
262
|
+
const keyRecords = jsonl("keys/records.jsonl"), keyProblems = [], timeline = new Map();
|
|
263
|
+
const logId = keyRecords.length ? get(at(keyRecords[0], "event"), "log_id") : null;
|
|
264
|
+
const keysAt = (seq) => [...timeline.values()].filter((k) => num(k.from) <= seq
|
|
265
|
+
&& (k.until === null || seq <= num(k.until))).map((k) => k.spki);
|
|
266
|
+
/** Signature, schema and log membership of one record under `keys`, the SPKIs valid at its position. */
|
|
267
|
+
const checkRecord = async (r, keys, problems) => {
|
|
268
|
+
const err = await verifyRecord(r, keys, trust.algs);
|
|
269
|
+
if (err) {
|
|
270
|
+
const seq = err === "unknown kid" ? get(r.event, "seq") : null; // a well-formed record by then
|
|
271
|
+
const retired = [...timeline.values()].filter((k) => Number.isInteger(seq) && k.until !== null && k.until < seq);
|
|
272
|
+
if (retired.length && !await verifyRecord(r, retired.map((k) => k.spki), trust.algs))
|
|
273
|
+
problems.push(`seq ${seq}: signed by key ${cut(r.kid, 80)} after its key.retire`);
|
|
274
|
+
else
|
|
275
|
+
problems.push(`seq ${repr(get(get(r, "event", {}), "seq"))}: ${err}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const e = r.event;
|
|
279
|
+
const errs = get(e, "schema_version") === "tracekit.event.v2" ? validateEvent(e, floats) : ["not a tracekit.event.v2 event"];
|
|
280
|
+
problems.push(...errs.map((x) => `seq ${repr(get(e, "seq"))}: ${x}`));
|
|
281
|
+
if (!errs.length && !same(get(e, "log_id"), logId))
|
|
282
|
+
problems.push(`seq ${e.seq}: another log's record`);
|
|
283
|
+
};
|
|
284
|
+
let last = -1;
|
|
285
|
+
for (const r of keyRecords) {
|
|
286
|
+
const e = at(r, "event");
|
|
287
|
+
if (!KEY_TYPES.includes(at(e, "type")) || num(at(e, "seq")) <= last || !await included(r)) {
|
|
288
|
+
keyProblems.push(`seq ${str(at(e, "seq"))}: not a key record of the checkpointed tree, in order`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
last = e.seq;
|
|
292
|
+
const declared = new Map();
|
|
293
|
+
if (e.type === "signer.epoch") {
|
|
294
|
+
for (const k of list(get(at(e, "data"), "keys", []))) {
|
|
295
|
+
const der = b64decode(at(k, "spki"));
|
|
296
|
+
if (await spkiKid(der) !== at(k, "kid") || keyAlg(der) !== at(k, "alg"))
|
|
297
|
+
keyProblems.push(`seq ${e.seq}: key ${str(k.kid)} does not match its SPKI`);
|
|
298
|
+
if (!has(k, "cert") && trust.issuers.length && !truthy(get(e.data, "bridge"))) {
|
|
299
|
+
keyProblems.push(`seq ${e.seq}: key ${cut(str(k.kid), 80)} has no certificate, and the trust config pins issuers`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// lean: a certified key is taken as declared and the bundle left UNVERIFIABLE; port docs/issuer.md's checks
|
|
303
|
+
// (issuance log notes, validity, tenants, revocations) when certified record keys are in use
|
|
304
|
+
if (has(k, "cert"))
|
|
305
|
+
rep.unsupport("record-key certificates");
|
|
306
|
+
declared.set(k.kid, { spki: der, from: e.seq, until: null });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
await checkRecord(r, [...keysAt(e.seq), ...[...declared.values()].map((k) => k.spki)], keyProblems);
|
|
310
|
+
if (e.type === "key.retire") {
|
|
311
|
+
const kid = at(at(e, "data"), "kid");
|
|
312
|
+
if (!timeline.has(kid))
|
|
313
|
+
keyProblems.push(`seq ${e.seq}: retires an unknown key`);
|
|
314
|
+
else
|
|
315
|
+
timeline.get(kid).until = at(e.data, "last_seq");
|
|
316
|
+
}
|
|
317
|
+
for (const [kid, k] of declared)
|
|
318
|
+
timeline.set(kid, k);
|
|
319
|
+
}
|
|
320
|
+
// the runs: one, or with a run-set any number, or one with its tracekit why findings and replay runs
|
|
321
|
+
// lean: findings (verify/v2.py's _findings) are not checked here: a bundle with them is UNVERIFIABLE; port it when
|
|
322
|
+
// TS users verify findings bundles
|
|
323
|
+
const names = [...files.keys()].filter((n) => n.startsWith("runs/")).sort();
|
|
324
|
+
const runSet = files.has("registry/run-set.json");
|
|
325
|
+
const runs = new Map(), problems = [], chain = [], outside = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const records = jsonl(name);
|
|
328
|
+
runs.set(name, records);
|
|
329
|
+
if (!records.length)
|
|
330
|
+
throw new Error("a run has no records");
|
|
331
|
+
const head = at(records[0], "event");
|
|
332
|
+
for (const [i, r] of records.entries()) {
|
|
333
|
+
await checkRecord(r, keysAt(num(at(at(r, "event"), "seq"))), problems);
|
|
334
|
+
const e = r.event, prev = i ? records[i - 1] : null;
|
|
335
|
+
if (!same(get(e, "run_seq"), i) || !same(get(e, "run_prev_hash"), prev ? at(prev, "hash") : ZERO_HASH)
|
|
336
|
+
|| !same([get(e, "tenant"), get(e, "run_id")], [get(head, "tenant"), get(head, "run_id")])
|
|
337
|
+
|| prev && (num(at(e, "seq")) <= num(at(at(prev, "event"), "seq")) || get(prev.event, "type") === "run.final"))
|
|
338
|
+
chain.push(`run ${repr(cut(str(get(head, "run_id")), 200))} run_seq ${i} (seq ${repr(get(e, "seq"))}) does not `
|
|
339
|
+
+ "continue the run");
|
|
340
|
+
}
|
|
341
|
+
if (!(await included(records[0]) && await included(records.at(-1))))
|
|
342
|
+
outside.push(`run ${repr(cut(str(get(head, "run_id")), 200))}`);
|
|
343
|
+
}
|
|
344
|
+
const why = [...runs.values()].some((rs) => rs.some((r) => get(r.event, "type") === "why.finding")
|
|
345
|
+
|| get(rs[0].event, "type") === "run.registered" && ["analyzes", "replay_of"].some((k) => has(get(rs[0].event, "data"), k)));
|
|
346
|
+
if (why)
|
|
347
|
+
rep.unsupport("tracekit why findings");
|
|
348
|
+
else if (names.length !== 1 && !runSet)
|
|
349
|
+
throw new Error("a v2 bundle holds exactly one run, or a run-set");
|
|
350
|
+
let retired = [], provenTo = -1, regCosigs = [];
|
|
351
|
+
if (runSet) {
|
|
352
|
+
[retired, provenTo, regCosigs] = await checkRunSet(rep, json("registry/run-set.json"), jsonl, file, trust, witnesses, origin, size, runs, included, (r, p) => checkRecord(r, keysAt(num(at(at(r, "event"), "seq"))), p), floats);
|
|
353
|
+
const keyed = new Set(keyRecords.map((r) => `${at(at(r, "event"), "seq")} ${at(r, "hash")}`));
|
|
354
|
+
keyProblems.push(...retired.filter(([seq, h]) => !keyed.has(`${seq} ${h}`))
|
|
355
|
+
.map(([seq]) => `seq ${seq}: key.retire withheld (the registry log has it)`));
|
|
356
|
+
}
|
|
357
|
+
rep.check("keys", timeline.size > 0 && !keyProblems.length, `${timeline.size} record key(s) from ${keyRecords.length} key `
|
|
358
|
+
+ "record(s)", keyProblems);
|
|
359
|
+
const every = [...runs.values()].flat();
|
|
360
|
+
const seqs = [...keyRecords, ...every].map((r) => num(at(at(r, "event"), "seq")));
|
|
361
|
+
if (!seqs.length)
|
|
362
|
+
throw new Error("the bundle holds no key or run records");
|
|
363
|
+
const relied = Math.max(...seqs);
|
|
364
|
+
const rotated = timeline.size > 1 || keyRecords.some((r) => get(r.event, "type") === "key.retire");
|
|
365
|
+
if (provenTo < relied && rotated) // one key never retired: the assurance line notes it, no warning
|
|
366
|
+
rep.check("keys", false, "retirements not proven complete", [`no run-set from registry size 0 reaches seq ${relied}, so a withheld key.retire would not show`], true);
|
|
367
|
+
const bridge = keyRecords.map((r) => r.event).find((e) => e.type === "signer.epoch" && has(at(e, "data"), "bridge"));
|
|
368
|
+
if (bridge && truthy(bridge.data.bridge))
|
|
369
|
+
rep.notes.push(`this log continues the v1 ledger of key ${str(at(bridge.data.bridge, "v1_kid"))}; pass the v1 ledger and `
|
|
370
|
+
+ "its signer.pub to the reference verifier to check the format bridge");
|
|
371
|
+
rep.check("signatures", !problems.length, `${every.length} record(s), keys valid at their position`, problems);
|
|
372
|
+
const only = runs.size === 1 ? [...runs.values()][0][0].event : null;
|
|
373
|
+
rep.check("run chain", !chain.length, only ? `run ${repr(cut(str(get(only, "run_id")), 200))} of tenant `
|
|
374
|
+
+ `${repr(get(only, "tenant"))}, contiguous from run_seq 0` : `${runs.size} run(s), each contiguous from run_seq 0`, chain);
|
|
375
|
+
rep.check("inclusion", !outside.length, `first and last records of ${runs.size} run(s) are in the checkpointed tree of `
|
|
376
|
+
+ `size ${size}`, outside);
|
|
377
|
+
// policy_hash -> the bundled snapshot named by it: the one set checkIncidents and approvals check against
|
|
378
|
+
const policies = new Map();
|
|
379
|
+
for (const p of [...files.keys()].filter((n) => n.startsWith("policies/")).sort()) {
|
|
380
|
+
const hex = await sha256hex(file(p));
|
|
381
|
+
rep.check("policy snapshot", p === `policies/${hex}.json`, p);
|
|
382
|
+
if (p === `policies/${hex}.json`)
|
|
383
|
+
policies.set(`sha256:${hex}`, file(p));
|
|
384
|
+
}
|
|
385
|
+
const open = [...runs.values()].filter((rs) => get(rs.at(-1).event, "type") !== "run.final");
|
|
386
|
+
rep.integrity = !open.length ? "VERIFIED" : only ? `VERIFIED TO HEAD ${str(get(open[0].at(-1).event, "run_seq"))} (open)`
|
|
387
|
+
: `VERIFIED (${open.length} run(s) open)`;
|
|
388
|
+
const events = every.map((r) => r.event), typed = (t) => events.filter((e) => get(e, "type") === t);
|
|
389
|
+
const selfApproved = typed("approval").some((e) => get(at(e, "data"), "self_approved") === true);
|
|
390
|
+
const gaps = (k) => typed("capture.gap").filter((e) => get(at(e, "data"), "kind") === k)
|
|
391
|
+
.map((e) => `seq ${e.seq}: ${cut(str(get(e.data, "reason")), 200)}`);
|
|
392
|
+
const against = gaps("executed_against_policy");
|
|
393
|
+
if (against.length)
|
|
394
|
+
rep.check("policy", false, `${against.length} tool call(s) ran against a deny or an unapproved ask`, against.slice(0, 20), true);
|
|
395
|
+
const unresolved = gaps("held_call_unresolved"); // the signer's word that a held call ended its run without a result
|
|
396
|
+
if (unresolved.length)
|
|
397
|
+
rep.check("held calls", false, `${unresolved.length} tool call(s) a gateway or executor decided have no result`, unresolved.slice(0, 20), true);
|
|
398
|
+
// capture.gap kinds that say nothing against a run's own records, and those warned on lines of their own
|
|
399
|
+
const info = ["witness_late", "witness_failed", "degraded_unanchored", "clock_skew", "format_upgrade", "key_retired",
|
|
400
|
+
"args_coerced", "approval_expired", "executed_against_policy", "held_call_unresolved", "decision_mismatch",
|
|
401
|
+
"observer_lost"];
|
|
402
|
+
const alarms = events.filter((e) => ["approval.binding_mismatch", "trace.tamper"].includes(get(e, "type"))
|
|
403
|
+
|| get(e, "type") === "capture.gap" && !info.includes(get(at(e, "data"), "kind")));
|
|
404
|
+
if (alarms.length) {
|
|
405
|
+
const kindOf = (e) => e.type === "capture.gap" ? cut(str(get(e.data, "kind")), 64) : e.type;
|
|
406
|
+
rep.check("gaps", false, `${alarms.length} signed gap, tamper or mismatch record(s): `
|
|
407
|
+
+ count(alarms.map(kindOf)).map(([k, n]) => `${k} ${n}`).join(", "), alarms.map((e) => `seq ${e.seq}: ${kindOf(e)}: ` + (e.type === "approval.binding_mismatch"
|
|
408
|
+
? `approval ${cut(str(get(e.data, "approval_id")), 200)} of call ${cut(str(get(e.data, "tool_use_id")), 200)}`
|
|
409
|
+
+ ": the arguments differ from the approved ones"
|
|
410
|
+
: cut(str(get(e.data, "reason", get(e.data, "path"))), 200))).slice(0, 20), true);
|
|
411
|
+
}
|
|
412
|
+
const external = typed("policy.external").map((e) => at(e, "data")), mismatched = gaps("decision_mismatch");
|
|
413
|
+
if (external.length || mismatched.length) {
|
|
414
|
+
const systems = count(external.map((d) => cut(str(at(d, "system")), 64)));
|
|
415
|
+
const verified = external.filter((d) => at(d, "signature") === "verified").length;
|
|
416
|
+
rep.check("external decisions", !mismatched.length, `${external.length} imported: `
|
|
417
|
+
+ (systems.map(([k, n]) => `${k} ${n}`).join(", ") || "none") + `; signatures verified ${verified}, unverified `
|
|
418
|
+
+ `${external.length - verified}; ${mismatched.length} disagree with the signer`, mismatched.slice(0, 20), true);
|
|
419
|
+
}
|
|
420
|
+
const finals = [...runs.values()].map((rs) => rs.at(-1).event)
|
|
421
|
+
.filter((e) => get(e, "type") === "run.final" && has(at(e, "data"), "coverage")).map((e) => e.data.coverage);
|
|
422
|
+
const reconciles = events.filter((e) => str(get(e, "type")).startsWith("reconcile."));
|
|
423
|
+
const paired = [...runs.values()].map((rs) => rs.at(-1).event).filter((e) => get(e, "type") === "run.final")
|
|
424
|
+
.reduce((n, e) => n + num(get(e.data, "paired_by_args", 0)), 0);
|
|
425
|
+
if (finals.length || reconciles.length) {
|
|
426
|
+
const unreconciled = count(reconciles.map((e) => e.type.slice("reconcile.".length)));
|
|
427
|
+
const layers = [...new Set(finals.flatMap((c) => list(at(c, "layers"))))].sort();
|
|
428
|
+
rep.check("coverage", !reconciles.length, `layers ${layers.join("+") || "none"}` + (layers.includes("L3") ? "" : " (L3 absent)")
|
|
429
|
+
+ `; ${finals.reduce((n, c) => n + num(at(c, "reconciled")), 0)} call(s) reconciled`
|
|
430
|
+
+ (paired ? ` (${paired} paired by arguments: no tool-call id)` : "") + "; unreconciled: "
|
|
431
|
+
+ (unreconciled.map(([k, n]) => `${k} ${n}`).join(", ") || "none"), reconciles.map((e) => `seq ${e.seq}: ${e.type}`
|
|
432
|
+
+ (has(e, "tool_call_id") ? ` ${cut(str(e.tool_call_id), 200)}` : "")
|
|
433
|
+
+ `: ${cut(str(get(at(e, "data"), "detail")), 200)}`).slice(0, 20), true);
|
|
434
|
+
}
|
|
435
|
+
// the coverage matrix: per action class its weakest status across the runs, the layers behind it; never "complete"
|
|
436
|
+
const matrices = [...runs.values()].map((rs) => rs.at(-1).event)
|
|
437
|
+
.filter((e) => get(e, "type") === "run.final" && has(at(e, "data"), "matrix")).map((e) => e.data.matrix);
|
|
438
|
+
if (matrices.length)
|
|
439
|
+
rep.check("coverage matrix", true, (matrices.length > 1 ? `weakest of ${matrices.length} runs: ` : "")
|
|
440
|
+
+ MATRIX.map((k) => {
|
|
441
|
+
const cs = matrices.map((m) => at(m, k));
|
|
442
|
+
const status = cs.map((c) => str(at(c, "status"))).reduce((a, b) => COVERED.indexOf(b) < COVERED.indexOf(a) ? b : a);
|
|
443
|
+
const layers = [...new Set(cs.flatMap((c) => list(at(c, "layers")).map(str)))].sort()
|
|
444
|
+
.concat(cs.some((c) => get(c, "asserted") === true) ? ["asserted"] : []);
|
|
445
|
+
return `${k.replace("_", " ")} ` + (status === "none" ? "not covered"
|
|
446
|
+
: status + (layers.length ? ` (${layers.join(", ")})` : ""));
|
|
447
|
+
}).join(", "));
|
|
448
|
+
const lost = gaps("observer_lost");
|
|
449
|
+
if (lost.length)
|
|
450
|
+
rep.check("observers", false, `${lost.length} egress observer(s) went silent while their run was open`, lost.slice(0, 20), true);
|
|
451
|
+
checkIncidents(rep, runs, policies, floats);
|
|
452
|
+
const accessed = typed("content.accessed").length, erased = typed("content.erased").length;
|
|
453
|
+
if (accessed || erased) // signer records of the content vault (docs/privacy.md): who read or erased retained content
|
|
454
|
+
rep.check("content vault", true, `${accessed} access(es), ${erased} erasure(s) of retained content, each signed by the signer`);
|
|
455
|
+
// a tool call's tier is the weakest its records claim (untiered < T3, receipt only, < T1 < T2): each tier a record
|
|
456
|
+
// names, and untiered for a decision or result that names none, so a call decided at T2 and completed elsewhere
|
|
457
|
+
// isn't T2;
|
|
458
|
+
// paths: the (source, tier)s of its decisions and results, which differ only when another layer answered for it
|
|
459
|
+
const order = [null, "T3", "T1", "T2"];
|
|
460
|
+
const tiers = new Map(), claims = new Map(), paths = new Map();
|
|
461
|
+
for (const e of events) {
|
|
462
|
+
if (get(e, "tool_call_id") === null)
|
|
463
|
+
continue;
|
|
464
|
+
const k = JSON.stringify([get(e, "run_id"), e.tool_call_id]);
|
|
465
|
+
const decided = ["policy.decision", "tool.result"].includes(get(e, "type"));
|
|
466
|
+
const tier = order.includes(get(e, "tier")) ? get(e, "tier") : null;
|
|
467
|
+
tiers.set(k, null);
|
|
468
|
+
if (tier !== null || decided) {
|
|
469
|
+
const had = claims.has(k) ? claims.get(k) : tier;
|
|
470
|
+
claims.set(k, order.indexOf(had) <= order.indexOf(tier) ? had : tier);
|
|
471
|
+
}
|
|
472
|
+
if (decided)
|
|
473
|
+
paths.set(k, (paths.get(k) ?? new Set()).add(`${cut(str(get(e, "source")), 64)} ${tier ?? "untiered"}`));
|
|
474
|
+
}
|
|
475
|
+
for (const [k, t] of claims)
|
|
476
|
+
tiers.set(k, t);
|
|
477
|
+
const split = [...paths].filter(([, p]) => p.size > 1).map(([k, p]) => {
|
|
478
|
+
const [run, tcid] = JSON.parse(k);
|
|
479
|
+
return `${cut(str(run), 200)} ${cut(str(tcid), 200)}: ${[...p].sort().join(", ")}`;
|
|
480
|
+
});
|
|
481
|
+
if (tiers.size) {
|
|
482
|
+
const n = (t) => [...tiers.values()].filter((x) => x === t).length;
|
|
483
|
+
rep.check("tiers", !split.length, `${tiers.size} tool call(s): ` + ["T1", "T2", "T3"].map((t) => `${t} ${n(t)}`).join(", ")
|
|
484
|
+
+ (n(null) ? `, untiered ${n(null)}` : "") + (split.length ? `; ${split.length} decided and completed by different capture paths` : ""), split.slice(0, 20), true);
|
|
485
|
+
}
|
|
486
|
+
const sources = events.filter((e) => has(e, "args_source")).map((e) => e.args_source);
|
|
487
|
+
if (sources.length)
|
|
488
|
+
rep.check("args source", true, `${sources.length} record(s): `
|
|
489
|
+
+ ["raw", "parsed", "coerced"].map((s) => `${s} ${sources.filter((x) => x === s).length}`).join(", "));
|
|
490
|
+
const firsts = [...runs.values()].map((rs) => rs[0].event);
|
|
491
|
+
const registered = firsts.map((e) => get(e, "type") === "run.registered" ? at(e, "data") : {});
|
|
492
|
+
rep.check("isolation", true, "signer-reported: " + count(registered.map((d) => cut(str(get(d, "signer_isolation", "unreported")), 64))).map(([k, n]) => `${k} ${n} run(s)`).join(", "));
|
|
493
|
+
const principals = firsts.filter((e) => get(e, "type") === "run.registered" && has(e, "principal"))
|
|
494
|
+
.map((e) => `${cut(str(e.principal), 256)} (${get(e, "principal_attested") === true ? "attested" : "app-asserted"})`);
|
|
495
|
+
if (principals.length)
|
|
496
|
+
rep.check("principals", true, principals.slice(0, 20).join(", ") + (principals.length > 20 ? ", ..." : ""));
|
|
497
|
+
if (registered.some((d) => has(d, "harness"))) {
|
|
498
|
+
const bound = firsts.map((e, i) => `${cut(str(get(e, "run_id")), 200)} `
|
|
499
|
+
+ (isObj(get(registered[i], "harness")) ? cut(str(get(registered[i].harness, "name")), 64) : "none"));
|
|
500
|
+
rep.check("harness", true, "signer-attested: " + bound.slice(0, 20).join(", ") + (bound.length > 20 ? ", ..." : ""));
|
|
501
|
+
}
|
|
502
|
+
const failOpen = [...new Set(registered.flatMap((d) => {
|
|
503
|
+
const modes = get(d, "fail_modes");
|
|
504
|
+
if (!truthy(modes))
|
|
505
|
+
return [];
|
|
506
|
+
if (!isObj(modes))
|
|
507
|
+
throw new TypeError("fail_modes is not an object");
|
|
508
|
+
return Object.entries(modes).filter(([, m]) => m === "open").map(([c]) => cut(c, 64));
|
|
509
|
+
}))].sort();
|
|
510
|
+
rep.check("fail-open classes", !failOpen.length, failOpen.join(", ") || "none", failOpen.length ? ["calls the agent classed as one of these may have run unrecorded while the signer was down"] : [], true);
|
|
511
|
+
rep.check("key assurance", true, "asserted: the log declares its record keys; none is attested");
|
|
512
|
+
const algs = new Set([...every, ...keyRecords].map((r) => text(at(r, "alg"))));
|
|
513
|
+
rep.assurance = await assurance(origin, cosigs, witnesses, trust, algs, selfApproved, regCosigs)
|
|
514
|
+
+ (provenTo < relied ? "; key retirements not proven complete" : "");
|
|
515
|
+
approvals(rep, runs, policies, floats);
|
|
516
|
+
const glass = typed("approval").filter((e) => get(at(e, "data"), "break_glass") === true);
|
|
517
|
+
if (glass.length)
|
|
518
|
+
rep.check("break-glass approvals", false, `${glass.length} approval(s) answered under the break-glass role`, glass.map((e) => `seq ${e.seq}: ${str(at(e.data, "decision"))} ${str(get(e.data, "approval_id"))} by `
|
|
519
|
+
+ `${cut(text(at(e.data, "approver")), 256)}: ${repr(cut(str(get(e.data, "reason")), 200))}`).slice(0, 20), true);
|
|
520
|
+
}
|
|
521
|
+
/** Approvals by people and by agents, each on a line of its own. An agent's approval fails the line unless its run
|
|
522
|
+
* holds the approval.request and policy.decision it answered, the signer called the request agent-eligible
|
|
523
|
+
* (agent.eligible) and no approval.escalated or approval.revoked of it comes first, the decision has no provenance and
|
|
524
|
+
* a complete provenance_state (or none: the signer leaves it out then), its approval needs no passkey, and the agent is
|
|
525
|
+
* neither the requester nor the run's owner; with the policy snapshot of the decision's policy_hash in `policies` (the
|
|
526
|
+
* bundle's, each named by its SHA-256), every rule the call was held by is an ask rule that permits agents and needs no
|
|
527
|
+
* passkey (else a warning: bounds unchecked). Argument bounds are never checkable offline (salted commitments). */
|
|
528
|
+
function approvals(rep, runs, policies, floats) {
|
|
529
|
+
let people = 0, agents = 0;
|
|
530
|
+
const problems = [], unchecked = [];
|
|
531
|
+
for (const rs of runs.values()) {
|
|
532
|
+
const events = rs.map((r) => r.event);
|
|
533
|
+
const ident = get(events[0], "type") === "run.registered" ? get(at(events[0], "data"), "identity") ?? {} : {};
|
|
534
|
+
const owner = `${str(get(ident, "scheme"))}:${cut(str(get(ident, "subject")), 256)}`;
|
|
535
|
+
const asked = new Map(), decided = new Map();
|
|
536
|
+
for (const e of events) {
|
|
537
|
+
if (e.type === "approval.request")
|
|
538
|
+
asked.set(get(e.data, "approval_id"), e.data);
|
|
539
|
+
if (e.type === "policy.decision" && has(e.data, "decision_id"))
|
|
540
|
+
decided.set(e.data.decision_id, e.data);
|
|
541
|
+
}
|
|
542
|
+
const toPeople = new Set(); // approval ids escalated or revoked so far: only people answer them
|
|
543
|
+
for (const e of events) {
|
|
544
|
+
const d = e.data;
|
|
545
|
+
if (e.type === "approval.escalated" || e.type === "approval.revoked")
|
|
546
|
+
toPeople.add(get(d, "approval_id"));
|
|
547
|
+
if (e.type !== "approval" || get(d, "decision") !== "approve")
|
|
548
|
+
continue;
|
|
549
|
+
if (get(d, "approver_kind") !== "agent") {
|
|
550
|
+
people++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
agents++;
|
|
554
|
+
const where = `seq ${e.seq}: ${cut(str(get(d, "approval_id")), 128)}`;
|
|
555
|
+
const req = asked.get(get(d, "approval_id")) ?? {};
|
|
556
|
+
const dec = decided.get(get(req, "decision_id"));
|
|
557
|
+
if (dec === undefined) {
|
|
558
|
+
problems.push(`${where}: its approval.request and policy.decision are not in the run`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const ai = get(d, "approver_identity") ?? {};
|
|
562
|
+
if (get(get(req, "agent") ?? {}, "eligible") !== true)
|
|
563
|
+
problems.push(`${where}: the signer did not route its approval.request to agents (agent.eligible)`);
|
|
564
|
+
if (toPeople.has(get(d, "approval_id")))
|
|
565
|
+
problems.push(`${where}: it was escalated or revoked before the agent approved it`);
|
|
566
|
+
if (truthy(get(dec, "provenance")) || get(dec, "provenance_state", "complete") !== "complete")
|
|
567
|
+
problems.push(`${where}: the decision's arguments carry provenance or it is incomplete`);
|
|
568
|
+
if (get(req, "passkey") === true)
|
|
569
|
+
problems.push(`${where}: a rule of the call needs a passkey`);
|
|
570
|
+
if (get(d, "approver") === get(req, "requester") || `${str(get(ai, "scheme"))}:${cut(str(get(ai, "subject")), 256)}` === owner)
|
|
571
|
+
problems.push(`${where}: the agent is the requester or the run's owner`);
|
|
572
|
+
const hash = str(get(dec, "policy_hash")), snap = policies.get(hash);
|
|
573
|
+
if (snap === undefined) {
|
|
574
|
+
unchecked.push(`${where}: no policy snapshot of ${cut(hash, 80)}; export the run with the signer's config, which `
|
|
575
|
+
+ "bundles the policies its records cite");
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
let asks, flags;
|
|
579
|
+
try {
|
|
580
|
+
const pol = strictParseBytes(snap, floats);
|
|
581
|
+
asks = new Map(list(get(pol, "ask", [])).map((r) => [at(r, "id"), r]));
|
|
582
|
+
flags = new Set(list(get(pol, "flag", [])).map((r) => at(r, "id")));
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
problems.push(`${where}: the policy snapshot is not a policy`);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
for (const rid of get(dec, "rule_ids") ?? []) {
|
|
589
|
+
const approval = get(asks.get(rid) ?? {}, "approval") ?? {};
|
|
590
|
+
if (!flags.has(rid) && (!isObj(approval) || !has(approval, "agent") || approval.passkey === "required"))
|
|
591
|
+
problems.push(`${where}: rule ${cut(str(rid), 64)} does not permit agents`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (!agents)
|
|
596
|
+
return; // a bundle without agent approvals keeps the report it always had
|
|
597
|
+
rep.check("approvals by people", true, `${people} approval(s) answered by people`);
|
|
598
|
+
rep.check("approvals by agents", !problems.length && !unchecked.length, `${agents} approval(s) answered by agents (model `
|
|
599
|
+
+ "output); argument bounds are not checkable offline (salted commitments): audit one with `tracekit signer reveal`"
|
|
600
|
+
+ (unchecked.length ? `; agent approval bounds unchecked for ${unchecked.length}` : ""), (problems.length ? problems : unchecked).slice(0, 20), !problems.length);
|
|
601
|
+
}
|
|
602
|
+
const CONTAINMENT = ["incident.opened", "incident.updated", "run.held", "run.quarantined", "run.resumed"];
|
|
603
|
+
const FIRES = ["policy.decision", "approval.consumed", "capture.gap", "trace.tamper", "refusal.summary",
|
|
604
|
+
"reconcile.unexplained_effect"];
|
|
605
|
+
const RECOMPUTED = ["sequence", "limit", "denied_calls", "provenance", "executed_against_policy", "class_mismatch"];
|
|
606
|
+
/** The `incidents` line, as verify/v2.py's _incidents: cited records, incidents recomputed from their run under the
|
|
607
|
+
* bundled compiled policy (`policies`, by policy_hash), responses honoured. */
|
|
608
|
+
function checkIncidents(rep, runs, policies, floats) {
|
|
609
|
+
const byRun = new Map([...runs.values()].map((rs) => [get(rs[0].event, "run_id"), rs.map((r) => r.event)]));
|
|
610
|
+
if (![...byRun.values()].some((es) => es.some((e) => CONTAINMENT.includes(get(e, "type")))))
|
|
611
|
+
return;
|
|
612
|
+
const problems = [], pointers = new Set(), opened = new Set(), unbundled = new Set();
|
|
613
|
+
let checked = 0;
|
|
614
|
+
for (const es of byRun.values())
|
|
615
|
+
for (const e of es)
|
|
616
|
+
if (e.type === "incident.opened")
|
|
617
|
+
opened.add(e.data.incident_id);
|
|
618
|
+
for (const [rid, events] of byRun) {
|
|
619
|
+
const mine = [];
|
|
620
|
+
for (const e of events) {
|
|
621
|
+
const d = e.data;
|
|
622
|
+
if (!["incident.opened", "incident.updated"].includes(e.type))
|
|
623
|
+
continue;
|
|
624
|
+
for (const c of list(d.cites)) {
|
|
625
|
+
const cited = byRun.get(c.run_id);
|
|
626
|
+
if (cited && (c.run_seq >= cited.length || !FIRES.includes(cited[c.run_seq].type)))
|
|
627
|
+
problems.push(`seq ${e.seq}: incident ${d.incident_id} cites ${cut(c.run_id, 128)} run_seq ${c.run_seq}, which is `
|
|
628
|
+
+ "not a record that fires a trigger");
|
|
629
|
+
}
|
|
630
|
+
if (e.type === "incident.updated")
|
|
631
|
+
continue;
|
|
632
|
+
if (d.trigger === "detector_unavailable") {
|
|
633
|
+
const c = d.cites.length === 1 ? d.cites[0] : {}, cited = byRun.get(c.run_id) ?? [];
|
|
634
|
+
const ok = (c.run_seq ?? cited.length) < cited.length && cited[c.run_seq].type === "policy.decision"
|
|
635
|
+
&& cited[c.run_seq].data.detect?.state === "unavailable";
|
|
636
|
+
if (ok)
|
|
637
|
+
checked++;
|
|
638
|
+
else
|
|
639
|
+
problems.push(`seq ${e.seq}: incident ${d.incident_id} (detector_unavailable) cites no decision whose detection `
|
|
640
|
+
+ "state was unavailable");
|
|
641
|
+
}
|
|
642
|
+
else if (d.scope.kind === "run" && RECOMPUTED.includes(d.trigger) && d.cites.every((c) => c.run_id === rid))
|
|
643
|
+
mine.push(e);
|
|
644
|
+
}
|
|
645
|
+
const hashes = new Set(events.filter((e) => e.type === "policy.decision").map((e) => get(e.data, "policy_hash")));
|
|
646
|
+
let det = null;
|
|
647
|
+
if (mine.length && hashes.size === 1) {
|
|
648
|
+
const h = str([...hashes][0]), data = policies.get(h);
|
|
649
|
+
if (!data)
|
|
650
|
+
unbundled.add(cut(h, 80));
|
|
651
|
+
else
|
|
652
|
+
det = new Detector(strictParseBytes(data, floats));
|
|
653
|
+
if (det) {
|
|
654
|
+
const st = Detector.state(), expected = [], key = (d) => JSON.stringify([d.trigger, d.rule_ids,
|
|
655
|
+
d.cites.map((c) => [c.run_id, c.run_seq]), d.mode]);
|
|
656
|
+
for (const e of events) {
|
|
657
|
+
if (mine.includes(e)) {
|
|
658
|
+
const i = expected.findIndex((x) => key(x) === key(e.data));
|
|
659
|
+
if (i >= 0) {
|
|
660
|
+
const [hit] = expected.splice(i, 1);
|
|
661
|
+
checked++;
|
|
662
|
+
if (hit.mode === "enforce" && hit.would.length && !list(e.data.actions).length && !has(e.data, "reason"))
|
|
663
|
+
problems.push(`seq ${e.seq}: incident ${e.data.incident_id} (${e.data.trigger}) is enforced under policy `
|
|
664
|
+
+ `${cut(h, 80)} but records no response (${hit.would.join(", ")}) and no reason`);
|
|
665
|
+
}
|
|
666
|
+
else
|
|
667
|
+
problems.push(`seq ${e.seq}: incident ${e.data.incident_id} (${e.data.trigger}) does not follow from the `
|
|
668
|
+
+ `run's records under policy ${cut(h, 80)}`);
|
|
669
|
+
}
|
|
670
|
+
expected.push(...det.observe(st, e).filter(([t]) => t === "incident.opened").map(([, d]) => d));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
problems.push(...honoured(events, pointers));
|
|
675
|
+
}
|
|
676
|
+
const n = opened.size + [...pointers].filter((p) => !opened.has(p)).length;
|
|
677
|
+
rep.check("incidents", !problems.length, `${n} incident(s): ${checked} checked against the bundle, ${n - checked} `
|
|
678
|
+
+ "signer-asserted (operator, cross-run, under several policies, or under a policy not in the bundle)", problems.slice(0, 20));
|
|
679
|
+
if (unbundled.size)
|
|
680
|
+
rep.check("incident policies", false, `${unbundled.size} cited policy(ies) not in the bundle: their incidents are `
|
|
681
|
+
+ "signer-asserted, not recomputed (export the run with the signer's config, which bundles the policies its "
|
|
682
|
+
+ "records cite)", [...unbundled].sort().slice(0, 20), true);
|
|
683
|
+
}
|
|
684
|
+
/** The problems of one run's records with the holds and quarantines they record (verify/v2.py's _honoured). */
|
|
685
|
+
function honoured(events, pointers) {
|
|
686
|
+
const problems = [], flight = new Set(), asked = new Map(), consumed = new Set();
|
|
687
|
+
const due = new Map();
|
|
688
|
+
let held = [], quarantined = null;
|
|
689
|
+
const state = () => quarantined ? "quarantined" : "held";
|
|
690
|
+
const unfollowed = () => [...due].map(([i, [seq, a]]) => `seq ${seq}: incident ${i} enforces ${a} on this run, which `
|
|
691
|
+
+ "no run.held or run.quarantined follows");
|
|
692
|
+
for (const e of events) {
|
|
693
|
+
const t = e.type, d = e.data, where = `seq ${e.seq}`, on = quarantined || held.join(" and ");
|
|
694
|
+
if (t === "policy.decision" && due.size && !quarantined) { // an enforced response is written before the next call
|
|
695
|
+
problems.push(...unfollowed());
|
|
696
|
+
due.clear();
|
|
697
|
+
}
|
|
698
|
+
if (t === "incident.opened" && d.scope.kind === "run" && d.scope.id === str(e.run_id).slice(0, 256)
|
|
699
|
+
&& Object.keys(d.scope).length === 2 && d.actions.some((a) => a === "hold" || a === "quarantine"))
|
|
700
|
+
due.set(d.incident_id, [e.seq, d.actions.includes("quarantine") ? "quarantine" : "hold"]);
|
|
701
|
+
else if (t === "run.held" || t === "run.quarantined") {
|
|
702
|
+
pointers.add(d.incident_id);
|
|
703
|
+
due.delete(d.incident_id);
|
|
704
|
+
for (const x of d.in_flight ?? [])
|
|
705
|
+
flight.add(x.decision_id);
|
|
706
|
+
if (t === "run.quarantined")
|
|
707
|
+
[quarantined, held] = [d.incident_id, []];
|
|
708
|
+
else if (!quarantined && !held.includes(d.incident_id))
|
|
709
|
+
held.push(d.incident_id);
|
|
710
|
+
}
|
|
711
|
+
else if (t === "run.resumed") {
|
|
712
|
+
held = held.filter((i) => i !== d.incident_id);
|
|
713
|
+
if (JSON.stringify([...(d.held_by ?? held)].sort()) !== JSON.stringify([...held].sort()))
|
|
714
|
+
problems.push(`${where}: run.resumed says ${d.held_by.join(" and ") || "no incident"} still holds the run, its `
|
|
715
|
+
+ `records ${held.join(" and ") || "none"}`);
|
|
716
|
+
}
|
|
717
|
+
else if (t === "policy.decision" && on && (quarantined ? d.decision !== "deny"
|
|
718
|
+
: d.decision !== "deny" && (d.decision !== "ask" || !held.includes(d.incident_id ?? null))))
|
|
719
|
+
problems.push(`${where}: ${d.decision} while the run is ${state()} by incident ${on}`);
|
|
720
|
+
else if (t === "approval.request") {
|
|
721
|
+
asked.set(d.approval_id, d.incident_id ?? null);
|
|
722
|
+
if (quarantined || held.length && !held.includes(d.incident_id ?? null))
|
|
723
|
+
problems.push(`${where}: an approval asked while the run is ${state()} by incident ${on}, not as its held call`);
|
|
724
|
+
}
|
|
725
|
+
else if (t === "approval" && asked.get(d.approval_id ?? null) && d.decision === "approve" && (d.approver_kind === "agent" || d.self_approved || !Object.hasOwn(d, "person")
|
|
726
|
+
&& !Object.hasOwn(d.approver_identity ?? {}, "person") || (d.incident_id ?? null) !== asked.get(d.approval_id)))
|
|
727
|
+
problems.push(`${where}: a held call approved by other than a person, or without its incident id`);
|
|
728
|
+
else if (t === "approval.consumed") {
|
|
729
|
+
if (quarantined || held.length && !held.includes(asked.get(d.approval_id) ?? null))
|
|
730
|
+
problems.push(`${where}: an approval consumed while the run is ${state()} by incident ${on}`);
|
|
731
|
+
else if (asked.get(d.approval_id))
|
|
732
|
+
consumed.add(e.tool_call_id ?? null);
|
|
733
|
+
}
|
|
734
|
+
else if (t === "tool.result" && on && !flight.has(d.decision_id) && (quarantined || !consumed.has(e.tool_call_id ?? null)))
|
|
735
|
+
problems.push(`${where}: a result while the run is ${state()} by incident ${on}, of a call neither in flight then nor `
|
|
736
|
+
+ "approved by a person for it");
|
|
737
|
+
}
|
|
738
|
+
if (!quarantined)
|
|
739
|
+
problems.push(...unfollowed());
|
|
740
|
+
return problems;
|
|
741
|
+
}
|
|
742
|
+
/** §4: null when `r` is a v2 record signed by one of `keys` (SPKI DER) with an allowed algorithm that is the key's own,
|
|
743
|
+
* else why not. */
|
|
744
|
+
export async function verifyRecord(r, keys, algs) {
|
|
745
|
+
if (!isObj(r) || Object.keys(r).length !== 6 || !["v", "event", "hash", "alg", "kid", "sig"].every((k) => Object.hasOwn(r, k))
|
|
746
|
+
|| !same(r.v, 2) || !isObj(r.event) || !["hash", "alg", "kid", "sig"].every((k) => typeof r[k] === "string"))
|
|
747
|
+
return "not a v2 record";
|
|
748
|
+
let h;
|
|
749
|
+
try {
|
|
750
|
+
h = "sha256:" + await sha256hex(bytes(canonicalize(r.event)));
|
|
751
|
+
}
|
|
752
|
+
catch (e) {
|
|
753
|
+
return `event is not canonical JSON: ${e.message}`;
|
|
754
|
+
}
|
|
755
|
+
if (r.hash !== h)
|
|
756
|
+
return "hash does not match the event";
|
|
757
|
+
if (!algs.includes(r.alg))
|
|
758
|
+
return `algorithm ${repr(r.alg)} is not allowed`;
|
|
759
|
+
let key;
|
|
760
|
+
for (const k of keys)
|
|
761
|
+
if (await spkiKid(k) === r.kid)
|
|
762
|
+
key = k;
|
|
763
|
+
if (!key)
|
|
764
|
+
return "unknown kid";
|
|
765
|
+
if (keyAlg(key) !== r.alg)
|
|
766
|
+
return `algorithm ${repr(r.alg)} is not the key's algorithm`;
|
|
767
|
+
let sig;
|
|
768
|
+
try {
|
|
769
|
+
sig = b64decode(r.sig);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return "sig is not base64";
|
|
773
|
+
}
|
|
774
|
+
const position = Object.fromEntries(["log_id", "seq", "prev_hash", "tenant", "run_id", "run_seq", "run_prev_hash"]
|
|
775
|
+
.map((k) => [k, Object.hasOwn(r.event, k) ? r.event[k] : null]));
|
|
776
|
+
const message = canonicalize({ ...position, alg: r.alg, kid: r.kid, hash: h, t: "tracekit.record.v2" });
|
|
777
|
+
return await verifyV2(r.alg, key, bytes(message), sig) ? null : "bad signature";
|
|
778
|
+
}
|
|
779
|
+
/** §7: the run-set line (COMPLETE or INCOMPLETE) and the log tail line. Returns the (seq, hash) of every key.retire the
|
|
780
|
+
* range points to, the highest seq it points to when it starts at registry size 0 (else -1), and the pinned
|
|
781
|
+
* cosignatures of each registry note. */
|
|
782
|
+
async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size, runs, included, checkRecord, floats) {
|
|
783
|
+
const problems = [], tsalt = b64decode(at(rs, "tenant_salt"));
|
|
784
|
+
const lo = at(rs, "from"), hi = at(rs, "to"), regOrigin = await registryOrigin(origin, tsalt);
|
|
785
|
+
const span = `registry ${str(lo)}..${str(hi)}`;
|
|
786
|
+
if (![lo, hi].every((n) => Number.isSafeInteger(n)) || floats.get(rs)?.has("from") || floats.get(rs)?.has("to")) {
|
|
787
|
+
rep.check("run-set", false, `INCOMPLETE, ${span}`, ["the registry range is not two integers"]);
|
|
788
|
+
return [[], -1, []];
|
|
789
|
+
}
|
|
790
|
+
// the registry notes are signed by the record log's own pinned key, under the registry origin
|
|
791
|
+
const vkeys = [];
|
|
792
|
+
for (const k of trust.logs) {
|
|
793
|
+
const p = await parseVkey(k);
|
|
794
|
+
if (p.name === origin)
|
|
795
|
+
vkeys.push(await vkey(regOrigin, p.type, p.pub));
|
|
796
|
+
}
|
|
797
|
+
const roots = new Map(), regCosigs = [];
|
|
798
|
+
for (const n of [...new Set([num(lo), num(hi)])].filter((n) => n !== 0).sort((a, b) => a - b)) {
|
|
799
|
+
let got;
|
|
800
|
+
try {
|
|
801
|
+
const note = await openNote(file(`checkpoints/registry-${n}.note`), vkeys, [...witnesses.keys()]);
|
|
802
|
+
got = note.size;
|
|
803
|
+
roots.set(n, note.root);
|
|
804
|
+
regCosigs.push(note.cosigs);
|
|
805
|
+
if (note.unchecked)
|
|
806
|
+
rep.unsupport("SLH-DSA-SHA2-128s checkpoint signature");
|
|
807
|
+
if (note.cosigs.length < trust.witnesses_required)
|
|
808
|
+
problems.push(`registry checkpoint at size ${n}: ${note.cosigs.length} pinned cosignature(s), `
|
|
809
|
+
+ `${trust.witnesses_required} required`);
|
|
810
|
+
}
|
|
811
|
+
catch (e) {
|
|
812
|
+
got = `unusable (${errorText(e)})`;
|
|
813
|
+
}
|
|
814
|
+
if (got !== n)
|
|
815
|
+
problems.push(`registry checkpoint at size ${n}: ${got}`);
|
|
816
|
+
}
|
|
817
|
+
if (problems.length || !(0 <= lo && lo <= hi) || hi === 0) {
|
|
818
|
+
rep.check("run-set", false, `INCOMPLETE, ${span}`, problems.length ? problems : [`bad registry range ${lo}..${hi}`]);
|
|
819
|
+
return [[], -1, regCosigs];
|
|
820
|
+
}
|
|
821
|
+
if (0 < lo && lo < hi && !await verifyConsistency(lo, hi, roots.get(lo), roots.get(hi), list(at(rs, "consistency")).map(b64decode)))
|
|
822
|
+
problems.push(`registry checkpoint ${lo} is not a prefix of ${hi}`);
|
|
823
|
+
const leaves = list(at(rs, "leaves"));
|
|
824
|
+
const pointed = new Map(jsonl("registry/records.jsonl").map((r) => [at(at(r, "event"), "seq"), r]));
|
|
825
|
+
if (leaves.length !== hi - lo)
|
|
826
|
+
problems.push(`${hi - lo} leaves in ${lo}..${hi}, ${leaves.length} in the bundle: a leaf is missing`);
|
|
827
|
+
const registered = new Map(), finals = new Map(), retired = [], targets = new Set();
|
|
828
|
+
const gaps = new Map();
|
|
829
|
+
let closed = null, tenant = null, top = -1;
|
|
830
|
+
for (const [j, x] of leaves.slice(0, hi - lo).entries()) {
|
|
831
|
+
const i = lo + j, leaf = b64decode(at(x, "leaf"));
|
|
832
|
+
if (!await verifyInclusion(i, hi, await leafHash(leaf), list(at(x, "inclusion")).map(b64decode), roots.get(hi))) {
|
|
833
|
+
problems.push(`leaf ${i} is not in the registry tree of size ${hi}`);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const { type, runHash: rh, logId, seq, hash } = parseLeaf(leaf);
|
|
837
|
+
const r = pointed.get(seq) ?? null, e = r && truthy(r) ? at(r, "event") : r;
|
|
838
|
+
if (r === null || at(r, "hash") !== hash || at(e, "type") !== type || at(e, "log_id") !== logId || !await included(r)
|
|
839
|
+
|| !equal(await runHash(tsalt, text(at(e, "run_id"))), rh)
|
|
840
|
+
|| !SIGNER_LEAVES.includes(type) && tenant !== null && !same(tenant, at(e, "tenant"))) {
|
|
841
|
+
problems.push(`leaf ${i} points to a missing or different record (seq ${seq})`);
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
await checkRecord(r, problems);
|
|
845
|
+
top = Math.max(top, seq);
|
|
846
|
+
if (type === "run.registered" || type === "run.final")
|
|
847
|
+
targets.add(await runName(at(e, "tenant"), e.run_id));
|
|
848
|
+
if (type === "run.registered") {
|
|
849
|
+
tenant = e.tenant;
|
|
850
|
+
registered.set(e.run_id, r);
|
|
851
|
+
}
|
|
852
|
+
else if (type === "run.final") {
|
|
853
|
+
tenant = e.tenant;
|
|
854
|
+
if (finals.has(e.run_id))
|
|
855
|
+
problems.push(`a second run.final for run ${repr(cut(e.run_id, 200))} (seq ${seq})`);
|
|
856
|
+
finals.set(e.run_id, r);
|
|
857
|
+
}
|
|
858
|
+
else if (type === "key.retire")
|
|
859
|
+
retired.push([seq, hash]);
|
|
860
|
+
else if (type === "log.closed")
|
|
861
|
+
closed = e;
|
|
862
|
+
else {
|
|
863
|
+
const k = at(at(e, "data"), "kind");
|
|
864
|
+
gaps.set(k, (gaps.get(k) ?? 0) + 1);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
for (const [runId, final] of finals) {
|
|
868
|
+
const recs = runs.get(await runName(tenant, runId));
|
|
869
|
+
if (!recs?.length || at(recs.at(-1), "hash") !== at(final, "hash")
|
|
870
|
+
|| registered.has(runId) && at(recs[0], "hash") !== at(registered.get(runId), "hash"))
|
|
871
|
+
problems.push(`run ${repr(cut(runId, 200))} is final in the range but its records are not in the bundle`);
|
|
872
|
+
}
|
|
873
|
+
if ([...runs.values()].some((rs) => !same(get(at(rs[0], "event"), "tenant"), tenant)))
|
|
874
|
+
problems.push("a run in the bundle is not of the run-set's tenant");
|
|
875
|
+
const untargeted = [...runs.keys()].filter((n) => !targets.has(n)).length;
|
|
876
|
+
if (untargeted > 1)
|
|
877
|
+
problems.push(`${untargeted} runs in the bundle, but only the selected run may be in no leaf of the range`);
|
|
878
|
+
if (truthy(closed) && size > num(at(at(closed, "data"), "final_seq")) + 1)
|
|
879
|
+
problems.push(`records after log.closed at seq ${str(at(closed, "seq"))}`);
|
|
880
|
+
const counted = [...gaps].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
881
|
+
const named = tenant === null ? "" : ` of tenant ${repr(cut(tenant, 64))}`;
|
|
882
|
+
const open = [...registered.keys()].filter((k) => !finals.has(k)).length;
|
|
883
|
+
rep.check("run-set", !problems.length, !problems.length ? `COMPLETE, ${span}${named} (${registered.size} runs registered, `
|
|
884
|
+
+ `${finals.size} final, ${open} open` + (gaps.size ? `; tenant-level gaps: ${counted}` : "") + ")" : `INCOMPLETE, ${span}`, problems);
|
|
885
|
+
if (gaps.size)
|
|
886
|
+
rep.check("tenant-level gaps", false, `${[...gaps.values()].reduce((a, b) => a + b, 0)} in the range: ${counted}`, [], true);
|
|
887
|
+
rep.check("log tail", truthy(closed), truthy(closed) ? `none: log.closed at seq ${str(closed.seq)} is the last record`
|
|
888
|
+
: `records after tree size ${size} are unproven (no log.closed in the range)`, [], true);
|
|
889
|
+
return [retired, lo === 0 ? top : -1, regCosigs];
|
|
890
|
+
}
|
|
891
|
+
const runName = async (tenant, runId) => `runs/${(await sha256hex(bytes(`${str(tenant)}/${str(runId)}`))).slice(0, 32)}.jsonl`;
|
|
892
|
+
const LEVELS = ["dev", "local", "witnessed"];
|
|
893
|
+
function level(cosigs, witnesses, trust) {
|
|
894
|
+
const independent = cosigs.filter(([k]) => witnesses.get(k) !== "operator").length;
|
|
895
|
+
return independent >= Math.max(1, trust.witnesses_required) ? "witnessed" : cosigs.length ? "local" : "dev";
|
|
896
|
+
}
|
|
897
|
+
/** dev: no pinned witness cosigned, or a self-approval in the run; local: only operator-run ones; witnessed: enough
|
|
898
|
+
* independent ones. A run-set's registry notes cap the level by the same rule. */
|
|
899
|
+
async function assurance(origin, cosigs, witnesses, trust, algs, selfApproved, registryNotes) {
|
|
900
|
+
let hybrid = false;
|
|
901
|
+
for (const k of trust.logs) {
|
|
902
|
+
const p = await parseVkey(k);
|
|
903
|
+
hybrid ||= p.name === origin && p.type === HYBRID;
|
|
904
|
+
}
|
|
905
|
+
const times = cosigs.filter(([k]) => witnesses.get(k) !== "operator").map(([, ts]) => ts);
|
|
906
|
+
let lvl = selfApproved ? "dev" : level(cosigs, witnesses, trust);
|
|
907
|
+
let capped = registryNotes.map((c) => level(c, witnesses, trust))
|
|
908
|
+
.reduce((a, b) => LEVELS.indexOf(b) < LEVELS.indexOf(a) ? b : a, "witnessed");
|
|
909
|
+
if (LEVELS.indexOf(capped) < LEVELS.indexOf(lvl))
|
|
910
|
+
lvl = capped;
|
|
911
|
+
else
|
|
912
|
+
capped = null;
|
|
913
|
+
const cosigned = [...cosigs].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)
|
|
914
|
+
.map(([k, ts]) => `${k.split("+")[0]} (${witnesses.get(k)}) at ${fmt(ts)}`).join(", ");
|
|
915
|
+
return `${lvl}; records ${[...algs].sort().join("+")}; checkpoint ${hybrid ? "Ed25519 + SLH-DSA-SHA2-128s" : "Ed25519 only"} `
|
|
916
|
+
+ `(${origin})` + (cosigned ? `; cosigned ed25519 by ${cosigned}` : "; no witness cosignature")
|
|
917
|
+
+ (times.length ? `; earliest independent anchor ${fmt(times.reduce((a, b) => b < a ? b : a))}` : "")
|
|
918
|
+
+ (capped ? `; capped by the run-set's registry notes (${capped})` : "") + (selfApproved ? "; approvals: self" : "");
|
|
919
|
+
}
|
|
920
|
+
/** The report as text, as the reference CLI prints it; control characters in bundle-derived strings are dropped. */
|
|
921
|
+
export function formatReport(rep) {
|
|
922
|
+
const clean = (x) => x.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
923
|
+
let s = "";
|
|
924
|
+
for (const c of rep.checks) {
|
|
925
|
+
s += `[${c.status.toUpperCase()}] ${c.check}` + (c.detail ? ` — ${clean(c.detail)}` : "") + "\n";
|
|
926
|
+
for (const p of c.problems)
|
|
927
|
+
s += ` ${clean(p)}\n`;
|
|
928
|
+
}
|
|
929
|
+
return s + `\nIntegrity: ${clean(rep.integrity)}.\nAssurance: ${clean(rep.assurance)}.\n`;
|
|
930
|
+
}
|