@cygnuxlabs/tracekit 1.3.0 → 1.4.1
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 +13 -1
- package/bin/tracekit.mjs +14 -12
- package/dist/v2/launcher.js +1 -1
- package/dist/v2/rpc_schema.d.ts +1 -1
- package/dist/v2/rpc_schema.js +6 -4
- package/dist/verify/cli.js +4 -4
- package/dist/verify/explain.d.ts +46 -0
- package/dist/verify/explain.js +212 -0
- package/dist/verify/index.d.ts +20 -4
- package/dist/verify/index.js +434 -67
- package/dist/verify/jcs.d.ts +2 -0
- package/dist/verify/jcs.js +10 -2
- package/dist/verify/primitives.d.ts +2 -1
- package/dist/verify/primitives.js +3 -2
- package/dist/verify/schema.d.ts +3 -3
- package/dist/verify/schema.js +5 -3
- package/dist/verify/tracekit.event.v2.json +244 -3
- package/dist/verify/web.d.ts +1 -0
- package/dist/verify/web.js +215 -0
- package/dist/verify/zip.d.ts +0 -5
- package/dist/verify/zip.js +10 -5
- package/dist/verify.html +2799 -0
- package/package.json +6 -6
package/dist/verify/index.js
CHANGED
|
@@ -10,25 +10,48 @@
|
|
|
10
10
|
* SLH-DSA-SHA2-128s hybrid checkpoint lines (Web Crypto has no SLH-DSA), Rekor v2 anchors, record-key certificates,
|
|
11
11
|
* tracekit why findings and replay runs.
|
|
12
12
|
* Monitor reports, revocations and the v1 format bridge are inputs of the reference's CLI that this one doesn't take.
|
|
13
|
+
* A bundle that needs a newer verifier is UNVERIFIABLE, with the commands that verify it; one whose records carry ext
|
|
14
|
+
* names this verifier doesn't interpret (format 1.4's non-critical slot) verifies, with a `not interpreted` warning
|
|
15
|
+
* per name; it interprets `amended` (the fields an LLM gateway changed in a request to show reasoning) and
|
|
16
|
+
* `openshell.policy` (OpenShell's sandbox policy changes, from the OpenShell middleware's OCSF import).
|
|
17
|
+
* `verify(bundle, null)` (no trust config) checks what needs no pinned key and is never VERIFIED. web.ts is this
|
|
18
|
+
* verifier as a browser page (verify.html).
|
|
13
19
|
*/
|
|
14
|
-
import { canonicalize, strictParseBytes } from "./jcs.js";
|
|
20
|
+
import { canonicalize, repr as quoted, strictParseBytes } from "./jcs.js";
|
|
15
21
|
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
22
|
import { Detector } from "./detect.js";
|
|
17
23
|
import { same, validateEvent } from "./schema.js";
|
|
18
|
-
import { MAX_ENTRY, readZip } from "./zip.js";
|
|
24
|
+
import { MAX_ENTRY, Unusable, readZip } from "./zip.js";
|
|
19
25
|
export { MAX_ENTRIES, MAX_ENTRY, MAX_TOTAL, Unusable, readZip } from "./zip.js";
|
|
20
26
|
export { StrictJSONError, canonicalize, strictParse, strictParseBytes } from "./jcs.js";
|
|
21
27
|
/** The bundle format version this verifier implements: a bundle needing a later one is UNVERIFIABLE. */
|
|
22
|
-
export const VERSION = "1.
|
|
28
|
+
export const VERSION = "1.4.1";
|
|
29
|
+
/** The browser verifier of the latest release (docs/RELEASING.md). */
|
|
30
|
+
const PAGE = "https://cygnux-labs.github.io/Tracekit/verify/";
|
|
23
31
|
export const EXIT_OK = 0, EXIT_FAIL = 1, EXIT_BAD = 2, EXIT_WARN = 3;
|
|
24
32
|
const FORMAT = "tracekit.bundle.v2";
|
|
25
33
|
const MAX_LINE = 1 << 20;
|
|
26
|
-
const
|
|
34
|
+
const MIN_RECORD = 256; // no signed record's line is shorter: its hash, signature, ids and timestamp alone are longer
|
|
35
|
+
const MAX_LINES = MAX_ENTRY / MIN_RECORD; // per JSON lines file
|
|
27
36
|
const CLASSES = ["public", "customer", "tracekit", "operator"];
|
|
28
37
|
const KEY_TYPES = ["signer.epoch", "key.retire"];
|
|
29
38
|
const ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
30
39
|
const MATRIX = ["model_calls", "tool_calls", "network", "processes", "services"];
|
|
31
40
|
const COVERED = ["none", "reported", "observed", "enforced"]; // weakest first
|
|
41
|
+
const SEVERITIES = ["critical", "high", "medium", "low", "info"]; // a signed finding's, worst first
|
|
42
|
+
// the closed fields of a signed finding's verdict (tracekit/signer/rpc_schema.py REVIEW) and the values each may take
|
|
43
|
+
const FAMILIES = ["anthropic", "openai", "google", "meta", "mistral", "deepseek", "qwen", "xai"];
|
|
44
|
+
const VERDICT = {
|
|
45
|
+
kind: ["finding", "coverage"], severity: SEVERITIES, tier: ["gateway", "harness", "transcript", "sdk"],
|
|
46
|
+
order: ["signed_before", "transcript_order", "after"], alignment: ["ok", "drift", "misaligned"],
|
|
47
|
+
reason: ["none", "unrequested_action", "unexplained_action", "contradicted_reasoning", "scope_creep", "workaround",
|
|
48
|
+
"false_success", "followed_tool_output", "injection", "other"],
|
|
49
|
+
};
|
|
50
|
+
/** The reference verifier's exception names, which reports print: a bundle the format refuses, a key it lacks. */
|
|
51
|
+
class ValueError extends Error {
|
|
52
|
+
}
|
|
53
|
+
class KeyError extends Error {
|
|
54
|
+
}
|
|
32
55
|
export class Report {
|
|
33
56
|
checks = [];
|
|
34
57
|
failures = [];
|
|
@@ -36,6 +59,8 @@ export class Report {
|
|
|
36
59
|
notes = [];
|
|
37
60
|
integrity = "UNUSABLE BUNDLE";
|
|
38
61
|
assurance = "none";
|
|
62
|
+
/** The Findings line: signed findings, an opinion of the run kept apart from its integrity (null: none). */
|
|
63
|
+
findings = null;
|
|
39
64
|
/** Checks the bundle needs that this verifier does not implement. */
|
|
40
65
|
unsupported = [];
|
|
41
66
|
check(check, ok, detail = "", problems = [], warn = false) {
|
|
@@ -66,7 +91,7 @@ function at(o, k) {
|
|
|
66
91
|
if (!isObj(o))
|
|
67
92
|
throw new TypeError(`${kind(o)} is not an object`);
|
|
68
93
|
if (!Object.hasOwn(o, k))
|
|
69
|
-
throw new
|
|
94
|
+
throw new KeyError(repr(k));
|
|
70
95
|
return o[k];
|
|
71
96
|
}
|
|
72
97
|
function get(o, k, missing = null) {
|
|
@@ -103,20 +128,36 @@ function text(v) {
|
|
|
103
128
|
const truthy = (v) => Array.isArray(v) ? v.length > 0 : isObj(v) ? Object.keys(v).length > 0 : !!v;
|
|
104
129
|
const str = (v) => v === null ? "None" : v === true ? "True" : v === false ? "False"
|
|
105
130
|
: typeof v === "string" ? v : JSON.stringify(v);
|
|
106
|
-
const repr = (v) => typeof v === "string" ?
|
|
131
|
+
const repr = (v) => typeof v === "string" ? quoted(v) : str(v);
|
|
107
132
|
const cut = (s, n) => [...s].slice(0, n).join("");
|
|
133
|
+
/** `xs` appended to `to` one by one: a spread call (`push(...xs)`, `Math.max(...xs)`) puts every element on the stack,
|
|
134
|
+
* which a bundle of 60k records overflows. */
|
|
135
|
+
const extend = (to, xs) => {
|
|
136
|
+
for (const x of xs)
|
|
137
|
+
to.push(x);
|
|
138
|
+
};
|
|
139
|
+
/** Python's order of strings, by code point (JavaScript's `<` and sort() compare UTF-16 code units, which put a
|
|
140
|
+
* character above U+FFFF before one in U+E000..U+FFFF). */
|
|
141
|
+
const byCodePoint = (a, b) => {
|
|
142
|
+
const unit = (u) => u >= 0xd800 && u < 0xe000 ? u + 0x2000 : u >= 0xe000 ? u - 0x800 : u;
|
|
143
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++)
|
|
144
|
+
if (a.charCodeAt(i) !== b.charCodeAt(i))
|
|
145
|
+
return unit(a.charCodeAt(i)) - unit(b.charCodeAt(i));
|
|
146
|
+
return a.length - b.length;
|
|
147
|
+
};
|
|
148
|
+
const byKey = ([a], [b]) => byCodePoint(a, b);
|
|
108
149
|
const count = (xs) => {
|
|
109
150
|
const m = new Map();
|
|
110
151
|
for (const x of xs)
|
|
111
152
|
m.set(x, (m.get(x) ?? 0) + 1);
|
|
112
|
-
return [...m].sort(
|
|
153
|
+
return [...m].sort(byKey);
|
|
113
154
|
};
|
|
114
155
|
const fmt = (ts) => {
|
|
115
156
|
if (ts > 253402300799)
|
|
116
157
|
throw new RangeError("year is out of range");
|
|
117
158
|
return new Date(Number(ts) * 1000).toISOString().slice(0, 19) + "Z";
|
|
118
159
|
};
|
|
119
|
-
async function loadTrust(data) {
|
|
160
|
+
async function loadTrust(data, name) {
|
|
120
161
|
const floats = new WeakMap();
|
|
121
162
|
const t = strictParseBytes(data.subarray(0, MAX_LINE), floats);
|
|
122
163
|
const int = (o, k, d) => !Object.hasOwn(o, k) ? d
|
|
@@ -126,7 +167,7 @@ async function loadTrust(data) {
|
|
|
126
167
|
const opt = (o, k) => Object.hasOwn(o, k) ? o[k] : [];
|
|
127
168
|
const r = isObj(t) ? get(t, "rekor", {}) : null;
|
|
128
169
|
const ok = isObj(t) && Object.keys(t).every((k) => ["logs", "witnesses", "algs", "witnesses_required", "rekor",
|
|
129
|
-
"monitors", "issuers"].includes(k))
|
|
170
|
+
"monitors", "issuers", "analyzers"].includes(k))
|
|
130
171
|
&& isObj(r) && (!Object.keys(r).length || exactly(r, ["trusted_root", "publishing_key", "class"])
|
|
131
172
|
&& isObj(r.trusted_root) && typeof r.publishing_key === "string" && CLASSES.includes(r.class))
|
|
132
173
|
&& strings(t.logs)
|
|
@@ -136,17 +177,34 @@ async function loadTrust(data) {
|
|
|
136
177
|
&& Array.isArray(opt(t, "monitors")) && opt(t, "monitors").every((m) => exactly(m, ["vkey", "class", "max_age_s"])
|
|
137
178
|
&& typeof m.vkey === "string" && CLASSES.includes(m.class) && int(m, "max_age_s", 0) > 0)
|
|
138
179
|
&& 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"))
|
|
180
|
+
&& Object.values(i).every((v) => typeof v === "string"))
|
|
181
|
+
&& Array.isArray(opt(t, "analyzers"))
|
|
182
|
+
&& opt(t, "analyzers").every((a) => typeof a === "string" && /^[a-z0-9_]+:.+$/.test(a));
|
|
140
183
|
if (!ok)
|
|
141
|
-
throw new
|
|
184
|
+
throw new ValueError(`${name}: not a v2 trust config (logs, witnesses, algs, witnesses_required, rekor, monitors, `
|
|
185
|
+
+ "issuers, analyzers)");
|
|
142
186
|
for (const k of [...t.logs, ...opt(t, "witnesses").map((w) => w.vkey), ...opt(t, "monitors").map((m) => m.vkey),
|
|
143
187
|
...opt(t, "issuers").flatMap((i) => Object.values(i))])
|
|
144
188
|
await parseVkey(k);
|
|
145
189
|
t.witnesses ??= [];
|
|
146
190
|
t.witnesses_required ??= 0;
|
|
147
191
|
t.issuers ??= [];
|
|
192
|
+
t.analyzers ??= [];
|
|
148
193
|
return t;
|
|
149
194
|
}
|
|
195
|
+
/** `s` as one shell argument, as the reference quotes it (shlex.quote; on Windows subprocess.list2cmdline). */
|
|
196
|
+
function quote(s) {
|
|
197
|
+
if (globalThis.process?.platform === "win32")
|
|
198
|
+
return /[ \t]/.test(s) || !s ? `"${s}"` : s;
|
|
199
|
+
return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
200
|
+
}
|
|
201
|
+
/** Ways to verify the bundle with a verifier of version `need` or later, as commands with the user's own paths. */
|
|
202
|
+
function upgrade(need, names) {
|
|
203
|
+
const [b, t] = [quote(names.bundle), quote(names.trust)];
|
|
204
|
+
return [`npx -y @cygnuxlabs/tracekit@latest verify ${b} --trust ${t}`,
|
|
205
|
+
`uvx --from "tracekit-ai>=${need}" tracekit verify ${b} --trust ${t}`,
|
|
206
|
+
`or open ${PAGE} and choose both files: the page verifies them in your browser and sends nothing`];
|
|
207
|
+
}
|
|
150
208
|
function newer(need, have) {
|
|
151
209
|
const a = need.split(".").map(Number), b = have.split(".").map(Number);
|
|
152
210
|
for (let i = 0; i < Math.min(a.length, b.length); i++)
|
|
@@ -155,38 +213,55 @@ function newer(need, have) {
|
|
|
155
213
|
return a.length > b.length;
|
|
156
214
|
}
|
|
157
215
|
const errorText = (e) => `${e?.constructor?.name ?? "Error"}: ${e?.message ?? e}`;
|
|
158
|
-
/**
|
|
159
|
-
|
|
216
|
+
/** Without a trust config: the bundle against its own checkpoint, whose signature no pinned key checks. */
|
|
217
|
+
const UNANCHORED = "UNVERIFIABLE (no trust config: the records match their own checkpoint, which no pinned key checked)";
|
|
218
|
+
/**
|
|
219
|
+
* Verify a v2 bundle (zip bytes) against the verifier's own pinned trust config (JSON bytes). Never throws. `names`: the
|
|
220
|
+
* files' paths, for the commands a bundle that needs a newer verifier is told to run. `trust` null (the browser page,
|
|
221
|
+
* when no trust config is chosen): every check that needs no pinned key, and never VERIFIED: a changed record still
|
|
222
|
+
* FAILS, and an intact bundle is UNVERIFIABLE, since anyone can sign a bundle and its checkpoint with keys of their own.
|
|
223
|
+
*/
|
|
224
|
+
export async function verify(bundle, trust, names = { bundle: "BUNDLE", trust: "TRUST.json" }) {
|
|
160
225
|
const rep = new Report();
|
|
161
|
-
let t, files, manifest;
|
|
162
|
-
|
|
163
|
-
t =
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
rep.check("trust config", false, errorText(e));
|
|
167
|
-
return { report: rep, code: EXIT_BAD };
|
|
226
|
+
let t, files, manifest, v1 = false;
|
|
227
|
+
if (trust === null) {
|
|
228
|
+
t = { logs: [], witnesses: [], algs: ["ed25519"], witnesses_required: 0, issuers: [], analyzers: [] };
|
|
229
|
+
rep.check("trust config", false, "none: the checkpoint's signature and its witnesses are not checked, so nothing ties "
|
|
230
|
+
+ "these records to a signer you trust", [], true);
|
|
168
231
|
}
|
|
232
|
+
else
|
|
233
|
+
try {
|
|
234
|
+
t = await loadTrust(trust, names.trust);
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
rep.check("trust config", false, errorText(e));
|
|
238
|
+
return { report: rep, code: EXIT_BAD };
|
|
239
|
+
}
|
|
169
240
|
const floats = new WeakMap();
|
|
170
241
|
try {
|
|
171
242
|
files = await readZip(bundle);
|
|
172
243
|
const m = files.get("manifest.json");
|
|
173
244
|
if (!m)
|
|
174
|
-
throw new
|
|
245
|
+
throw new KeyError(repr("manifest.json"));
|
|
175
246
|
files.delete("manifest.json");
|
|
176
247
|
manifest = strictParseBytes(m, floats);
|
|
248
|
+
v1 = isObj(manifest) && manifest.format === "tracekit.bundle.v1";
|
|
177
249
|
if (!isObj(manifest) || manifest.format !== FORMAT)
|
|
178
|
-
throw new
|
|
250
|
+
throw new Unusable(`format is not ${FORMAT}`);
|
|
179
251
|
const need = manifest.verifier_min_version;
|
|
180
252
|
if (!(typeof need === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(need)))
|
|
181
|
-
throw new
|
|
253
|
+
throw new Unusable("verifier_min_version is not a version number");
|
|
182
254
|
if (newer(need, VERSION)) {
|
|
183
255
|
rep.integrity = `UNVERIFIABLE (needs tracekit >= ${need})`;
|
|
184
|
-
rep.check("bundle readable", false, rep.integrity);
|
|
256
|
+
rep.check("bundle readable", false, rep.integrity, upgrade(need, names));
|
|
185
257
|
return { report: rep, code: EXIT_BAD };
|
|
186
258
|
}
|
|
187
259
|
}
|
|
188
260
|
catch (e) {
|
|
189
|
-
|
|
261
|
+
// a format v1 bundle (the laptop signer's): the reference verifier checks it, against the signer.pub the sender pins
|
|
262
|
+
rep.check("bundle readable", false, `cannot read bundle: ${errorText(e)}`, v1 ? ["a format v1 bundle: verify it with "
|
|
263
|
+
+ `the signer's public key (signer.pub, from the sender): uvx --from tracekit-ai tracekit verify ${quote(names.bundle)}`
|
|
264
|
+
+ " --key signer.pub"] : []);
|
|
190
265
|
return { report: rep, code: EXIT_BAD };
|
|
191
266
|
}
|
|
192
267
|
try {
|
|
@@ -197,6 +272,8 @@ export async function verify(bundle, trust) {
|
|
|
197
272
|
}
|
|
198
273
|
if (rep.failures.length)
|
|
199
274
|
rep.integrity = "FAILED";
|
|
275
|
+
else if (trust === null)
|
|
276
|
+
rep.integrity = UNANCHORED;
|
|
200
277
|
else if (rep.unsupported.length)
|
|
201
278
|
rep.integrity = `UNVERIFIABLE (not checked by this verifier: ${rep.unsupported.join(", ")})`;
|
|
202
279
|
return { report: rep, code: rep.failures.length ? EXIT_FAIL : rep.integrity.startsWith("UNVERIFIABLE") ? EXIT_BAD : EXIT_OK };
|
|
@@ -205,23 +282,27 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
205
282
|
const file = (name) => {
|
|
206
283
|
const f = files.get(name);
|
|
207
284
|
if (!f)
|
|
208
|
-
throw new
|
|
285
|
+
throw new KeyError(repr(name));
|
|
209
286
|
return f;
|
|
210
287
|
};
|
|
211
288
|
const json = (name) => strictParseBytes(file(name), floats);
|
|
289
|
+
/** A JSON lines file's texts, each line at least MIN_RECORD bytes: checked before any is parsed, so a bundle's lines
|
|
290
|
+
* can't outnumber MAX_TOTAL / MIN_RECORD, whatever it unzips to. */
|
|
212
291
|
const jsonl = (name) => {
|
|
213
292
|
const data = file(name);
|
|
214
293
|
if (data.length && data.at(-1) !== 0x0a)
|
|
215
|
-
throw new
|
|
294
|
+
throw new ValueError("a JSON lines file must end in a newline");
|
|
216
295
|
const lines = [];
|
|
217
296
|
for (let i = 0, j; (j = data.indexOf(0x0a, i)) >= 0; i = j + 1) {
|
|
218
297
|
// checked before any is parsed: a run of tiny lines costs no memory
|
|
219
298
|
if (lines.length === MAX_LINES)
|
|
220
|
-
throw new
|
|
299
|
+
throw new ValueError("more lines than the bundle limits");
|
|
221
300
|
lines.push(data.subarray(i, j));
|
|
222
301
|
}
|
|
223
302
|
if (lines.some((l) => l.length > MAX_LINE))
|
|
224
|
-
throw new
|
|
303
|
+
throw new ValueError("line longer than the bundle limits");
|
|
304
|
+
if (lines.some((l) => l.length < MIN_RECORD))
|
|
305
|
+
throw new ValueError("line shorter than a signed record");
|
|
225
306
|
return lines.map((l) => strictParseBytes(l, floats));
|
|
226
307
|
};
|
|
227
308
|
const listed = manifest.files, hashes = new Map();
|
|
@@ -234,11 +315,12 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
234
315
|
const proofs = json("proofs/records.json");
|
|
235
316
|
const name = at(proofs, "checkpoint");
|
|
236
317
|
if (!(typeof name === "string" && name.startsWith("checkpoints/") && name.endsWith(".note")))
|
|
237
|
-
throw new
|
|
318
|
+
throw new ValueError("proofs name no checkpoint note");
|
|
238
319
|
const witnesses = new Map(trust.witnesses.map((w) => [w.vkey, w.class]));
|
|
320
|
+
const anchored = trust.logs.length > 0; // a trust config pins at least one log key
|
|
239
321
|
let note;
|
|
240
322
|
try {
|
|
241
|
-
note = await openNote(file(name), trust.logs, [...witnesses.keys()]);
|
|
323
|
+
note = await openNote(file(name), trust.logs, [...witnesses.keys()], anchored);
|
|
242
324
|
}
|
|
243
325
|
catch (e) {
|
|
244
326
|
if (!(e instanceof NoteError))
|
|
@@ -247,16 +329,18 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
247
329
|
return;
|
|
248
330
|
}
|
|
249
331
|
const { origin, size, root, cosigs } = note;
|
|
250
|
-
rep.check("checkpoint", same(size, at(proofs, "tree_size")), `${origin} at tree size ${size},
|
|
332
|
+
rep.check("checkpoint", same(size, at(proofs, "tree_size")), `${origin} at tree size ${size}, `
|
|
333
|
+
+ (anchored ? "signed by its pinned log key" : "its signature not checked (no trust config)"));
|
|
251
334
|
if (note.unchecked)
|
|
252
335
|
rep.unsupport("SLH-DSA-SHA2-128s checkpoint signature");
|
|
253
|
-
|
|
336
|
+
if (anchored)
|
|
337
|
+
rep.check("witness quorum", cosigs.length >= trust.witnesses_required, `${cosigs.length} pinned cosignature(s), ${trust.witnesses_required} required`);
|
|
254
338
|
if (truthy(trust.rekor) && files.has(`rekor/${size}.json`))
|
|
255
339
|
rep.unsupport("rekor anchor");
|
|
256
340
|
const included = async (r) => {
|
|
257
341
|
const seq = at(at(r, "event"), "seq");
|
|
258
342
|
const path = get(at(proofs, "inclusion"), str(seq));
|
|
259
|
-
return path
|
|
343
|
+
return Array.isArray(path) && verifyInclusion(num(seq), size, await leafHash(unhex(text(at(r, "hash")).slice(7))), path.map(b64decode), root);
|
|
260
344
|
};
|
|
261
345
|
// keys: declared and retired by records the checkpointed tree includes, so the log key vouches for them
|
|
262
346
|
const keyRecords = jsonl("keys/records.jsonl"), keyProblems = [], timeline = new Map();
|
|
@@ -317,18 +401,26 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
317
401
|
for (const [kid, k] of declared)
|
|
318
402
|
timeline.set(kid, k);
|
|
319
403
|
}
|
|
320
|
-
// the runs: one, or with a run-set any number, or one with its
|
|
321
|
-
// lean: findings (verify/v2.py's _findings) are not checked here: a bundle with
|
|
322
|
-
// TS users verify
|
|
323
|
-
const names =
|
|
404
|
+
// the runs: one, or with a run-set any number, or one with its findings runs (and replay runs)
|
|
405
|
+
// lean: tracekit why findings (verify/v2.py's _findings) are not checked here: a bundle with a why.finding or a replay
|
|
406
|
+
// run is UNVERIFIABLE; port it when TS users verify tracekit why bundles
|
|
407
|
+
const names = runFiles(files.keys());
|
|
324
408
|
const runSet = files.has("registry/run-set.json");
|
|
325
409
|
const runs = new Map(), problems = [], chain = [], outside = [];
|
|
326
|
-
|
|
327
|
-
|
|
410
|
+
const layout = [], seen = new Set();
|
|
411
|
+
for (const [name, parts] of names) {
|
|
412
|
+
const pieces = parts.map(jsonl), records = pieces.flat(), whole = parts.length === 1 && parts[0] === name;
|
|
328
413
|
runs.set(name, records);
|
|
414
|
+
extend(layout, parted(name, parts, pieces));
|
|
329
415
|
if (!records.length)
|
|
330
|
-
throw new
|
|
331
|
-
const head = at(records[0], "event");
|
|
416
|
+
throw new ValueError("a run has no records");
|
|
417
|
+
const head = at(records[0], "event"), tenant = get(head, "tenant"), runId = get(head, "run_id");
|
|
418
|
+
if (typeof tenant === "string" && typeof runId === "string") { // a second file of one run would count it twice
|
|
419
|
+
const key = JSON.stringify([tenant, runId]);
|
|
420
|
+
if (seen.has(key))
|
|
421
|
+
chain.push(`run ${repr(cut(runId, 200))} of tenant ${repr(cut(tenant, 64))} again in ${name}`);
|
|
422
|
+
seen.add(key);
|
|
423
|
+
}
|
|
332
424
|
for (const [i, r] of records.entries()) {
|
|
333
425
|
await checkRecord(r, keysAt(num(at(at(r, "event"), "seq"))), problems);
|
|
334
426
|
const e = r.event, prev = i ? records[i - 1] : null;
|
|
@@ -339,19 +431,19 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
339
431
|
+ "continue the run");
|
|
340
432
|
}
|
|
341
433
|
if (!(await included(records[0]) && await included(records.at(-1))))
|
|
342
|
-
outside.push(`run ${repr(cut(str(get(head, "run_id")), 200))}`)
|
|
434
|
+
outside.push(`run ${repr(cut(str(get(head, "run_id")), 200))}` + (!whole && await included(records[0])
|
|
435
|
+
? `: the last record of ${parts.at(-1)} is not in the checkpointed tree, so a later part may be missing` : ""));
|
|
343
436
|
}
|
|
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)));
|
|
437
|
+
const why = [...runs.values()].some((rs) => rs.some((r) => get(r.event, "type") === "why.finding") || about(rs)[1] !== null);
|
|
346
438
|
if (why)
|
|
347
439
|
rep.unsupport("tracekit why findings");
|
|
348
|
-
else if (
|
|
349
|
-
throw new
|
|
440
|
+
else if (runs.size !== 1 && !runSet && !aboutOne(runs))
|
|
441
|
+
throw new ValueError("a v2 bundle holds exactly one run, one run with its findings and replay runs, or a run-set");
|
|
350
442
|
let retired = [], provenTo = -1, regCosigs = [];
|
|
351
443
|
if (runSet) {
|
|
352
444
|
[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
445
|
const keyed = new Set(keyRecords.map((r) => `${at(at(r, "event"), "seq")} ${at(r, "hash")}`));
|
|
354
|
-
keyProblems
|
|
446
|
+
extend(keyProblems, retired.filter(([seq, h]) => !keyed.has(`${seq} ${h}`))
|
|
355
447
|
.map(([seq]) => `seq ${seq}: key.retire withheld (the registry log has it)`));
|
|
356
448
|
}
|
|
357
449
|
rep.check("keys", timeline.size > 0 && !keyProblems.length, `${timeline.size} record key(s) from ${keyRecords.length} key `
|
|
@@ -359,8 +451,8 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
359
451
|
const every = [...runs.values()].flat();
|
|
360
452
|
const seqs = [...keyRecords, ...every].map((r) => num(at(at(r, "event"), "seq")));
|
|
361
453
|
if (!seqs.length)
|
|
362
|
-
throw new
|
|
363
|
-
const relied =
|
|
454
|
+
throw new ValueError("the bundle holds no key or run records");
|
|
455
|
+
const relied = seqs.reduce((a, b) => b > a ? b : a);
|
|
364
456
|
const rotated = timeline.size > 1 || keyRecords.some((r) => get(r.event, "type") === "key.retire");
|
|
365
457
|
if (provenTo < relied && rotated) // one key never retired: the assurance line notes it, no warning
|
|
366
458
|
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);
|
|
@@ -368,6 +460,10 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
368
460
|
if (bridge && truthy(bridge.data.bridge))
|
|
369
461
|
rep.notes.push(`this log continues the v1 ledger of key ${str(at(bridge.data.bridge, "v1_kid"))}; pass the v1 ledger and `
|
|
370
462
|
+ "its signer.pub to the reference verifier to check the format bridge");
|
|
463
|
+
const inParts = [...names].filter(([n, ps]) => !(ps.length === 1 && ps[0] === n)).map(([, ps]) => ps.length);
|
|
464
|
+
if (inParts.length)
|
|
465
|
+
rep.check("run parts", !layout.length, `${inParts.length} run(s) in ${inParts.reduce((a, b) => a + b, 0)} parts, `
|
|
466
|
+
+ "each part continuing the one before it", layout);
|
|
371
467
|
rep.check("signatures", !problems.length, `${every.length} record(s), keys valid at their position`, problems);
|
|
372
468
|
const only = runs.size === 1 ? [...runs.values()][0][0].event : null;
|
|
373
469
|
rep.check("run chain", !chain.length, only ? `run ${repr(cut(str(get(only, "run_id")), 200))} of tenant `
|
|
@@ -440,14 +536,16 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
440
536
|
+ MATRIX.map((k) => {
|
|
441
537
|
const cs = matrices.map((m) => at(m, k));
|
|
442
538
|
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()
|
|
539
|
+
const layers = [...new Set(cs.flatMap((c) => list(at(c, "layers")).map(str)))].sort(byCodePoint)
|
|
444
540
|
.concat(cs.some((c) => get(c, "asserted") === true) ? ["asserted"] : []);
|
|
445
541
|
return `${k.replace("_", " ")} ` + (status === "none" ? "not covered"
|
|
446
542
|
: status + (layers.length ? ` (${layers.join(", ")})` : ""));
|
|
447
543
|
}).join(", "));
|
|
448
544
|
const lost = gaps("observer_lost");
|
|
449
545
|
if (lost.length)
|
|
450
|
-
rep.check("observers", false, `${lost.length}
|
|
546
|
+
rep.check("observers", false, `${lost.length} observer gap(s): an observer went silent, restarted or lost events while its run was open`, lost.slice(0, 20), true);
|
|
547
|
+
checkReviews(rep, runs, trust.analyzers);
|
|
548
|
+
checkConstraints(rep, runs);
|
|
451
549
|
checkIncidents(rep, runs, policies, floats);
|
|
452
550
|
const accessed = typed("content.accessed").length, erased = typed("content.erased").length;
|
|
453
551
|
if (accessed || erased) // signer records of the content vault (docs/privacy.md): who read or erased retained content
|
|
@@ -476,7 +574,7 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
476
574
|
tiers.set(k, t);
|
|
477
575
|
const split = [...paths].filter(([, p]) => p.size > 1).map(([k, p]) => {
|
|
478
576
|
const [run, tcid] = JSON.parse(k);
|
|
479
|
-
return `${cut(str(run), 200)} ${cut(str(tcid), 200)}: ${[...p].sort().join(", ")}`;
|
|
577
|
+
return `${cut(str(run), 200)} ${cut(str(tcid), 200)}: ${[...p].sort(byCodePoint).join(", ")}`;
|
|
480
578
|
});
|
|
481
579
|
if (tiers.size) {
|
|
482
580
|
const n = (t) => [...tiers.values()].filter((x) => x === t).length;
|
|
@@ -506,17 +604,107 @@ async function check(rep, manifest, files, trust, floats) {
|
|
|
506
604
|
if (!isObj(modes))
|
|
507
605
|
throw new TypeError("fail_modes is not an object");
|
|
508
606
|
return Object.entries(modes).filter(([, m]) => m === "open").map(([c]) => cut(c, 64));
|
|
509
|
-
}))].sort();
|
|
607
|
+
}))].sort(byCodePoint);
|
|
510
608
|
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
609
|
rep.check("key assurance", true, "asserted: the log declares its record keys; none is attested");
|
|
512
610
|
const algs = new Set([...every, ...keyRecords].map((r) => text(at(r, "alg"))));
|
|
513
|
-
rep.assurance =
|
|
514
|
-
|
|
611
|
+
rep.assurance = !anchored ? "none: no trust config pins a log key or witness"
|
|
612
|
+
: await assurance(origin, cosigs, witnesses, trust, algs, selfApproved, regCosigs)
|
|
613
|
+
+ (provenTo < relied ? "; key retirements not proven complete" : "");
|
|
515
614
|
approvals(rep, runs, policies, floats);
|
|
516
615
|
const glass = typed("approval").filter((e) => get(at(e, "data"), "break_glass") === true);
|
|
517
616
|
if (glass.length)
|
|
518
617
|
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
618
|
+ `${cut(text(at(e.data, "approver")), 256)}: ${repr(cut(str(get(e.data, "reason")), 200))}`).slice(0, 20), true);
|
|
619
|
+
const pointed = runSet ? jsonl("registry/records.jsonl") : []; // the records a run-set's leaves name
|
|
620
|
+
checkExt(rep, [...new Map([...keyRecords, ...pointed, ...every].map((r) => [at(at(r, "event"), "seq"), r.event]))
|
|
621
|
+
.values()], get(manifest, "verifier_version"));
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* ext.amended: the JSON pointers of the fields an LLM gateway changed in a model request before sending it, to show the
|
|
625
|
+
* reasoning the request has the model do. The signer writes it on a gateway's request record only, whose content digest
|
|
626
|
+
* is of the request as sent; anywhere else, or not a list of pointers, it is a warning.
|
|
627
|
+
*/
|
|
628
|
+
function checkAmended(rep, events) {
|
|
629
|
+
const fields = (e) => {
|
|
630
|
+
const v = isObj(get(e, "ext")) ? get(e.ext, "amended") : null;
|
|
631
|
+
const data = get(e, "data", {});
|
|
632
|
+
return get(e, "type") === "model.exchange" && get(e, "source") === "gateway" && isObj(data)
|
|
633
|
+
&& get(data, "phase") === "request" && Array.isArray(v) && v.length > 0 && v.every((p) => typeof p === "string"
|
|
634
|
+
&& [...p].length <= 256 && /^(\/([^~/]|~[01])*)+$/u.test(p)) ? v : null;
|
|
635
|
+
};
|
|
636
|
+
const good = events.filter((e) => fields(e)), bad = events.filter((e) => !fields(e));
|
|
637
|
+
const n = new Map();
|
|
638
|
+
for (const e of good)
|
|
639
|
+
for (const p of fields(e))
|
|
640
|
+
n.set(p, (n.get(p) ?? 0) + 1);
|
|
641
|
+
const shown = [...n].sort(byKey).slice(0, 8).map(([p, c]) => `${p}: ${c}`);
|
|
642
|
+
rep.check("gateway amendments", bad.length === 0, `gateway amended ${good.length} request(s) to show reasoning`
|
|
643
|
+
+ (shown.length ? ` (${shown.join(", ")})` : "") + "; each record commits to the request as sent", bad.map((e) => `seq ${str(get(e, "seq"))}: ext.amended is not a list of JSON pointers on a gateway's model request`)
|
|
644
|
+
.slice(0, 20), true);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* ext.openshell.policy: a change of the sandbox's policy or configuration that OpenShell reported (its name for the
|
|
648
|
+
* change, its hash of the policy, who made it; its message committed), an `ext` record the signer writes from the
|
|
649
|
+
* OpenShell middleware's OCSF import, that observer's assertion; anything else carrying the name is a warning.
|
|
650
|
+
*/
|
|
651
|
+
function checkOpenshellPolicy(rep, events) {
|
|
652
|
+
const change = (e) => {
|
|
653
|
+
const d = get(e, "data");
|
|
654
|
+
return get(e, "type") === "ext" && get(e, "source") === "signer" && isObj(d)
|
|
655
|
+
&& get(d, "name") === "openshell.policy" && typeof get(d, "change") === "string"
|
|
656
|
+
&& /^[a-z0-9_.-]{1,64}$/u.test(d.change) && typeof get(d, "observer") === "string" && d.observer !== ""
|
|
657
|
+
? d.change : null;
|
|
658
|
+
};
|
|
659
|
+
const good = events.filter((e) => change(e)), bad = events.filter((e) => !change(e));
|
|
660
|
+
const n = new Map();
|
|
661
|
+
for (const e of good)
|
|
662
|
+
n.set(change(e), (n.get(change(e)) ?? 0) + 1);
|
|
663
|
+
const shown = [...n].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).slice(0, 8).map(([c, k]) => `${c}: ${k}`);
|
|
664
|
+
rep.check("openshell policy", bad.length === 0, "OpenShell changed the sandbox policy or configuration "
|
|
665
|
+
+ `${good.length} time(s)` + (shown.length ? ` (${shown.join(", ")})` : "") + ", as OpenShell reported it", bad.map((e) => `seq ${str(get(e, "seq"))}: ext.openshell.policy is not an OpenShell policy change the signer `
|
|
666
|
+
+ "recorded").slice(0, 20), true);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The ext names this verifier interprets (the VERSIONS rows' "ext" in tracekit/bundle_v2.py): name -> its check, whose
|
|
670
|
+
* lines are its own: every other line of the report reads the same without them (docs/format-v2.md §9).
|
|
671
|
+
*/
|
|
672
|
+
const EXT_CHECKS = new Map([["amended", checkAmended],
|
|
673
|
+
["openshell.policy", checkOpenshellPolicy]]);
|
|
674
|
+
const MAX_UNREAD = 20; // `not interpreted` lines, one per ext name; the rest on one line
|
|
675
|
+
/** The ext names an event carries: its `ext` field's keys, and an `ext` record's name. */
|
|
676
|
+
const extNames = (e) => [...Object.keys(isObj(get(e, "ext")) ? e.ext : {}),
|
|
677
|
+
...get(e, "type") === "ext" ? [at(at(e, "data"), "name")] : []];
|
|
678
|
+
/**
|
|
679
|
+
* A line per ext name the records carry: its EXT_CHECKS check, or a `not interpreted` warning. The records were checked
|
|
680
|
+
* like every other (signature, chain, inclusion); `full`: the manifest's advisory verifier_version.
|
|
681
|
+
*/
|
|
682
|
+
function checkExt(rep, events, full) {
|
|
683
|
+
const carry = new Map(); // name -> the events carrying it
|
|
684
|
+
for (const e of events)
|
|
685
|
+
for (const n of new Set(extNames(e).map((x) => cut(str(x), 64)))) {
|
|
686
|
+
if (!carry.has(n))
|
|
687
|
+
carry.set(n, []);
|
|
688
|
+
carry.get(n).push(e);
|
|
689
|
+
}
|
|
690
|
+
const later = typeof full === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(full) && newer(full, VERSION)
|
|
691
|
+
? `; tracekit >= ${full} checks it` : "";
|
|
692
|
+
let shown = 0, more = 0;
|
|
693
|
+
for (const [n, es] of [...carry].sort(byKey)) {
|
|
694
|
+
const interpret = EXT_CHECKS.get(n);
|
|
695
|
+
if (interpret)
|
|
696
|
+
interpret(rep, es);
|
|
697
|
+
else if (shown < MAX_UNREAD) {
|
|
698
|
+
shown++;
|
|
699
|
+
rep.check("not interpreted", false, `${es.length} record(s) carry ext.${n}: their signatures, chain and `
|
|
700
|
+
+ `inclusion are checked above; what ext.${n} means is not checked${later}`, [], true);
|
|
701
|
+
}
|
|
702
|
+
else
|
|
703
|
+
more++; // a line each would bury the report
|
|
704
|
+
}
|
|
705
|
+
if (more)
|
|
706
|
+
rep.check("not interpreted", false, `${more} more ext name(s), on records whose signatures, chain and inclusion `
|
|
707
|
+
+ `are checked above; what they mean is not checked${later}`, [], true);
|
|
520
708
|
}
|
|
521
709
|
/** Approvals by people and by agents, each on a line of its own. An agent's approval fails the line unless its run
|
|
522
710
|
* holds the approval.request and policy.decision it answered, the signer called the request agent-eligible
|
|
@@ -603,6 +791,142 @@ const CONTAINMENT = ["incident.opened", "incident.updated", "run.held", "run.qua
|
|
|
603
791
|
const FIRES = ["policy.decision", "approval.consumed", "capture.gap", "trace.tamper", "refusal.summary",
|
|
604
792
|
"reconcile.unexplained_effect"];
|
|
605
793
|
const RECOMPUTED = ["sequence", "limit", "denied_calls", "provenance", "executed_against_policy", "class_mismatch"];
|
|
794
|
+
/** [analyzes, replay_of, investigation] of a run's run.registered (nulls when it is not one). */
|
|
795
|
+
function about(rs) {
|
|
796
|
+
const e = at(rs[0], "event"), d = get(e, "type") === "run.registered" ? get(e, "data") : null;
|
|
797
|
+
return ["analyzes", "replay_of", "investigation"].map((k) => truthy(d) ? get(d, k) : null);
|
|
798
|
+
}
|
|
799
|
+
/** Whether every run but one of the same tenant is a findings or replay run of that one. */
|
|
800
|
+
function aboutOne(runs) {
|
|
801
|
+
const all = [...runs.values()], main = all.filter((rs) => about(rs)[0] === null && about(rs)[1] === null);
|
|
802
|
+
if (main.length !== 1)
|
|
803
|
+
return false;
|
|
804
|
+
const head = main[0][0].event;
|
|
805
|
+
return all.every((rs) => rs === main[0] || same(get(rs[0].event, "tenant"), get(head, "tenant"))
|
|
806
|
+
&& about(rs).slice(0, 2).some((x) => same(x, get(head, "run_id"))));
|
|
807
|
+
}
|
|
808
|
+
/** ["scheme:subject", signer_isolation] of a run's run.registered (nulls when it is not one). */
|
|
809
|
+
function author(rs) {
|
|
810
|
+
const e = at(rs[0], "event"), d = get(e, "type") === "run.registered" ? get(e, "data") : {};
|
|
811
|
+
const i = get(d, "identity");
|
|
812
|
+
return [truthy(i) ? `${str(at(i, "scheme"))}:${str(at(i, "subject"))}` : null, get(d, "signer_isolation")];
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Signed findings (format 1.4, docs/format-v2.md §11): each `review` with `cites`, in a findings run, cites records of
|
|
816
|
+
* the analysed run that are in the bundle with those hashes (else `reviews` fails), and is labelled by its author (the
|
|
817
|
+
* findings run's registered identity), positive-only: independent only when the trust config's `analyzers` pins it;
|
|
818
|
+
* self-asserted (a warning) when it is the analysed run's identity or either run's signer_isolation is same-user; else
|
|
819
|
+
* unpinned. Without its analysed run: not counted, author not checked (`reviews counted`). No cites: imported.
|
|
820
|
+
*/
|
|
821
|
+
/** What is wrong with a signed finding's verdict `v` (VERDICT; its asserted model, families and window), or null. */
|
|
822
|
+
function verdictProblem(v) {
|
|
823
|
+
if (!isObj(v) || typeof v.rule !== "string" || !/^TK-[A-Z][0-9]{3}$/.test(v.rule)
|
|
824
|
+
|| !["kind", "severity"].every((k) => VERDICT[k].includes(get(v, k))))
|
|
825
|
+
return "its verdict has no kind, rule and severity of a signed finding";
|
|
826
|
+
const a = get(v, "asserted"), f = get(v, "families"), w = get(v, "window");
|
|
827
|
+
const name = (x) => typeof x === "string" && /^[A-Za-z0-9._:/@+-]{1,128}$/.test(x);
|
|
828
|
+
const seconds = (x) => typeof x === "number" && x >= 0;
|
|
829
|
+
const checks = [
|
|
830
|
+
["asserted", isObj(a) && name(get(a, "model")) && name(get(a, "version"))
|
|
831
|
+
&& typeof get(a, "prompt_hash") === "string" && /^sha256:[0-9a-f]{64}$/.test(str(get(a, "prompt_hash")))],
|
|
832
|
+
["families", isObj(f) && (get(f, "reviewer") === null || FAMILIES.includes(str(get(f, "reviewer"))))
|
|
833
|
+
&& Array.isArray(get(f, "agent")) && list(get(f, "agent")).every((x) => typeof x === "string" && FAMILIES.includes(x))],
|
|
834
|
+
["window", isObj(w) && seconds(get(w, "grace_s")) && seconds(get(w, "skew_s"))],
|
|
835
|
+
];
|
|
836
|
+
const bad = [...Object.keys(VERDICT).filter((k) => has(v, k) && !VERDICT[k].includes(v[k])),
|
|
837
|
+
...checks.filter(([k, ok]) => has(v, k) && !ok).map(([k]) => k)];
|
|
838
|
+
return bad.length ? `its verdict's ${bad.join(", ")} is not what a signed finding holds` : null;
|
|
839
|
+
}
|
|
840
|
+
function checkReviews(rep, runs, pinned) {
|
|
841
|
+
const byId = new Map([...runs.values()].map((rs) => [get(rs[0].event, "run_id"), rs]));
|
|
842
|
+
const labels = [], problems = [], selfs = [], uncounted = [];
|
|
843
|
+
const hashesOf = new Map(); // analysed run id -> {run_seq: record hash}, built once
|
|
844
|
+
let total = 0;
|
|
845
|
+
for (const rs of runs.values()) {
|
|
846
|
+
const reviews = rs.map((r) => r.event).filter((e) => get(e, "type") === "review");
|
|
847
|
+
const signed = reviews.filter((e) => has(at(e, "data"), "cites"));
|
|
848
|
+
total += reviews.length;
|
|
849
|
+
extend(labels, Array(reviews.length - signed.length).fill("imported"));
|
|
850
|
+
if (!signed.length)
|
|
851
|
+
continue;
|
|
852
|
+
for (const e of signed) {
|
|
853
|
+
const why = verdictProblem(get(at(e, "data"), "verdict"));
|
|
854
|
+
if (why)
|
|
855
|
+
problems.push(`seq ${e.seq}: ${why}`);
|
|
856
|
+
}
|
|
857
|
+
const rid = cut(str(get(rs[0].event, "run_id")), 128), analyzes = about(rs)[0];
|
|
858
|
+
if (analyzes === null) {
|
|
859
|
+
extend(problems, signed.map((e) => `seq ${e.seq}: a signed finding outside a findings run`));
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const target = byId.get(analyzes);
|
|
863
|
+
if (!target) {
|
|
864
|
+
extend(labels, Array(signed.length).fill("author not checked"));
|
|
865
|
+
uncounted.push(`findings run ${rid}: run ${cut(str(analyzes), 128)} is not in the bundle`);
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (!hashesOf.has(analyzes))
|
|
869
|
+
hashesOf.set(analyzes, new Map(target.map((r) => [get(r.event, "run_seq"), r.hash])));
|
|
870
|
+
const hashes = hashesOf.get(analyzes);
|
|
871
|
+
for (const e of signed)
|
|
872
|
+
for (const c of list(at(e.data, "cites")))
|
|
873
|
+
if (hashes.get(at(c, "run_seq")) !== at(c, "hash"))
|
|
874
|
+
problems.push(`seq ${e.seq}: cites run_seq ${str(c.run_seq)} of run ${cut(str(analyzes), 128)}, `
|
|
875
|
+
+ (hashes.has(c.run_seq) ? "whose hash differs" : "which it does not have"));
|
|
876
|
+
const [mine, isolation] = author(rs), [theirs, theirIsolation] = author(target);
|
|
877
|
+
let label;
|
|
878
|
+
if (mine === theirs || [isolation, theirIsolation].includes("same-user")) {
|
|
879
|
+
label = "self-asserted";
|
|
880
|
+
selfs.push(`findings run ${rid}: ` + (mine === theirs
|
|
881
|
+
? `its author ${cut(str(mine), 256)} is the analysed run's identity`
|
|
882
|
+
: "the signer runs as the same user as a client (signer_isolation same-user)"));
|
|
883
|
+
}
|
|
884
|
+
else
|
|
885
|
+
label = pinned.includes(mine) ? "independent" : `unpinned author ${cut(str(mine), 256)}`;
|
|
886
|
+
extend(labels, Array(signed.length).fill(label));
|
|
887
|
+
}
|
|
888
|
+
if (total) {
|
|
889
|
+
rep.check("reviews", !problems.length && !selfs.length, `${total} review(s): `
|
|
890
|
+
+ count(labels).map(([k, n]) => `${n} ${k}`).join(", "), (problems.length ? problems : selfs).slice(0, 20), !problems.length);
|
|
891
|
+
const verdicts = [...runs.values()].flatMap((rs) => rs.map((r) => r.event)) // signed findings only
|
|
892
|
+
.filter((e) => get(e, "type") === "review" && has(at(e, "data"), "cites")).map((e) => get(e.data, "verdict"));
|
|
893
|
+
const sev = count(verdicts.filter((v) => isObj(v) && get(v, "kind") === "finding")
|
|
894
|
+
.map((v) => cut(str(get(v, "severity")), 16)));
|
|
895
|
+
const n = sev.reduce((a, [, k]) => a + k, 0), cov = verdicts.filter((v) => isObj(v) && get(v, "kind") === "coverage").length;
|
|
896
|
+
rep.findings = `${n} finding(s)` + (n ? " (" + SEVERITIES.flatMap((s) => sev.filter(([k]) => k === s))
|
|
897
|
+
.map(([k, c]) => `${c} ${k}`).join(", ") + ")" : "") + (cov ? ` and ${cov} coverage review(s)` : "") + "; "
|
|
898
|
+
+ count(labels).map(([k, c]) => `${c} ${k}`).join(", ") + " (an opinion of the run, not its integrity)";
|
|
899
|
+
}
|
|
900
|
+
if (uncounted.length)
|
|
901
|
+
rep.check("reviews counted", false, `${labels.filter((l) => l === "author not checked").length} signed finding(s) not `
|
|
902
|
+
+ "counted: export the analysed run with `tracekit export --v2 --run <run> --with-findings` to check their citations", uncounted.slice(0, 20), true);
|
|
903
|
+
}
|
|
904
|
+
/** The `constraints` line, as verify/v2.py's _constraints: live constraints added, and the calls they held, each an
|
|
905
|
+
* ask after one in its run. */
|
|
906
|
+
function checkConstraints(rep, runs) {
|
|
907
|
+
let added = 0, held = 0;
|
|
908
|
+
const problems = [];
|
|
909
|
+
for (const rs of runs.values()) {
|
|
910
|
+
let seen = false;
|
|
911
|
+
for (const r of rs) {
|
|
912
|
+
const e = r.event;
|
|
913
|
+
if (get(e, "type") === "constraint.added") {
|
|
914
|
+
added++;
|
|
915
|
+
seen = true;
|
|
916
|
+
}
|
|
917
|
+
else if (get(e, "type") === "policy.decision" && Array.isArray(get(at(e, "data"), "rule_ids"))
|
|
918
|
+
&& get(e.data, "rule_ids").includes("TK-CONSTRAINT")) {
|
|
919
|
+
held++;
|
|
920
|
+
const decision = get(e.data, "decision");
|
|
921
|
+
if (!seen || decision !== "ask")
|
|
922
|
+
problems.push(`seq ${e.seq}: a ${cut(str(decision), 16)} held by TK-CONSTRAINT `
|
|
923
|
+
+ (!seen ? "before any constraint.added in its run" : "is no ask"));
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (added || held)
|
|
928
|
+
rep.check("constraints", !problems.length, `${added} live constraint(s) added; ${held} call(s) held by them`, problems.slice(0, 20));
|
|
929
|
+
}
|
|
606
930
|
/** The `incidents` line, as verify/v2.py's _incidents: cited records, incidents recomputed from their run under the
|
|
607
931
|
* bundled compiled policy (`policies`, by policy_hash), responses honoured. */
|
|
608
932
|
function checkIncidents(rep, runs, policies, floats) {
|
|
@@ -671,7 +995,7 @@ function checkIncidents(rep, runs, policies, floats) {
|
|
|
671
995
|
}
|
|
672
996
|
}
|
|
673
997
|
}
|
|
674
|
-
problems
|
|
998
|
+
extend(problems, honoured(events, pointers));
|
|
675
999
|
}
|
|
676
1000
|
const n = opened.size + [...pointers].filter((p) => !opened.has(p)).length;
|
|
677
1001
|
rep.check("incidents", !problems.length, `${n} incident(s): ${checked} checked against the bundle, ${n - checked} `
|
|
@@ -679,7 +1003,7 @@ function checkIncidents(rep, runs, policies, floats) {
|
|
|
679
1003
|
if (unbundled.size)
|
|
680
1004
|
rep.check("incident policies", false, `${unbundled.size} cited policy(ies) not in the bundle: their incidents are `
|
|
681
1005
|
+ "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);
|
|
1006
|
+
+ "records cite)", [...unbundled].sort(byCodePoint).slice(0, 20), true);
|
|
683
1007
|
}
|
|
684
1008
|
/** The problems of one run's records with the holds and quarantines they record (verify/v2.py's _honoured). */
|
|
685
1009
|
function honoured(events, pointers) {
|
|
@@ -692,7 +1016,7 @@ function honoured(events, pointers) {
|
|
|
692
1016
|
for (const e of events) {
|
|
693
1017
|
const t = e.type, d = e.data, where = `seq ${e.seq}`, on = quarantined || held.join(" and ");
|
|
694
1018
|
if (t === "policy.decision" && due.size && !quarantined) { // an enforced response is written before the next call
|
|
695
|
-
problems
|
|
1019
|
+
extend(problems, unfollowed());
|
|
696
1020
|
due.clear();
|
|
697
1021
|
}
|
|
698
1022
|
if (t === "incident.opened" && d.scope.kind === "run" && d.scope.id === str(e.run_id).slice(0, 256)
|
|
@@ -736,7 +1060,7 @@ function honoured(events, pointers) {
|
|
|
736
1060
|
+ "approved by a person for it");
|
|
737
1061
|
}
|
|
738
1062
|
if (!quarantined)
|
|
739
|
-
problems
|
|
1063
|
+
extend(problems, unfollowed());
|
|
740
1064
|
return problems;
|
|
741
1065
|
}
|
|
742
1066
|
/** §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,
|
|
@@ -782,7 +1106,10 @@ export async function verifyRecord(r, keys, algs) {
|
|
|
782
1106
|
async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size, runs, included, checkRecord, floats) {
|
|
783
1107
|
const problems = [], tsalt = b64decode(at(rs, "tenant_salt"));
|
|
784
1108
|
const lo = at(rs, "from"), hi = at(rs, "to"), regOrigin = await registryOrigin(origin, tsalt);
|
|
785
|
-
|
|
1109
|
+
// a bound as the reference prints it: 2.0 when its token had a fraction or exponent
|
|
1110
|
+
// lean: integral floats below 1e16 only; Python's repr of others (1e+16, 1e-05) differs from JavaScript's
|
|
1111
|
+
const bound = (k, v) => floats.get(rs)?.has(k) && Number.isInteger(v) && Math.abs(v) < 1e16 ? `${v}.0` : str(v);
|
|
1112
|
+
const span = `registry ${bound("from", lo)}..${bound("to", hi)}`;
|
|
786
1113
|
if (![lo, hi].every((n) => Number.isSafeInteger(n)) || floats.get(rs)?.has("from") || floats.get(rs)?.has("to")) {
|
|
787
1114
|
rep.check("run-set", false, `INCOMPLETE, ${span}`, ["the registry range is not two integers"]);
|
|
788
1115
|
return [[], -1, []];
|
|
@@ -798,7 +1125,7 @@ async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size,
|
|
|
798
1125
|
for (const n of [...new Set([num(lo), num(hi)])].filter((n) => n !== 0).sort((a, b) => a - b)) {
|
|
799
1126
|
let got;
|
|
800
1127
|
try {
|
|
801
|
-
const note = await openNote(file(`checkpoints/registry-${n}.note`), vkeys, [...witnesses.keys()]);
|
|
1128
|
+
const note = await openNote(file(`checkpoints/registry-${n}.note`), vkeys, [...witnesses.keys()], trust.logs.length > 0);
|
|
802
1129
|
got = note.size;
|
|
803
1130
|
roots.set(n, note.root);
|
|
804
1131
|
regCosigs.push(note.cosigs);
|
|
@@ -877,7 +1204,7 @@ async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size,
|
|
|
877
1204
|
problems.push(`${untargeted} runs in the bundle, but only the selected run may be in no leaf of the range`);
|
|
878
1205
|
if (truthy(closed) && size > num(at(at(closed, "data"), "final_seq")) + 1)
|
|
879
1206
|
problems.push(`records after log.closed at seq ${str(at(closed, "seq"))}`);
|
|
880
|
-
const counted = [...gaps].sort(
|
|
1207
|
+
const counted = [...gaps].sort(byKey).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
881
1208
|
const named = tenant === null ? "" : ` of tenant ${repr(cut(tenant, 64))}`;
|
|
882
1209
|
const open = [...registered.keys()].filter((k) => !finals.has(k)).length;
|
|
883
1210
|
rep.check("run-set", !problems.length, !problems.length ? `COMPLETE, ${span}${named} (${registered.size} runs registered, `
|
|
@@ -888,6 +1215,43 @@ async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size,
|
|
|
888
1215
|
: `records after tree size ${size} are unproven (no log.closed in the range)`, [], true);
|
|
889
1216
|
return [retired, lo === 0 ? top : -1, regCosigs];
|
|
890
1217
|
}
|
|
1218
|
+
/** A run's part: the run's file name, the part's number (tracekit/bundle_v2.py PART). */
|
|
1219
|
+
const PART = /^(runs\/.+)\.part-([0-9]+)\.jsonl$/;
|
|
1220
|
+
/** {run file: its entries in order} of a bundle's `names`: the run's file, runs/<id>.jsonl, or its parts,
|
|
1221
|
+
* runs/<id>.part-0001.jsonl, ..., whose records read in that order are the run's (format-v2.md §9). */
|
|
1222
|
+
export function runFiles(names) {
|
|
1223
|
+
const out = new Map();
|
|
1224
|
+
for (const n of [...names].filter((x) => x.startsWith("runs/")).sort()) {
|
|
1225
|
+
const m = PART.exec(n), run = m ? `${m[1]}.jsonl` : n;
|
|
1226
|
+
if (!out.has(run))
|
|
1227
|
+
out.set(run, []);
|
|
1228
|
+
out.get(run).push(n);
|
|
1229
|
+
}
|
|
1230
|
+
return out;
|
|
1231
|
+
}
|
|
1232
|
+
/** What is wrong with the parts of `run` (runFiles): `names`, its files in order, and `pieces`, their records. A run in
|
|
1233
|
+
* one file has none; one in parts has part-0001 to part-n and no other file, each holding records, each one's first
|
|
1234
|
+
* record the successor of the last of the part before it. */
|
|
1235
|
+
function parted(run, names, pieces) {
|
|
1236
|
+
if (names.length === 1 && names[0] === run)
|
|
1237
|
+
return [];
|
|
1238
|
+
const stem = run.slice(0, -".jsonl".length), digits = names.filter((n) => n !== run).map((n) => [n, PART.exec(n)[2]]);
|
|
1239
|
+
const numbered = new Set(digits.filter(([, d]) => d.length === 4 && d !== "0000").map(([, d]) => Number(d)));
|
|
1240
|
+
const gaps = [];
|
|
1241
|
+
for (let i = 1; i < Math.max(0, ...numbered); i++)
|
|
1242
|
+
if (!numbered.has(i))
|
|
1243
|
+
gaps.push(i);
|
|
1244
|
+
const held = names.map((n, i) => [n, pieces[i]]).filter(([, p]) => p.length);
|
|
1245
|
+
return [
|
|
1246
|
+
...names.includes(run) ? [`${run} and ${names[1]}: one run in one file and in parts`] : [],
|
|
1247
|
+
...digits.filter(([, d]) => d.length !== 4 || d === "0000").map(([n]) => `${n}: not a part's name (part-0001, part-0002, ...)`),
|
|
1248
|
+
...gaps.length ? [`${stem}.part-${String(gaps[0]).padStart(4, "0")}.jsonl is missing`
|
|
1249
|
+
+ (gaps.length > 1 ? `, and ${gaps.length - 1} more part(s)` : "")] : [],
|
|
1250
|
+
...names.filter((_, i) => !pieces[i].length).map((n) => `${n} holds no records`),
|
|
1251
|
+
...held.slice(1).flatMap(([b, y], i) => same(get(at(y[0], "event"), "run_prev_hash"), at(held[i][1].at(-1), "hash"))
|
|
1252
|
+
? [] : [`${b} does not continue ${held[i][0]}`]),
|
|
1253
|
+
];
|
|
1254
|
+
}
|
|
891
1255
|
const runName = async (tenant, runId) => `runs/${(await sha256hex(bytes(`${str(tenant)}/${str(runId)}`))).slice(0, 32)}.jsonl`;
|
|
892
1256
|
const LEVELS = ["dev", "local", "witnessed"];
|
|
893
1257
|
function level(cosigs, witnesses, trust) {
|
|
@@ -912,19 +1276,22 @@ async function assurance(origin, cosigs, witnesses, trust, algs, selfApproved, r
|
|
|
912
1276
|
capped = null;
|
|
913
1277
|
const cosigned = [...cosigs].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)
|
|
914
1278
|
.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"} `
|
|
1279
|
+
return `${lvl}; records ${[...algs].sort(byCodePoint).join("+")}; checkpoint ${hybrid ? "Ed25519 + SLH-DSA-SHA2-128s" : "Ed25519 only"} `
|
|
916
1280
|
+ `(${origin})` + (cosigned ? `; cosigned ed25519 by ${cosigned}` : "; no witness cosignature")
|
|
917
1281
|
+ (times.length ? `; earliest independent anchor ${fmt(times.reduce((a, b) => b < a ? b : a))}` : "")
|
|
918
1282
|
+ (capped ? `; capped by the run-set's registry notes (${capped})` : "") + (selfApproved ? "; approvals: self" : "");
|
|
919
1283
|
}
|
|
920
|
-
/**
|
|
1284
|
+
/** `x` without control characters and the bidirectional and zero-width format characters that would reorder or hide
|
|
1285
|
+
* the text around them, as the report prints bundle-derived strings. */
|
|
1286
|
+
export const clean = (x) => x.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "");
|
|
1287
|
+
/** The report as text, as the reference CLI prints it; `clean` drops characters from bundle-derived strings. */
|
|
921
1288
|
export function formatReport(rep) {
|
|
922
|
-
const clean = (x) => x.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
923
1289
|
let s = "";
|
|
924
1290
|
for (const c of rep.checks) {
|
|
925
1291
|
s += `[${c.status.toUpperCase()}] ${c.check}` + (c.detail ? ` — ${clean(c.detail)}` : "") + "\n";
|
|
926
1292
|
for (const p of c.problems)
|
|
927
1293
|
s += ` ${clean(p)}\n`;
|
|
928
1294
|
}
|
|
929
|
-
return s + `\nIntegrity: ${clean(rep.integrity)}.\nAssurance: ${clean(rep.assurance)}.\n
|
|
1295
|
+
return s + `\nIntegrity: ${clean(rep.integrity)}.\nAssurance: ${clean(rep.assurance)}.\n`
|
|
1296
|
+
+ (rep.findings ? `Findings: ${clean(rep.findings)}.\n` : "");
|
|
930
1297
|
}
|