@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.html
ADDED
|
@@ -0,0 +1,2799 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-pRfyVCb1BGcA2IqTivZH4aPvN/utDHN3END16k0qCkg='; style-src 'sha256-hGOb6corVyDqUYHluhAwCLbaaMKqsxeEGQkVPYNw5lA='; worker-src blob:; connect-src 'none'">
|
|
7
|
+
<title>tracekit verify</title>
|
|
8
|
+
<style>
|
|
9
|
+
main { overflow-wrap: anywhere; }
|
|
10
|
+
pre { white-space: pre-wrap; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<main id="drop">
|
|
15
|
+
<h1>tracekit verify</h1>
|
|
16
|
+
<p><label for="bundle">bundle (.tkb):</label> <input type="file" id="bundle" accept=".tkb,.zip,.json" multiple>
|
|
17
|
+
<b id="bundle-name">none chosen</b><br>
|
|
18
|
+
<label for="trust">trust.json (optional):</label> <input type="file" id="trust" accept=".json"> <b id="trust-name">none</b>
|
|
19
|
+
<button type="button" id="clear-trust" hidden>remove</button><br>
|
|
20
|
+
samples: <button type="button" id="sample-clean">clean</button> <button type="button" id="sample-tampered">tampered</button></p>
|
|
21
|
+
<hr>
|
|
22
|
+
<p id="busy" hidden role="status">Verifying...</p>
|
|
23
|
+
<section id="result" hidden aria-live="polite">
|
|
24
|
+
<div id="banner"><h2 id="verdict-word"></h2><p id="verdict-means"></p><p id="verdict-not"></p></div>
|
|
25
|
+
<div id="next" hidden><h3>what to do next</h3><ol id="steps"></ol></div>
|
|
26
|
+
<div id="summary-box" hidden><h3 id="summary-title"></h3><dl id="summary"></dl></div>
|
|
27
|
+
<h3>checks</h3>
|
|
28
|
+
<div id="checks"></div>
|
|
29
|
+
<p id="integrity"></p>
|
|
30
|
+
<p id="assurance"></p>
|
|
31
|
+
<p id="exit"></p>
|
|
32
|
+
<p><button type="button" id="copy">copy report</button> <button type="button" id="download">download JSON</button>
|
|
33
|
+
<span id="copied"></span></p>
|
|
34
|
+
<details><summary>report as text</summary><pre id="report"></pre></details>
|
|
35
|
+
<hr>
|
|
36
|
+
</section>
|
|
37
|
+
<p><small>tracekit <span id="version"></span> · same checks as <code>tracekit verify</code> · files never leave this
|
|
38
|
+
page</small></p>
|
|
39
|
+
</main>
|
|
40
|
+
<script id="verifier" type="module">const SAMPLES = {"sample.tkb":"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","sample-tampered.tkb":"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","sample.trust.json":"ewogImxvZ3MiOiBbCiAgInRyYWNla2l0LmxvY2FsLzRkYTFjZDk2MjAzYjM0ZDc5YmYwMmI4Mjk2ODM3NzA0KzI4NjEwNDI4K0FXaTVIMGFSWGpYQmRuSElwektXcTJXeWZsM2h5R0RhRTlsRHVBMzByY0hwIgogXSwKICJ3aXRuZXNzZXMiOiBbXSwKICJhbGdzIjogWwogICJlZDI1NTE5IgogXSwKICJ3aXRuZXNzZXNfcmVxdWlyZWQiOiAwCn0="};
|
|
41
|
+
const __jcs = (() => {
|
|
42
|
+
/**
|
|
43
|
+
* JCS (RFC 8785) and the strict parser of docs/format-v2.md §1, byte for byte (tests/vectors/jcs.jsonl).
|
|
44
|
+
* Strict: bytes that are not UTF-8, duplicate keys, NaN/±Infinity (and overflow such as 1e400), lone surrogates and
|
|
45
|
+
* integer tokens (no `.` or exponent) outside ±(2^53-1) are refused, and so are arrays and objects nested deeper than
|
|
46
|
+
* MAX_DEPTH. No node: imports: browsers load this too.
|
|
47
|
+
*/
|
|
48
|
+
/** A string as Python's repr() writes it, as the reference verifier's messages quote names and keys. */
|
|
49
|
+
function repr(s) {
|
|
50
|
+
const q = s.includes("'") && !s.includes('"') ? '"' : "'";
|
|
51
|
+
const named = { "\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t", [q]: "\\" + q };
|
|
52
|
+
// lean: control characters only; Python also escapes other code points it can't print (U+0080..U+009F, U+2028...)
|
|
53
|
+
return q + s.replace(/[\\'"\x00-\x1f\x7f]/g, (c) => named[c] ?? (c === "'" || c === '"' ? c
|
|
54
|
+
: "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))) + q;
|
|
55
|
+
}
|
|
56
|
+
/** `code` is one of: invalid_utf8, syntax, duplicate_key, non_finite, int_range, lone_surrogate, depth. */
|
|
57
|
+
class StrictJSONError extends Error {
|
|
58
|
+
code;
|
|
59
|
+
constructor(code, message) {
|
|
60
|
+
super(`${code}: ${message}`);
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const WS = /[ \t\n\r]*/y;
|
|
65
|
+
const ESC = /\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/y;
|
|
66
|
+
const NUM = /-?(?:0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/y;
|
|
67
|
+
const MAX_DEPTH = 128;
|
|
68
|
+
const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
69
|
+
function strictParse(text, floats) {
|
|
70
|
+
let i = 0;
|
|
71
|
+
let float = false; // the last value read was a number token with a fraction or exponent
|
|
72
|
+
const mark = (o, k) => {
|
|
73
|
+
if (!float || !floats)
|
|
74
|
+
return;
|
|
75
|
+
if (!floats.has(o))
|
|
76
|
+
floats.set(o, new Set());
|
|
77
|
+
floats.get(o).add(k);
|
|
78
|
+
};
|
|
79
|
+
const fail = (code, msg) => { throw new StrictJSONError(code, msg); };
|
|
80
|
+
const match = (re) => {
|
|
81
|
+
re.lastIndex = i;
|
|
82
|
+
const m = re.exec(text);
|
|
83
|
+
if (m)
|
|
84
|
+
i = re.lastIndex;
|
|
85
|
+
return m;
|
|
86
|
+
};
|
|
87
|
+
const str = () => {
|
|
88
|
+
const start = i;
|
|
89
|
+
if (text[i++] !== '"')
|
|
90
|
+
fail("syntax", `string expected at ${start}`);
|
|
91
|
+
for (let c; (c = text.charCodeAt(i)) !== 0x22;) {
|
|
92
|
+
if (c === 0x5c)
|
|
93
|
+
match(ESC) ?? fail("syntax", `bad escape at ${i}`);
|
|
94
|
+
else if (c < 0x20 || Number.isNaN(c))
|
|
95
|
+
fail("syntax", `unterminated string at ${start}`);
|
|
96
|
+
else
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
const raw = text.slice(start, ++i), s = JSON.parse(raw);
|
|
100
|
+
if (LONE.test(s))
|
|
101
|
+
fail("lone_surrogate", raw.slice(0, 80));
|
|
102
|
+
return s;
|
|
103
|
+
};
|
|
104
|
+
const expect = (c) => {
|
|
105
|
+
match(WS);
|
|
106
|
+
if (text[i++] !== c)
|
|
107
|
+
fail("syntax", `${c} expected at ${i - 1}`);
|
|
108
|
+
};
|
|
109
|
+
const value = (depth) => {
|
|
110
|
+
float = false;
|
|
111
|
+
match(WS);
|
|
112
|
+
const c = text[i];
|
|
113
|
+
if ((c === "{" || c === "[") && depth === MAX_DEPTH)
|
|
114
|
+
fail("depth", `nesting deeper than ${MAX_DEPTH}`);
|
|
115
|
+
if (c === "{") {
|
|
116
|
+
i++;
|
|
117
|
+
const o = {};
|
|
118
|
+
match(WS);
|
|
119
|
+
if (text[i] === "}")
|
|
120
|
+
return i++, o;
|
|
121
|
+
do {
|
|
122
|
+
match(WS);
|
|
123
|
+
const k = str();
|
|
124
|
+
if (Object.hasOwn(o, k))
|
|
125
|
+
fail("duplicate_key", repr(k));
|
|
126
|
+
expect(":");
|
|
127
|
+
Object.defineProperty(o, k, { value: value(depth + 1), enumerable: true, writable: true, configurable: true }); // "__proto__" too
|
|
128
|
+
mark(o, k);
|
|
129
|
+
match(WS);
|
|
130
|
+
} while (text[i] === "," && ++i);
|
|
131
|
+
expect("}");
|
|
132
|
+
float = false;
|
|
133
|
+
return o;
|
|
134
|
+
}
|
|
135
|
+
if (c === "[") {
|
|
136
|
+
i++;
|
|
137
|
+
const a = [];
|
|
138
|
+
match(WS);
|
|
139
|
+
if (text[i] === "]")
|
|
140
|
+
return i++, a;
|
|
141
|
+
do
|
|
142
|
+
a.push(value(depth + 1)), mark(a, a.length - 1);
|
|
143
|
+
while ((match(WS), text[i] === ",") && ++i);
|
|
144
|
+
expect("]");
|
|
145
|
+
float = false;
|
|
146
|
+
return a;
|
|
147
|
+
}
|
|
148
|
+
if (c === '"')
|
|
149
|
+
return str();
|
|
150
|
+
for (const [lit, v] of [["true", true], ["false", false], ["null", null]])
|
|
151
|
+
if (text.startsWith(lit, i))
|
|
152
|
+
return (i += lit.length), v;
|
|
153
|
+
for (const lit of ["NaN", "Infinity", "-Infinity"])
|
|
154
|
+
if (text.startsWith(lit, i))
|
|
155
|
+
fail("non_finite", lit);
|
|
156
|
+
const m = match(NUM) ?? fail("syntax", `value expected at ${i}`);
|
|
157
|
+
const n = Number(m[0]);
|
|
158
|
+
if (!Number.isFinite(n))
|
|
159
|
+
fail("non_finite", m[0]);
|
|
160
|
+
if (!m[1] && !m[2] && !Number.isSafeInteger(n))
|
|
161
|
+
fail("int_range", m[0]);
|
|
162
|
+
float = !!(m[1] || m[2]);
|
|
163
|
+
return n;
|
|
164
|
+
};
|
|
165
|
+
const v = value(0);
|
|
166
|
+
match(WS);
|
|
167
|
+
if (i < text.length)
|
|
168
|
+
fail("syntax", `extra data at ${i}`);
|
|
169
|
+
return v;
|
|
170
|
+
}
|
|
171
|
+
/** JCS text of a JSON value; StrictJSONError for anything JCS can't encode. */
|
|
172
|
+
function canonicalize(v) {
|
|
173
|
+
if (typeof v === "string") {
|
|
174
|
+
if (LONE.test(v))
|
|
175
|
+
throw new StrictJSONError("lone_surrogate", JSON.stringify(v));
|
|
176
|
+
return JSON.stringify(v);
|
|
177
|
+
}
|
|
178
|
+
if (typeof v === "number") {
|
|
179
|
+
if (!Number.isFinite(v))
|
|
180
|
+
throw new StrictJSONError("non_finite", String(v));
|
|
181
|
+
return JSON.stringify(v); // ECMAScript Number::toString, -0 as 0: what JCS specifies
|
|
182
|
+
}
|
|
183
|
+
if (v === null || typeof v === "boolean")
|
|
184
|
+
return String(v);
|
|
185
|
+
if (Array.isArray(v))
|
|
186
|
+
return `[${v.map(canonicalize).join(",")}]`;
|
|
187
|
+
if (typeof v === "object") {
|
|
188
|
+
const o = v;
|
|
189
|
+
return `{${Object.keys(o).sort().map((k) => `${canonicalize(k)}:${canonicalize(o[k])}`).join(",")}}`; // UTF-16 order
|
|
190
|
+
}
|
|
191
|
+
throw new StrictJSONError("syntax", `${typeof v} is not JSON`);
|
|
192
|
+
}
|
|
193
|
+
/** strictParse of UTF-8 bytes (a byte order mark is not whitespace). */
|
|
194
|
+
function strictParseBytes(data, floats) {
|
|
195
|
+
let text;
|
|
196
|
+
try {
|
|
197
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new StrictJSONError("invalid_utf8", "not UTF-8");
|
|
201
|
+
}
|
|
202
|
+
return strictParse(text, floats);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { MAX_DEPTH, StrictJSONError, canonicalize, repr, strictParse, strictParseBytes };
|
|
206
|
+
})();
|
|
207
|
+
const __primitives = (() => {
|
|
208
|
+
/**
|
|
209
|
+
* Hashes, base64, strict Ed25519 (docs/format-v2.md §4), RFC 9162 proofs (§5), C2SP checkpoint notes (§6) and registry
|
|
210
|
+
* leaves (§7), on Web Crypto only.
|
|
211
|
+
*/
|
|
212
|
+
const subtle = globalThis.crypto.subtle;
|
|
213
|
+
const utf8 = new TextEncoder();
|
|
214
|
+
const bytes = (s) => utf8.encode(s);
|
|
215
|
+
const concat = (...parts) => {
|
|
216
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
217
|
+
let at = 0;
|
|
218
|
+
for (const p of parts)
|
|
219
|
+
out.set(p, (at += p.length) - p.length);
|
|
220
|
+
return out;
|
|
221
|
+
};
|
|
222
|
+
const equal = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
223
|
+
const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
224
|
+
function unhex(s) {
|
|
225
|
+
if (!/^(?:[0-9a-fA-F]{2})*$/.test(s))
|
|
226
|
+
throw new Error(`not hex: ${s.slice(0, 80)}`);
|
|
227
|
+
return Uint8Array.from(s.match(/../g) ?? [], (x) => parseInt(x, 16));
|
|
228
|
+
}
|
|
229
|
+
async function sha256(...parts) {
|
|
230
|
+
return new Uint8Array(await subtle.digest("SHA-256", concat(...parts)));
|
|
231
|
+
}
|
|
232
|
+
const sha256hex = async (data) => hex(await sha256(data));
|
|
233
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
234
|
+
function b64encode(data) {
|
|
235
|
+
let s = "";
|
|
236
|
+
for (let i = 0; i < data.length; i += 3) {
|
|
237
|
+
const n = (data[i] << 16) | ((data[i + 1] ?? 0) << 8) | (data[i + 2] ?? 0);
|
|
238
|
+
s += B64[n >> 18] + B64[(n >> 12) & 63] + (i + 1 < data.length ? B64[(n >> 6) & 63] : "=")
|
|
239
|
+
+ (i + 2 < data.length ? B64[n & 63] : "=");
|
|
240
|
+
}
|
|
241
|
+
return s;
|
|
242
|
+
}
|
|
243
|
+
/** Standard base64: only the alphabet, then at most two `=`, padded to whole quads (padding after a whole quad is
|
|
244
|
+
* tolerated); bits past the last byte are ignored. */
|
|
245
|
+
function b64decode(s) {
|
|
246
|
+
const m = typeof s === "string" ? /^([A-Za-z0-9+/]*)(={0,2})$/.exec(s) : null;
|
|
247
|
+
const r = m ? m[1].length % 4 : 1;
|
|
248
|
+
if (!m || r === 1 || (r && m[1].length % 4 + m[2].length < 4))
|
|
249
|
+
throw new Error("bad base64");
|
|
250
|
+
const out = [];
|
|
251
|
+
let acc = 0, bits = 0;
|
|
252
|
+
for (const c of m[1]) {
|
|
253
|
+
acc = (acc << 6) | B64.indexOf(c);
|
|
254
|
+
if ((bits += 6) >= 8)
|
|
255
|
+
out.push((acc >> (bits -= 8)) & 255);
|
|
256
|
+
}
|
|
257
|
+
return Uint8Array.from(out);
|
|
258
|
+
}
|
|
259
|
+
/** Canonical base64 only (checkpoint notes and vkeys). */
|
|
260
|
+
function unb64(s) {
|
|
261
|
+
const raw = b64decode(s);
|
|
262
|
+
if (b64encode(raw) !== s)
|
|
263
|
+
throw new Error("non-canonical base64");
|
|
264
|
+
return raw;
|
|
265
|
+
}
|
|
266
|
+
// Ed25519: S < L, a canonical public key (y < p) that is not of small order, then Web Crypto's cofactorless check
|
|
267
|
+
const L = (1n << 252n) + 27742317777372353535851937790883648493n;
|
|
268
|
+
const P = (1n << 255n) - 19n;
|
|
269
|
+
const SMALL_ORDER = new Set([
|
|
270
|
+
"0100000000000000000000000000000000000000000000000000000000000000",
|
|
271
|
+
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
|
|
272
|
+
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
273
|
+
"0000000000000000000000000000000000000000000000000000000000000080",
|
|
274
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
|
|
275
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85",
|
|
276
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
277
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
|
|
278
|
+
]);
|
|
279
|
+
const le = (b) => b.reduceRight((n, x) => (n << 8n) | BigInt(x), 0n);
|
|
280
|
+
async function ed25519Verify(pub, msg, sig) {
|
|
281
|
+
if (pub.length !== 32 || sig.length !== 64 || le(sig.subarray(32)) >= L)
|
|
282
|
+
return false;
|
|
283
|
+
const y = le(pub) & ((1n << 255n) - 1n);
|
|
284
|
+
// x = 0 with the sign bit set is no encoding at all; y = ±1 are the small-order points with x = 0
|
|
285
|
+
if (y >= P || SMALL_ORDER.has(hex(pub)) || (pub[31] & 0x80 && (y === 1n || y === P - 1n)))
|
|
286
|
+
return false;
|
|
287
|
+
try {
|
|
288
|
+
const key = await subtle.importKey("raw", pub, { name: "Ed25519" }, false, ["verify"]);
|
|
289
|
+
return await subtle.verify({ name: "Ed25519" }, key, sig, msg);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false; // not a point on the curve
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const ED25519_SPKI = unhex("302a300506032b6570032100");
|
|
296
|
+
/** The algorithm of a DER SubjectPublicKeyInfo, or null when it is not one this format defines. */
|
|
297
|
+
const keyAlg = (spki) => spki.length === 44 && equal(spki.subarray(0, 12), ED25519_SPKI) ? "ed25519" : null;
|
|
298
|
+
const spkiKid = async (spki) => "sha256:" + await sha256hex(spki);
|
|
299
|
+
const spkiOf = (pub) => concat(ED25519_SPKI, pub);
|
|
300
|
+
/** §4: false unless `alg` is the key's own algorithm and the signature holds under its v2 rules. */
|
|
301
|
+
const verifyV2 = async (alg, spki, msg, sig) => keyAlg(spki) === alg && ed25519Verify(spki.subarray(12), msg, sig);
|
|
302
|
+
// RFC 9162 Merkle trees; sizes are below 2^53, so halving is exact in a Number
|
|
303
|
+
const leafHash = (data) => sha256(Uint8Array.of(0), data);
|
|
304
|
+
const nodeHash = (l, r) => sha256(Uint8Array.of(1), l, r);
|
|
305
|
+
const odd = (n) => n % 2 === 1;
|
|
306
|
+
const half = (n) => Math.floor(n / 2);
|
|
307
|
+
/** RFC 9162 §2.1.3.2. */
|
|
308
|
+
async function verifyInclusion(index, size, leaf, proof, root) {
|
|
309
|
+
if (!(index >= 0 && index < size) || !root)
|
|
310
|
+
return false;
|
|
311
|
+
let fn = index, sn = size - 1, r = leaf;
|
|
312
|
+
for (const p of proof) {
|
|
313
|
+
if (sn === 0)
|
|
314
|
+
return false;
|
|
315
|
+
if (odd(fn) || fn === sn) {
|
|
316
|
+
r = await nodeHash(p, r);
|
|
317
|
+
if (!odd(fn))
|
|
318
|
+
while (fn && !odd(fn))
|
|
319
|
+
fn = half(fn), sn = half(sn);
|
|
320
|
+
}
|
|
321
|
+
else
|
|
322
|
+
r = await nodeHash(r, p);
|
|
323
|
+
fn = half(fn);
|
|
324
|
+
sn = half(sn);
|
|
325
|
+
}
|
|
326
|
+
return sn === 0 && equal(r, root);
|
|
327
|
+
}
|
|
328
|
+
/** RFC 9162 §2.1.4.2. */
|
|
329
|
+
async function verifyConsistency(first, second, firstRoot, secondRoot, proof) {
|
|
330
|
+
if (first === 0 || first > second)
|
|
331
|
+
return false;
|
|
332
|
+
if (first === second)
|
|
333
|
+
return !proof.length && equal(firstRoot, secondRoot);
|
|
334
|
+
let pow = first;
|
|
335
|
+
while (!odd(pow))
|
|
336
|
+
pow = half(pow);
|
|
337
|
+
const path = pow === 1 ? [firstRoot, ...proof] : proof; // a power of two: its root is the first proof node
|
|
338
|
+
if (!path.length)
|
|
339
|
+
return false;
|
|
340
|
+
let fn = first - 1, sn = second - 1;
|
|
341
|
+
while (odd(fn))
|
|
342
|
+
fn = half(fn), sn = half(sn);
|
|
343
|
+
let fr = path[0], sr = path[0];
|
|
344
|
+
for (const c of path.slice(1)) {
|
|
345
|
+
if (sn === 0)
|
|
346
|
+
return false;
|
|
347
|
+
if (odd(fn) || fn === sn) {
|
|
348
|
+
fr = await nodeHash(c, fr);
|
|
349
|
+
sr = await nodeHash(c, sr);
|
|
350
|
+
if (!odd(fn))
|
|
351
|
+
while (fn && !odd(fn))
|
|
352
|
+
fn = half(fn), sn = half(sn);
|
|
353
|
+
}
|
|
354
|
+
else
|
|
355
|
+
sr = await nodeHash(sr, c);
|
|
356
|
+
fn = half(fn);
|
|
357
|
+
sn = half(sn);
|
|
358
|
+
}
|
|
359
|
+
return sn === 0 && equal(fr, firstRoot) && equal(sr, secondRoot);
|
|
360
|
+
}
|
|
361
|
+
// C2SP signed notes: tlog-checkpoint, the log's type 0x01 line, witnesses' type 0x04 cosignatures, the hybrid 0xff line
|
|
362
|
+
const ED25519 = 0x01, COSIGNATURE = 0x04, HYBRID = 0xff;
|
|
363
|
+
const SLH_DSA = bytes("tracekit/slh-dsa-sha2-128s");
|
|
364
|
+
const MAX_NOTE = 1 << 20;
|
|
365
|
+
const DASH = "— ";
|
|
366
|
+
// Unicode whitespace as str.isspace sees it, besides the control characters a name can't hold anyway
|
|
367
|
+
const SPACE = /[\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
|
|
368
|
+
class NoteError extends Error {
|
|
369
|
+
}
|
|
370
|
+
function checkName(name) {
|
|
371
|
+
if (!name || name.includes("+") || bytes(name).length > 255 || SPACE.test(name) || /[\x00-\x1f]/.test(name))
|
|
372
|
+
throw new NoteError(`bad key name ${JSON.stringify(name.slice(0, 64))}`);
|
|
373
|
+
}
|
|
374
|
+
const keyId = async (name, type, pub) => (await sha256(bytes(name), Uint8Array.of(0x0a, type), pub)).subarray(0, 4);
|
|
375
|
+
async function vkey(name, type, pub) {
|
|
376
|
+
checkName(name);
|
|
377
|
+
return `${name}+${hex(await keyId(name, type, pub))}+${b64encode(concat(Uint8Array.of(type), pub))}`;
|
|
378
|
+
}
|
|
379
|
+
/** (name, key id, type, public key) of a vkey `name+hex id+base64(type ‖ key)`. */
|
|
380
|
+
async function parseVkey(text) {
|
|
381
|
+
const i = text.indexOf("+"), j = text.indexOf("+", i + 1);
|
|
382
|
+
if (i < 0 || j < 0)
|
|
383
|
+
throw new NoteError("a vkey is name+id+key");
|
|
384
|
+
const name = text.slice(0, i), hid = text.slice(i + 1, j);
|
|
385
|
+
checkName(name);
|
|
386
|
+
let raw;
|
|
387
|
+
try {
|
|
388
|
+
raw = unb64(text.slice(j + 1));
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
throw new NoteError(e.message);
|
|
392
|
+
}
|
|
393
|
+
if (!(raw.length === 33 && (raw[0] === ED25519 || raw[0] === COSIGNATURE)
|
|
394
|
+
|| raw.length === 1 + SLH_DSA.length + 32 && raw[0] === HYBRID && equal(raw.subarray(1, 1 + SLH_DSA.length), SLH_DSA)))
|
|
395
|
+
throw new NoteError("not an Ed25519 log, cosignature or SLH-DSA log vkey");
|
|
396
|
+
const id = await keyId(name, raw[0], raw.subarray(1));
|
|
397
|
+
if (hid !== hex(id))
|
|
398
|
+
throw new NoteError("vkey id does not match the key");
|
|
399
|
+
return { name, id, type: raw[0], pub: raw.subarray(1) };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Open a checkpoint note with pinned vkeys: a pinned Ed25519 log key named after the origin must sign it, and each
|
|
403
|
+
* pinned hybrid key of the origin must have a line. Web Crypto has no SLH-DSA, so a hybrid line is left `unchecked`.
|
|
404
|
+
* `anchored` false (no trust config, so `logKeys` is empty): no log signature is required.
|
|
405
|
+
*/
|
|
406
|
+
async function openNote(data, logKeys, witnessKeys, anchored = true) {
|
|
407
|
+
if (data.length > MAX_NOTE)
|
|
408
|
+
throw new NoteError("note too large");
|
|
409
|
+
let note;
|
|
410
|
+
try {
|
|
411
|
+
note = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
throw new NoteError("note is not UTF-8");
|
|
415
|
+
}
|
|
416
|
+
if (!note.endsWith("\n") || /[\x00-\x09\x0b-\x1f]/.test(note))
|
|
417
|
+
throw new NoteError("malformed note");
|
|
418
|
+
const i = note.indexOf("\n\n");
|
|
419
|
+
if (i < 0)
|
|
420
|
+
throw new NoteError("no signature block");
|
|
421
|
+
const text = note.slice(0, i + 1), lines = note.slice(i + 2, -1).split("\n");
|
|
422
|
+
if (lines.length > 64)
|
|
423
|
+
throw new NoteError("too many signatures");
|
|
424
|
+
const head = text.slice(0, -1).split("\n");
|
|
425
|
+
if (head.length !== 3)
|
|
426
|
+
throw new NoteError("a checkpoint is origin, size and root");
|
|
427
|
+
const [origin, size] = head;
|
|
428
|
+
checkName(origin);
|
|
429
|
+
if (!/^(0|[1-9][0-9]{0,19})$/.test(size))
|
|
430
|
+
throw new NoteError("bad tree size");
|
|
431
|
+
let root;
|
|
432
|
+
try {
|
|
433
|
+
root = unb64(head[2]);
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
throw new NoteError(e.message);
|
|
437
|
+
}
|
|
438
|
+
if (root.length !== 32)
|
|
439
|
+
throw new NoteError("bad root hash");
|
|
440
|
+
const pinned = new Map();
|
|
441
|
+
for (const [keys, want] of [[logKeys, [ED25519, HYBRID]], [witnessKeys, [COSIGNATURE]]])
|
|
442
|
+
for (const k of keys) {
|
|
443
|
+
const p = await parseVkey(k);
|
|
444
|
+
if (!want.includes(p.type))
|
|
445
|
+
throw new NoteError(`pinned key ${p.name} has the wrong signature type`);
|
|
446
|
+
pinned.set(`${p.name}\n${hex(p.id)}`, [k, p]);
|
|
447
|
+
}
|
|
448
|
+
const hybrids = new Set([...pinned].filter(([, [, p]]) => p.type === HYBRID && p.name === origin).map(([id]) => id));
|
|
449
|
+
const seen = new Set(), cosigs = [], msg = bytes(text);
|
|
450
|
+
let logged = false, unchecked = false;
|
|
451
|
+
for (const line of lines) {
|
|
452
|
+
const parts = line.startsWith(DASH) ? line.slice(DASH.length).split(" ") : [];
|
|
453
|
+
if (parts.length !== 2)
|
|
454
|
+
throw new NoteError("malformed signature line");
|
|
455
|
+
const [name, blob] = parts;
|
|
456
|
+
checkName(name);
|
|
457
|
+
let raw;
|
|
458
|
+
try {
|
|
459
|
+
raw = unb64(blob);
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
throw new NoteError(e.message);
|
|
463
|
+
}
|
|
464
|
+
if (raw.length < 5)
|
|
465
|
+
throw new NoteError("short signature");
|
|
466
|
+
const id = `${name}\n${hex(raw.subarray(0, 4))}`;
|
|
467
|
+
if (seen.has(id))
|
|
468
|
+
throw new NoteError("two signatures from one key");
|
|
469
|
+
seen.add(id);
|
|
470
|
+
const key = pinned.get(id);
|
|
471
|
+
if (!key)
|
|
472
|
+
continue;
|
|
473
|
+
const [k, p] = key, sig = raw.subarray(4);
|
|
474
|
+
if (p.type === ED25519) {
|
|
475
|
+
if (!await verifyV2("ed25519", spkiOf(p.pub), msg, sig))
|
|
476
|
+
throw new NoteError(`bad signature from pinned key ${name}`);
|
|
477
|
+
logged ||= name === origin;
|
|
478
|
+
}
|
|
479
|
+
else if (p.type === HYBRID) {
|
|
480
|
+
unchecked = true;
|
|
481
|
+
hybrids.delete(id);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
const ts = sig.length === 72 ? new DataView(sig.buffer, sig.byteOffset).getBigUint64(0) : null;
|
|
485
|
+
if (ts === null || !await verifyV2("ed25519", spkiOf(p.pub), bytes(`cosignature/v1\ntime ${ts}\n${text}`), sig.subarray(8)))
|
|
486
|
+
throw new NoteError(`bad cosignature from pinned witness ${name}`);
|
|
487
|
+
cosigs.push([k, ts]);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (!logged && anchored)
|
|
491
|
+
throw new NoteError(`no signature from a pinned log key for origin ${origin}`);
|
|
492
|
+
if (hybrids.size)
|
|
493
|
+
throw new NoteError(`no SLH-DSA signature from the pinned hybrid key for origin ${origin}`);
|
|
494
|
+
return { origin, size: Number(size), root, cosigs, unchecked };
|
|
495
|
+
}
|
|
496
|
+
// registry leaves: type u8 ‖ H(tenant_salt ‖ run_id) 32B ‖ record log_id 16B ‖ seq u64 ‖ record hash 32B
|
|
497
|
+
const LEAF_TYPES = ["run.registered", "run.final", "log.closed", "key.retire", "capture.gap", "trace.tamper"];
|
|
498
|
+
const SIGNER_LEAVES = ["log.closed", "key.retire", "capture.gap", "trace.tamper"];
|
|
499
|
+
const registryOrigin = async (logOrigin, tsalt) => `${logOrigin}/registry/${(await sha256hex(concat(bytes("registry "), tsalt))).slice(0, 32)}`;
|
|
500
|
+
const runHash = (tsalt, runId) => sha256(tsalt, bytes(runId));
|
|
501
|
+
function parseLeaf(data) {
|
|
502
|
+
if (data.length !== 89 || !(data[0] >= 1 && data[0] <= LEAF_TYPES.length))
|
|
503
|
+
throw new Error("not a registry leaf");
|
|
504
|
+
return { type: LEAF_TYPES[data[0] - 1], runHash: data.subarray(1, 33), logId: hex(data.subarray(33, 49)),
|
|
505
|
+
seq: Number(new DataView(data.buffer, data.byteOffset).getBigUint64(49)), hash: "sha256:" + hex(data.subarray(57)) };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return { COSIGNATURE, ED25519, HYBRID, LEAF_TYPES, NoteError, SIGNER_LEAVES, SLH_DSA, b64decode, b64encode, bytes, concat, ed25519Verify, equal, hex, keyAlg, keyId, leafHash, openNote, parseLeaf, parseVkey, registryOrigin, runHash, sha256, sha256hex, spkiKid, spkiOf, unb64, unhex, verifyConsistency, verifyInclusion, verifyV2, vkey };
|
|
509
|
+
})();
|
|
510
|
+
const __detect = (() => {
|
|
511
|
+
const RUN_CAP = 100, TENANT_CAP = 60, RUN_SHARE = 20, IDLE_S = 3600, UPDATE_S = 60;
|
|
512
|
+
const NOT_COUNTED = ["TK-ARGS-INVALID", "TK-OVERSIZE", "TK-QUARANTINE"];
|
|
513
|
+
const SECTIONS = ["deny", "ask", "flag"];
|
|
514
|
+
const cut = (s, n) => [...s].slice(0, n).join("");
|
|
515
|
+
/** Seconds since the epoch of a record's `ts`, as Python's datetime.timestamp() computes it. */
|
|
516
|
+
function ts(t) {
|
|
517
|
+
const m = /^(.{19})(?:\.(\d{1,6}))?Z$/.exec(t);
|
|
518
|
+
if (!m)
|
|
519
|
+
throw new TypeError(`not a timestamp: ${t}`);
|
|
520
|
+
return (Date.parse(m[1] + "Z") / 1000 * 1e6 + Number((m[2] ?? "").padEnd(6, "0"))) / 1e6;
|
|
521
|
+
}
|
|
522
|
+
/** fnmatch.fnmatchcase's pattern as a RegExp: `*`, `?` and `[...]` (`[!...]` negated). */
|
|
523
|
+
function glob(pat) {
|
|
524
|
+
let out = "";
|
|
525
|
+
for (let i = 0; i < pat.length; i++) {
|
|
526
|
+
const c = pat[i], end = c === "[" ? pat.indexOf("]", i + 2) : -1;
|
|
527
|
+
if (c === "*")
|
|
528
|
+
out += "[\\s\\S]*";
|
|
529
|
+
else if (c === "?")
|
|
530
|
+
out += "[\\s\\S]";
|
|
531
|
+
else if (end > 0) {
|
|
532
|
+
const body = pat.slice(i + 1, end);
|
|
533
|
+
out += "[" + (body[0] === "!" ? "^" + body.slice(1) : body).replace(/\\/g, "\\\\") + "]";
|
|
534
|
+
i = end;
|
|
535
|
+
}
|
|
536
|
+
else
|
|
537
|
+
out += c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
538
|
+
}
|
|
539
|
+
return new RegExp(`^${out}$`, "u");
|
|
540
|
+
}
|
|
541
|
+
class Detector {
|
|
542
|
+
label = new Map();
|
|
543
|
+
untrusted = new Set();
|
|
544
|
+
deny = new Set();
|
|
545
|
+
sequences;
|
|
546
|
+
limits;
|
|
547
|
+
respond = new Map();
|
|
548
|
+
every = new Map();
|
|
549
|
+
idle;
|
|
550
|
+
tools;
|
|
551
|
+
constructor(policy) {
|
|
552
|
+
for (const sec of SECTIONS)
|
|
553
|
+
for (const r of policy[sec] ?? []) {
|
|
554
|
+
if (Object.hasOwn(r, "label"))
|
|
555
|
+
this.label.set(r.id, r.label);
|
|
556
|
+
if (Object.hasOwn(r, "from"))
|
|
557
|
+
this.untrusted.add(r.id);
|
|
558
|
+
}
|
|
559
|
+
for (const r of policy.deny ?? [])
|
|
560
|
+
this.deny.add(r.id);
|
|
561
|
+
if (policy.unknown_tools === "deny")
|
|
562
|
+
this.deny.add("TK-UNKNOWN-TOOL");
|
|
563
|
+
this.sequences = policy.sequences ?? [];
|
|
564
|
+
this.limits = policy.limits ?? [];
|
|
565
|
+
for (const r of policy.respond ?? [])
|
|
566
|
+
this.respond.set(r.trigger, r);
|
|
567
|
+
for (const x of [...this.sequences, ...this.limits])
|
|
568
|
+
this.every.set(x.id, x.window);
|
|
569
|
+
if (this.respond.has("denied_calls"))
|
|
570
|
+
this.every.set("denied_calls", this.respond.get("denied_calls").window);
|
|
571
|
+
this.idle = Math.max(IDLE_S, 0, ...this.every.values());
|
|
572
|
+
this.tools = policy.tools ?? {};
|
|
573
|
+
}
|
|
574
|
+
toolClass(tool) {
|
|
575
|
+
if (Object.hasOwn(this.tools, tool))
|
|
576
|
+
return this.tools[tool];
|
|
577
|
+
const hits = Object.keys(this.tools).filter((p) => glob(p).test(tool))
|
|
578
|
+
.map((p) => [-p.replace(/[*?]/g, "").length, p])
|
|
579
|
+
.sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
|
|
580
|
+
return hits.length ? this.tools[hits[0][1]] : "unknown";
|
|
581
|
+
}
|
|
582
|
+
names(ids) {
|
|
583
|
+
return new Set([...ids, ...ids.filter((i) => this.label.has(i)).map((i) => this.label.get(i)), "*"]);
|
|
584
|
+
}
|
|
585
|
+
on(trigger) {
|
|
586
|
+
return (this.respond.get(trigger)?.mode ?? "off") !== "off";
|
|
587
|
+
}
|
|
588
|
+
static state() {
|
|
589
|
+
return { runs: new Map(), scopes: new Map(), minute: new Map() };
|
|
590
|
+
}
|
|
591
|
+
scope(det, e, run, kind) {
|
|
592
|
+
if (kind === "principal" && !(run && run.principal))
|
|
593
|
+
kind = "owner";
|
|
594
|
+
if (!run)
|
|
595
|
+
kind = "run";
|
|
596
|
+
const val = cut(kind === "run" ? e.run_id : run[kind], 256), key = `${kind}\0${e.tenant}\0${val}`;
|
|
597
|
+
const old = det.scopes.get(key);
|
|
598
|
+
if (!old || ts(e.ts) - ts(old.t) > this.idle)
|
|
599
|
+
det.scopes.set(key, { seq: new Map(), lim: new Map(), den: null, open: new Map(), t: e.ts });
|
|
600
|
+
const st = det.scopes.get(key);
|
|
601
|
+
st.t = e.ts;
|
|
602
|
+
return [key, { kind, id: val }, st];
|
|
603
|
+
}
|
|
604
|
+
/** The incident records (type, data without ids) record `e` fires, after indexing it into `det`. */
|
|
605
|
+
observe(det, e) {
|
|
606
|
+
const typ = e.type, d = e.data, rk = `${e.tenant}\0${e.run_id}`, t = ts(e.ts), fired = [];
|
|
607
|
+
const run = det.runs.get(rk) ?? null;
|
|
608
|
+
if (typ === "run.registered") {
|
|
609
|
+
det.runs.set(rk, { session: d.session ?? e.run_id, owner: `${d.identity.scheme}:${d.identity.subject}`,
|
|
610
|
+
principal: e.principal_attested ? e.principal ?? null : null, pending: new Map(), n: new Map(),
|
|
611
|
+
capped: new Map(), minute: [0, 0, false] });
|
|
612
|
+
}
|
|
613
|
+
else if (typ === "run.final") {
|
|
614
|
+
det.runs.delete(rk);
|
|
615
|
+
det.scopes.delete("run\0" + rk);
|
|
616
|
+
}
|
|
617
|
+
else if (typ === "incident.opened" || typ === "incident.capped")
|
|
618
|
+
this.countIncident(det, e, run, t);
|
|
619
|
+
else if (typ === "policy.decision") {
|
|
620
|
+
const inp = d.detect ?? {}, ids = inp.rule_ids ?? d.rule_ids, cite = [e.run_id, e.run_seq];
|
|
621
|
+
const names = this.names(ids), unknown = this.toolClass(d.tool) === "unknown";
|
|
622
|
+
if (run) {
|
|
623
|
+
const k = `call\0${e.tool_call_id}`;
|
|
624
|
+
if (d.decision === "ask")
|
|
625
|
+
run.pending.set(k, [d.decision_id ?? null, [...names], unknown, inp.sums ?? {},
|
|
626
|
+
Object.hasOwn(inp, "rule_ids")]);
|
|
627
|
+
else
|
|
628
|
+
run.pending.delete(k);
|
|
629
|
+
}
|
|
630
|
+
if (d.decision === "allow" || d.decision === "flag") // detect.rule_ids: a client's class hint read it too
|
|
631
|
+
this.count(det, e, run, names, unknown, inp.sums ?? {}, Object.hasOwn(inp, "rule_ids"), cite, fired);
|
|
632
|
+
else if (d.decision === "deny")
|
|
633
|
+
this.denied(det, e, run, t, cite, fired);
|
|
634
|
+
if (d.decision !== "allow" && this.on("provenance") && d.rule_ids.some((i) => this.untrusted.has(i))
|
|
635
|
+
&& (Array.isArray(d.provenance) ? d.provenance.length : d.provenance) && d.provenance_state === "complete")
|
|
636
|
+
fired.push(["provenance", d.rule_ids, ...this.scope(det, e, run, "run").slice(0, 2), [cite]]);
|
|
637
|
+
}
|
|
638
|
+
else if (typ === "approval.request" && run) {
|
|
639
|
+
const k = `call\0${e.tool_call_id ?? null}`, p = run.pending.get(k);
|
|
640
|
+
if (p && p[0] === d.decision_id) {
|
|
641
|
+
run.pending.delete(k);
|
|
642
|
+
run.pending.set(d.approval_id, p.slice(1));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
else if (["approval.consumed", "approval.abandoned", "approval.expired"].includes(typ) && run) {
|
|
646
|
+
const p = run.pending.get(d.approval_id);
|
|
647
|
+
if (p) {
|
|
648
|
+
run.pending.delete(d.approval_id);
|
|
649
|
+
if (typ === "approval.consumed")
|
|
650
|
+
this.count(det, e, run, new Set(p[0]), p[1], p[2], Boolean(p[3]), [e.run_id, e.run_seq], fired);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
let trigger = { "trace.tamper": "tamper", "refusal.summary": "refusal_burst" }[typ];
|
|
655
|
+
if (typ === "capture.gap" && ["executed_against_policy", "class_mismatch"].includes(d.kind))
|
|
656
|
+
trigger = d.kind;
|
|
657
|
+
if (trigger && this.on(trigger) && (trigger !== "refusal_burst" || d.count > this.respond.get(trigger).max))
|
|
658
|
+
fired.push([trigger, [], ...this.scope(det, e, run, "run").slice(0, 2), [[e.run_id, e.run_seq]]]);
|
|
659
|
+
}
|
|
660
|
+
return this.incidents(det, e, run, t, fired);
|
|
661
|
+
}
|
|
662
|
+
/** As detect.py's _count: `unless` exempts only a call the policy's own reading names, never one a hint added. */
|
|
663
|
+
count(det, e, run, names, unknown, sums, hinted, cite, fired) {
|
|
664
|
+
const own = unknown ? new Set() : new Set([...names].filter((n) => n !== "*"));
|
|
665
|
+
const at = ts(e.ts);
|
|
666
|
+
for (const s of this.sequences) {
|
|
667
|
+
if (!unknown && !hinted && (s.unless ?? []).some((n) => names.has(n)))
|
|
668
|
+
continue;
|
|
669
|
+
const steps = s.steps, last = steps.length - 1;
|
|
670
|
+
if (!(unknown || steps.some((n) => names.has(n))))
|
|
671
|
+
continue;
|
|
672
|
+
const [key, scope, st] = this.scope(det, e, run, s.scope ?? "run");
|
|
673
|
+
const old = st.seq.get(s.id) ?? Array(last).fill(null), next = [...old];
|
|
674
|
+
let done = null;
|
|
675
|
+
for (let j = 0; j <= last; j++) {
|
|
676
|
+
if (!(unknown || names.has(steps[j])))
|
|
677
|
+
continue;
|
|
678
|
+
const prev = j ? old[j - 1] : null;
|
|
679
|
+
if (j && (prev === null || at - ts(prev[0]) > s.window))
|
|
680
|
+
continue;
|
|
681
|
+
const [start, cites] = j ? [prev[0], [...prev[1], cite]] : [e.ts, [cite]];
|
|
682
|
+
const used = new Set();
|
|
683
|
+
for (let k = j;; k++) {
|
|
684
|
+
if (k === last) {
|
|
685
|
+
if (done === null || ts(start) >= ts(done[0]))
|
|
686
|
+
done = [start, cites];
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
if (next[k] === null || ts(start) >= ts(next[k][0]))
|
|
690
|
+
next[k] = [start, cites];
|
|
691
|
+
used.add(steps[k]);
|
|
692
|
+
if (!own.has(steps[k]) || !own.has(steps[k + 1]) || used.has(steps[k + 1]))
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
st.seq.set(s.id, next);
|
|
697
|
+
if (done)
|
|
698
|
+
fired.push(["sequence", [s.id], key, scope, done[1]]);
|
|
699
|
+
}
|
|
700
|
+
for (const lim of this.limits) {
|
|
701
|
+
if (!(unknown || names.has(lim.match)) || Object.hasOwn(lim, "sum") && !Object.hasOwn(sums, lim.id))
|
|
702
|
+
continue;
|
|
703
|
+
const [key, scope, st] = this.scope(det, e, run, lim.scope ?? "run");
|
|
704
|
+
let [start, val] = st.lim.get(lim.id) ?? [null, null], before, after;
|
|
705
|
+
if (start === null || at - ts(start) > lim.window)
|
|
706
|
+
[start, val] = [e.ts, Object.hasOwn(lim, "sum") ? {} : 0];
|
|
707
|
+
if (Object.hasOwn(lim, "sum")) {
|
|
708
|
+
const [amount, cur] = sums[lim.id];
|
|
709
|
+
before = val[cur] ?? 0;
|
|
710
|
+
val = { ...val, [cur]: Math.min(before + amount, Number.MAX_SAFE_INTEGER) }; // saturated, as detect.py
|
|
711
|
+
after = val[cur];
|
|
712
|
+
}
|
|
713
|
+
else
|
|
714
|
+
[before, val, after] = [val, val + 1, val + 1];
|
|
715
|
+
st.lim.set(lim.id, [start, val]);
|
|
716
|
+
if (before <= lim.max && lim.max < after)
|
|
717
|
+
fired.push(["limit", [lim.id], key, scope, [cite]]);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
denied(det, e, run, t, cite, fired) {
|
|
721
|
+
const r = this.respond.get("denied_calls"), d = e.data, ids = d.rule_ids;
|
|
722
|
+
if (!this.on("denied_calls") || d.nondeterministic
|
|
723
|
+
|| ids.some((i) => NOT_COUNTED.includes(i)) && !ids.some((i) => this.deny.has(i)))
|
|
724
|
+
return;
|
|
725
|
+
const [key, scope, st] = this.scope(det, e, run, r.scope ?? "run");
|
|
726
|
+
if (st.den === null || t - ts(st.den[0]) > r.window)
|
|
727
|
+
st.den = [e.ts, new Set()];
|
|
728
|
+
const seen = st.den[1], call = `${e.tool_call_id}\0${e.attempt ?? 0}`;
|
|
729
|
+
if (seen.has(call) || seen.size > r.max)
|
|
730
|
+
return;
|
|
731
|
+
seen.add(call);
|
|
732
|
+
if (seen.size > r.max)
|
|
733
|
+
fired.push(["denied_calls", ids, key, scope, [cite]]);
|
|
734
|
+
}
|
|
735
|
+
incidents(det, e, run, t, fired) {
|
|
736
|
+
if (!fired.length)
|
|
737
|
+
return [];
|
|
738
|
+
const out = [], minute = Math.floor(t / 60), m = det.minute.get(e.tenant);
|
|
739
|
+
let nTen = m && m[0] === minute ? m[1] : 0;
|
|
740
|
+
const nRun = new Map(run ? run.n : []), capped = new Map(run ? run.capped : []);
|
|
741
|
+
let [nMine, shared] = run && run.minute[0] === minute ? [run.minute[1], run.minute[2]] : [0, false];
|
|
742
|
+
for (const [trigger, ruleIds, key, scope, cites] of fired) {
|
|
743
|
+
const rule = ["sequence", "limit"].includes(trigger) ? ruleIds[0] : trigger;
|
|
744
|
+
const r = this.respond.get(rule) ?? { mode: "shadow" };
|
|
745
|
+
if (r.mode === "off")
|
|
746
|
+
continue;
|
|
747
|
+
const st = det.scopes.get(key), k = `${trigger}\0${rule}`, cited = cites.map(([a, b]) => ({ run_id: a, run_seq: b }));
|
|
748
|
+
if (st.open.has(k)) {
|
|
749
|
+
const [inc, last] = st.open.get(k);
|
|
750
|
+
if (t - ts(last) >= (this.every.get(rule) ?? UPDATE_S)) {
|
|
751
|
+
st.open.set(k, [inc, e.ts]);
|
|
752
|
+
out.push(["incident.updated", { incident_id: inc, cites: cited }]);
|
|
753
|
+
}
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (run && (nRun.get(trigger) ?? 0) >= RUN_CAP) {
|
|
757
|
+
if (capped.get(trigger))
|
|
758
|
+
continue;
|
|
759
|
+
capped.set(trigger, true);
|
|
760
|
+
out.push(["incident.capped", { trigger }]);
|
|
761
|
+
}
|
|
762
|
+
else if (nTen >= TENANT_CAP || nMine >= RUN_SHARE) {
|
|
763
|
+
if (shared)
|
|
764
|
+
continue;
|
|
765
|
+
shared = true;
|
|
766
|
+
out.push(["incident.capped", { trigger }]);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
nRun.set(trigger, (nRun.get(trigger) ?? 0) + 1);
|
|
770
|
+
nTen++;
|
|
771
|
+
nMine++;
|
|
772
|
+
out.push(["incident.opened", { trigger, rule_ids: ruleIds, scope, cites: cited, mode: r.mode,
|
|
773
|
+
would: r.action ? [r.action] : [] }]);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
countIncident(det, e, run, t) {
|
|
779
|
+
const d = e.data, minute = Math.floor(t / 60);
|
|
780
|
+
if (e.type === "incident.capped") {
|
|
781
|
+
if (run && d.cap === "run")
|
|
782
|
+
run.capped.set(d.trigger, true);
|
|
783
|
+
else if (run)
|
|
784
|
+
run.minute = [minute, run.minute[0] === minute ? run.minute[1] : 0, true];
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const m = det.minute.get(e.tenant);
|
|
788
|
+
det.minute.set(e.tenant, [minute, (m && m[0] === minute ? m[1] : 0) + 1]);
|
|
789
|
+
if (run) {
|
|
790
|
+
run.n.set(d.trigger, (run.n.get(d.trigger) ?? 0) + 1);
|
|
791
|
+
const mine = run.minute;
|
|
792
|
+
run.minute = mine[0] === minute ? [minute, mine[1] + 1, mine[2]] : [minute, 1, false];
|
|
793
|
+
}
|
|
794
|
+
const st = det.scopes.get(`${d.scope.kind}\0${e.tenant}\0${d.scope.id}`);
|
|
795
|
+
const rule = ["sequence", "limit"].includes(d.trigger) ? d.rule_ids[0] : d.trigger;
|
|
796
|
+
if (st)
|
|
797
|
+
st.open.set(`${d.trigger}\0${rule}`, [d.incident_id, e.ts]);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return { Detector };
|
|
802
|
+
})();
|
|
803
|
+
const __schema = (() => {
|
|
804
|
+
const SCHEMA = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/Cygnux-Labs/Tracekit/schema/tracekit.event.v2.json","title":"Tracekit event v2","description":"One event in a format v2 record log. Additive over tracekit.event.v1: new optional fields and types, and bounds on every string and array. Validated with full-match ASCII patterns.","type":"object","additionalProperties":false,"required":["schema_version","id","seq","prev_hash","ts","run_id","agent_id","parent_id","source","type","data","log_id","tenant","run_seq","run_prev_hash"],"properties":{"schema_version":{"const":"tracekit.event.v2"},"id":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"Random 128-bit id, hex.","maxLength":4096},"seq":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Signer's monotonic counter. Assigned by tracekitd, never by the client."},"prev_hash":{"$ref":"#/$defs/hash","description":"Hash of the previous record in this log; sha256: and 64 zeros for seq 0."},"ts":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","description":"Client-reported time, UTC, microseconds.","maxLength":4096},"ts_signed":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","description":"Time the signer accepted the event.","maxLength":4096},"run_id":{"type":"string","minLength":1,"maxLength":200},"agent_id":{"type":"string","minLength":1,"maxLength":200,"description":"\"main\" for the top-level agent, otherwise the harness's sub-agent id."},"parent_id":{"type":["string","null"],"maxLength":200,"description":"agent_id of the spawning agent, or null."},"source":{"enum":["hook","proxy","transcript","signer","sdk","migrated","import","gateway"],"description":"Capture path. transcript = harness-reported, lower trust; import = received over OTLP, or another system's decision (decision_import) (tier T3): receipt only; gateway = model.exchange recorded by the LLM gateway, or a tool call (policy.decision, approval.request, tool.result) or an input (input.observed) the MCP gateway or a tool executor decided, ran or received for its client (tier T2)."},"type":{"enum":["run.start","run.end","tool.call","policy.decision","approval","tool.result","error","review","trace.tamper","capture.gap","checkpoint","user.prompt","model.message","model.exchange","run.registered","run.closing","run.final","approval.request","approval.consumed","approval.expired","approval.refused","approval.binding_mismatch","approval.abandoned","approval.escalated","approval.revoked","approval.suggested","approval.agents","input.observed","state.write","signer.epoch","key.retire","log.closed","reconcile.hook_missing","reconcile.fabricated","reconcile.args_mismatch","reconcile.result_without_call","reconcile.unexplained_effect","reconcile.args_unparseable","reconcile.result_mismatch","refusal.summary","policy.external","content.accessed","content.erased","why.finding","run.child","incident.opened","incident.updated","incident.capped","run.held","run.resumed","run.quarantined","incident.resolved","incident.response","identity.suspension","effect.observed","constraint.added","ext"]},"data":{"type":"object"},"transcript":{"type":"object","additionalProperties":false,"required":["path","length","hash"],"description":"C2: what the hook read of the harness transcript at this point (prefix hash). Lets the signer detect later edits, truncation or deletion.","properties":{"path":{"type":"string","maxLength":4096},"length":{"type":"integer","minimum":0},"hash":{"$ref":"#/$defs/commitment"},"prefix_length":{"type":["integer","null"],"minimum":0,"description":"Length the signer last acknowledged for this path."},"prefix_hash":{"oneOf":[{"$ref":"#/$defs/commitment"},{"type":"null"}],"description":"Hash of the first prefix_length bytes as read now."},"missing":{"type":"boolean"}}},"log_id":{"type":"string","pattern":"^[0-9a-f]{32}$","description":"The record log this event belongs to.","maxLength":4096},"tenant":{"$ref":"#/$defs/id"},"tenant_attested":{"type":"boolean"},"principal":{"type":"string","maxLength":256},"principal_attested":{"type":"boolean"},"request_id":{"$ref":"#/$defs/id"},"stream":{"$ref":"#/$defs/id"},"client_seq":{"type":"integer","minimum":0,"maximum":9007199254740991},"tool_call_id":{"$ref":"#/$defs/id"},"attempt":{"type":"integer","minimum":0,"maximum":9007199254740991},"step":{"type":"integer","minimum":0,"maximum":9007199254740991},"run_seq":{"type":"integer","minimum":0,"maximum":9007199254740991},"run_prev_hash":{"$ref":"#/$defs/hash"},"capture_layer":{"enum":["L1","L2","L3","L4","L5","L6"]},"tier":{"enum":["T1","T2","T3"]},"span_id":{"type":"string","pattern":"^[0-9a-f]{16}$","maxLength":16,"description":"source import: the OpenTelemetry span the record was imported from"},"args_commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":4096},"args_source":{"enum":["raw","parsed","coerced"]},"engine":{"type":"string","maxLength":128,"pattern":"^[A-Za-z0-9._-]+@[A-Za-z0-9._+-]+$","description":"Policy engine id@version."},"ext":{"type":"object","propertyNames":{"$ref":"#/$defs/ext_name"},"description":"Non-critical fields (format 1.4), on any record: {name: any strict JSON value}. A verifier that does not interpret a name still checks the record's signature, chain and inclusion, and says it did not interpret the name (docs/format-v2.md section 9)."}},"allOf":[{"if":{"properties":{"type":{"const":"run.start"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_start"}}}},{"if":{"properties":{"type":{"const":"run.end"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_end"}}}},{"if":{"properties":{"type":{"const":"tool.call"}}},"then":{"properties":{"data":{"$ref":"#/$defs/tool_call"}}}},{"if":{"properties":{"type":{"const":"policy.decision"}}},"then":{"properties":{"data":{"$ref":"#/$defs/policy_decision"}}}},{"if":{"properties":{"type":{"const":"approval"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval"}}}},{"if":{"properties":{"type":{"const":"tool.result"}}},"then":{"properties":{"data":{"$ref":"#/$defs/tool_result"}}}},{"if":{"properties":{"type":{"const":"error"}}},"then":{"properties":{"data":{"$ref":"#/$defs/error"}}}},{"if":{"properties":{"type":{"const":"review"}}},"then":{"properties":{"data":{"$ref":"#/$defs/review"}}}},{"if":{"properties":{"type":{"const":"trace.tamper"}}},"then":{"properties":{"data":{"$ref":"#/$defs/trace_tamper"}}}},{"if":{"properties":{"type":{"const":"capture.gap"}}},"then":{"properties":{"data":{"$ref":"#/$defs/capture_gap"}}}},{"if":{"properties":{"type":{"const":"checkpoint"}}},"then":{"properties":{"data":{"$ref":"#/$defs/checkpoint"}}}},{"if":{"properties":{"type":{"const":"user.prompt"}}},"then":{"properties":{"data":{"$ref":"#/$defs/user_prompt"}}}},{"if":{"properties":{"type":{"const":"model.message"}}},"then":{"properties":{"data":{"$ref":"#/$defs/model_message"}}}},{"if":{"properties":{"type":{"const":"model.exchange"}}},"then":{"properties":{"data":{"$ref":"#/$defs/model_exchange"}}}},{"if":{"properties":{"type":{"const":"run.registered"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_registered"}}}},{"if":{"properties":{"type":{"const":"run.closing"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_closing"}}}},{"if":{"properties":{"type":{"const":"run.final"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_final"}}}},{"if":{"properties":{"type":{"const":"approval.request"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_request"}}}},{"if":{"properties":{"type":{"const":"approval.consumed"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_consumed"}}}},{"if":{"properties":{"type":{"const":"approval.expired"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_expired"}}}},{"if":{"properties":{"type":{"const":"approval.refused"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_refused"}}}},{"if":{"properties":{"type":{"const":"approval.binding_mismatch"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_binding_mismatch"}}}},{"if":{"properties":{"type":{"const":"approval.abandoned"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_abandoned"}}}},{"if":{"properties":{"type":{"const":"approval.escalated"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_escalated"}}}},{"if":{"properties":{"type":{"const":"approval.revoked"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_revoked"}}}},{"if":{"properties":{"type":{"const":"approval.suggested"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_suggested"}}}},{"if":{"properties":{"type":{"const":"approval.agents"}}},"then":{"properties":{"data":{"$ref":"#/$defs/approval_agents"}}}},{"if":{"properties":{"type":{"const":"input.observed"}}},"then":{"properties":{"data":{"$ref":"#/$defs/input_observed"}}}},{"if":{"properties":{"type":{"const":"state.write"}}},"then":{"properties":{"data":{"$ref":"#/$defs/state_write"}}}},{"if":{"properties":{"type":{"const":"signer.epoch"}}},"then":{"properties":{"data":{"$ref":"#/$defs/signer_epoch"}}}},{"if":{"properties":{"type":{"const":"key.retire"}}},"then":{"properties":{"data":{"$ref":"#/$defs/key_retire"}}}},{"if":{"properties":{"type":{"const":"log.closed"}}},"then":{"properties":{"data":{"$ref":"#/$defs/log_closed"}}}},{"if":{"properties":{"type":{"const":"reconcile.hook_missing"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile"}}}},{"if":{"properties":{"type":{"const":"reconcile.fabricated"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile"}}}},{"if":{"properties":{"type":{"const":"reconcile.args_mismatch"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile"}}}},{"if":{"properties":{"type":{"const":"reconcile.result_without_call"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile"}}}},{"if":{"properties":{"type":{"const":"reconcile.unexplained_effect"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile_effect"}}}},{"if":{"properties":{"type":{"const":"reconcile.args_unparseable"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile"}}}},{"if":{"properties":{"type":{"const":"reconcile.result_mismatch"}}},"then":{"properties":{"data":{"$ref":"#/$defs/reconcile"}}}},{"if":{"properties":{"type":{"const":"refusal.summary"}}},"then":{"properties":{"data":{"$ref":"#/$defs/refusal_summary"}}}},{"if":{"properties":{"type":{"const":"policy.external"}}},"then":{"properties":{"data":{"$ref":"#/$defs/policy_external"}}}},{"if":{"properties":{"type":{"const":"content.accessed"}}},"then":{"properties":{"data":{"$ref":"#/$defs/content_accessed"}}}},{"if":{"properties":{"type":{"const":"content.erased"}}},"then":{"properties":{"data":{"$ref":"#/$defs/content_erased"}}}},{"if":{"properties":{"type":{"const":"why.finding"}}},"then":{"properties":{"data":{"$ref":"#/$defs/why_finding"}}}},{"if":{"properties":{"type":{"const":"run.child"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_child"}}}},{"if":{"properties":{"type":{"const":"incident.opened"}}},"then":{"properties":{"data":{"$ref":"#/$defs/incident_opened"}}}},{"if":{"properties":{"type":{"const":"incident.updated"}}},"then":{"properties":{"data":{"$ref":"#/$defs/incident_updated"}}}},{"if":{"properties":{"type":{"const":"incident.capped"}}},"then":{"properties":{"data":{"$ref":"#/$defs/incident_capped"}}}},{"if":{"properties":{"type":{"const":"run.held"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_held"}}}},{"if":{"properties":{"type":{"const":"run.resumed"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_resumed"}}}},{"if":{"properties":{"type":{"const":"run.quarantined"}}},"then":{"properties":{"data":{"$ref":"#/$defs/run_quarantined"}}}},{"if":{"properties":{"type":{"const":"incident.resolved"}}},"then":{"properties":{"data":{"$ref":"#/$defs/incident_resolved"}}}},{"if":{"properties":{"type":{"const":"incident.response"}}},"then":{"properties":{"data":{"$ref":"#/$defs/incident_response"}}}},{"if":{"properties":{"type":{"const":"identity.suspension"}}},"then":{"properties":{"data":{"$ref":"#/$defs/identity_suspension"}}}},{"if":{"properties":{"type":{"const":"effect.observed"}}},"then":{"properties":{"data":{"oneOf":[{"$ref":"#/$defs/effect_observed"},{"$ref":"#/$defs/sensor_effect"}]}}}},{"if":{"properties":{"type":{"const":"constraint.added"}}},"then":{"properties":{"data":{"$ref":"#/$defs/constraint_added"}}}},{"if":{"properties":{"type":{"const":"ext"}}},"then":{"properties":{"data":{"$ref":"#/$defs/ext"}}}}],"$defs":{"hash":{"type":"string","pattern":"^sha256:[0-9a-f]{64}$","maxLength":71},"commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76,"description":"HMAC-SHA256 of a sha256: digest of agent content under a per-record salt the signer keeps and can reveal for that one record (tracekit signer reveal)"},"redaction":{"type":"object","additionalProperties":false,"required":["rules","count","client_claimed"],"description":"what the signer redacted from this record's content before committing to it; never the values","properties":{"rules":{"type":"array","maxItems":64,"items":{"type":"string","maxLength":64},"description":"the redaction rules that fired"},"count":{"type":"integer","minimum":0},"client_claimed":{"type":"boolean","description":"the client said it redacted the content already"},"client":{"type":"object","additionalProperties":false,"description":"the client's own manifest, as it claimed it","properties":{"rules":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":64}},"count":{"type":"integer","minimum":0}}},"client_redaction_incomplete":{"const":true,"description":"the client claimed redaction and the signer still found a secret"}}},"content":{"description":"Either the (redacted) value or a reference to it. Which one is decided by docs/privacy.md, never by the caller.","oneOf":[{"type":"object","additionalProperties":false,"required":["value","redacted"],"properties":{"value":{},"redacted":{"type":"boolean"}}},{"type":"object","additionalProperties":false,"required":["hash","size","redacted"],"properties":{"hash":{"$ref":"#/$defs/commitment"},"size":{"type":"integer","minimum":0},"redacted":{"type":"boolean"}}}]},"run_start":{"type":"object","additionalProperties":false,"required":["agent","host","os_user","fail_mode","policy","capture_sources","sandbox","content_capture","signer_isolation"],"properties":{"agent":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":4096},"version":{"type":["string","null"],"maxLength":4096}}},"model":{"type":["string","null"],"maxLength":4096},"repo":{"type":["string","null"],"maxLength":4096},"commit":{"type":["string","null"],"maxLength":4096},"cwd":{"type":["string","null"],"maxLength":4096},"host":{"type":"string","maxLength":4096},"os_user":{"type":"string","maxLength":4096},"fail_mode":{"enum":["open","closed"]},"policy":{"type":"object","additionalProperties":false,"required":["version","hash"],"properties":{"version":{"type":"string","maxLength":4096},"hash":{"$ref":"#/$defs/hash"}}},"capture_sources":{"type":"array","items":{"enum":["hook","proxy","transcript","sdk"]},"maxItems":1024},"sandbox":{"enum":["none","unknown","self-reported-active"]},"content_capture":{"enum":["hashed","full"]},"reasoning_capture":{"type":"boolean"},"signer_isolation":{"enum":["separate-user","same-user"]},"os_user_attested":{"type":"boolean","description":"true when tracekitd set os_user from the socket peer's credentials rather than trusting the client."},"harness":{"type":"object","additionalProperties":false,"description":"0.3: set only by the signer. The registered harness instance the run.start came from (pid + kernel start time), found from the kernel's view of the sender's process tree.","required":["name","exe","pid","attested"],"properties":{"name":{"type":"string","maxLength":4096},"exe":{"type":"string","maxLength":4096},"pid":{"type":"integer"},"start_time":{"type":["integer","null"]},"attested":{"type":"boolean"}}}}},"run_end":{"type":"object","additionalProperties":false,"required":["reason"],"properties":{"reason":{"type":"string","maxLength":4096}}},"tool_call":{"type":"object","additionalProperties":false,"required":["tool_use_id","name","input"],"properties":{"tool_use_id":{"type":"string","maxLength":4096},"name":{"type":"string","maxLength":4096},"input":{"type":"object","additionalProperties":{"$ref":"#/$defs/content"}},"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>)"}}},"policy_decision":{"type":"object","additionalProperties":false,"required":["tool_use_id","decision","rule_ids"],"properties":{"tool_use_id":{"type":"string","maxLength":4096},"decision":{"enum":["allow","deny","flag","ask"]},"rule_ids":{"type":"array","items":{"type":"string","maxLength":4096},"maxItems":1024},"reasons":{"type":"array","items":{"type":"string","maxLength":4096},"maxItems":1024},"policy_hash":{"$ref":"#/$defs/hash","description":"hash of the policy that produced this decision"},"policy_version":{"type":"string","maxLength":4096},"tool":{"type":"string","maxLength":256,"description":"tool name the decision is for"},"decision_id":{"$ref":"#/$defs/id","description":"signer-assigned; `complete` consumes it once"},"args_commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76,"description":"HMAC of the signer's args digest under a per-decision salt the signer can reveal to an auditor; absent when the args failed strict parsing"},"engine":{"type":"string","maxLength":64,"description":"matching engine id@version"},"nonce":{"type":"string","pattern":"^[0-9a-f]{32}$","maxLength":32},"expires_at":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27},"nondeterministic":{"type":"boolean","description":"a fallback-engine match ran out of time and denied"},"provenance":{"type":"array","maxItems":16,"description":"argument values that only untrusted content brought into the run: the top-level argument, the kind of value and the record that brought it; never the value","items":{"type":"object","additionalProperties":false,"required":["field","kind","source"],"properties":{"field":{"type":"string","maxLength":256},"kind":{"enum":["email","url","host","path","digits","string"]},"source":{"type":"object","additionalProperties":false,"required":["run_seq"],"properties":{"run_seq":{"type":"integer","minimum":0},"tool":{"type":"string","maxLength":256},"tool_call_id":{"$ref":"#/$defs/id"}}}}}},"provenance_state":{"enum":["complete","partial","truncated","unavailable","timeout"],"description":"the run's provenance index: complete, partial (an untrusted tool's result came as a digest), truncated (more values than it reads), unavailable (lost in a signer restart) or timeout (this call's values took past the decision's deadline); when not complete, from: untrusted rules match whenever their pattern does"},"provenance_reason":{"type":"string","maxLength":256,"description":"why provenance_state is not complete"},"detect":{"type":"object","additionalProperties":false,"required":["sequences","limits","state"],"description":"format 1.2: what the policy's sequences and limits saw of this call (signer/detect.py): the step each sequence it advanced reached, the count (a sum limit: the total of the call's currency) and window start of each limit it counted, and whether the detector's state is complete or was lost in a restart (unavailable)","properties":{"rule_ids":{"type":"array","maxItems":1024,"items":{"type":"string","maxLength":256},"description":"the rule ids of both class readings, when the client's class hint disagreed and they differ from rule_ids"},"sums":{"type":"object","description":"per sum limit: [amount in minor units, currency] of this call","additionalProperties":{"type":"array","minItems":2,"maxItems":2,"items":{"type":["integer","string"],"maxLength":16}}},"sequences":{"type":"array","maxItems":1024,"items":{"type":"object","additionalProperties":false,"required":["id","step"],"properties":{"id":{"type":"string","maxLength":256},"step":{"type":"integer","minimum":1}}}},"limits":{"type":"array","maxItems":1024,"items":{"type":"object","additionalProperties":false,"required":["id","count","window_start"],"properties":{"id":{"type":"string","maxLength":256},"count":{"type":"integer"},"window_start":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27}}}},"state":{"enum":["complete","unavailable"]}}},"via":{"type":"string","maxLength":256,"description":"format 1.2: the gateway or tool executor (its identity, scheme:subject) that wrote this record for the run's client (source gateway, tier T2); the signer takes the call's later steps from it only"},"caller":{"type":"string","maxLength":256,"description":"format 1.2, with via: the client (scheme:subject) the gateway or tool executor decided the call for"},"incident_id":{"$ref":"#/$defs/id","description":"format 1.2: the incident whose hold (ask, TK-HOLD) or quarantine (deny, TK-QUARANTINE) decided the call"}}},"approval":{"type":"object","additionalProperties":false,"required":["tool_use_id","decision","approver","channel"],"properties":{"tool_use_id":{"type":"string","maxLength":4096},"decision":{"enum":["approve","reject","timeout","self_approval_refused"]},"approver":{"type":"string","maxLength":4096},"channel":{"type":"string","maxLength":4096},"approver_uid":{"type":["integer","null"]},"wait_ms":{"type":["integer","null"]},"same_user":{"type":["boolean","null"],"description":"true when the approver is the agent's own OS user (dev mode only; not trustworthy)"},"approval_id":{"$ref":"#/$defs/id"},"self_approved":{"type":"boolean","description":"the approver is the requester (dev mode only); caps assurance at dev"},"reason":{"type":"string","maxLength":1024},"approver_identity":{"type":"object","additionalProperties":false,"required":["scheme","subject","attested"],"properties":{"scheme":{"type":"string","maxLength":32},"subject":{"type":"string","maxLength":256},"attested":{"type":"boolean"},"person":{"type":"string","maxLength":256,"description":"an OIDC identity's stable person id"}},"description":"the approver as the signer's transport established it"},"break_glass":{"type":"boolean","description":"answered under the break-glass role, which may answer any approval"},"via":{"type":"object","additionalProperties":false,"required":["scheme","subject","attested"],"properties":{"scheme":{"type":"string","maxLength":32},"subject":{"type":"string","maxLength":256},"attested":{"type":"boolean"},"person":{"type":"string","maxLength":256,"description":"an OIDC identity's stable person id"}},"description":"the bridge (Slack, or the viewer's approval pages) that answered for the approver, as the signer's transport established it"},"groups":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":64},"description":"user groups the bridge found the approver in"},"person":{"type":"string","maxLength":4096,"description":"the person id the signer's config maps the approver to"},"passkey":{"type":"object","additionalProperties":false,"required":["credential_id","user_verified"],"properties":{"credential_id":{"type":"string","maxLength":4096},"user_verified":{"const":true}},"description":"the WebAuthn credential whose assertion over the approval's binding digest the signer verified"},"approver_kind":{"const":"agent","description":"format 1.2: an agent approver (approvals.agents) answered; absent for people"},"asserted":{"type":"object","additionalProperties":false,"required":["attested"],"properties":{"model":{"type":"string","maxLength":128},"version":{"type":"string","maxLength":64},"prompt_hash":{"$ref":"#/$defs/hash"},"attested":{"const":false}},"description":"format 1.2: what the agent approver says it runs; its own word, never checked"},"spent":{"type":"object","additionalProperties":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"description":"format 1.2: rule id -> the argument value this agent approval takes from the rule's sum budget"},"not_before":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":4096,"description":"format 1.2: an agent's approval is consumed from then on (after the rule's veto_s), so people may revoke it first"},"consume_by":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":4096,"description":"format 1.2: an agent's approval is consumed before then (approvals.agent_consume_s), or never"},"incident_id":{"$ref":"#/$defs/id","description":"format 1.2: the answer to a call held by this incident, by the person `person`"}}},"tool_result":{"type":"object","additionalProperties":false,"required":["tool_use_id","ok","output"],"properties":{"tool_use_id":{"type":"string","maxLength":4096},"ok":{"type":"boolean"},"output":{"$ref":"#/$defs/content"},"duration_ms":{"type":["integer","null"]},"decision_id":{"$ref":"#/$defs/id","description":"the policy.decision this result consumes"},"redaction":{"$ref":"#/$defs/redaction"},"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>)"},"via":{"type":"string","maxLength":256,"description":"format 1.2: the gateway or tool executor (its identity, scheme:subject) that wrote this record for the run's client (source gateway, tier T2); the signer takes the call's later steps from it only"}}},"error":{"type":"object","required":["message"],"properties":{"message":{"type":"string","maxLength":4096}}},"review":{"type":"object","required":["reviewer","verdict"],"properties":{"reviewer":{"type":"string","maxLength":4096},"verdict":{"type":"object"},"cites":{"type":"array","maxItems":256,"items":{"type":"object","additionalProperties":false,"required":["run_seq","hash"],"properties":{"run_seq":{"type":"integer","minimum":0,"maximum":9007199254740991},"hash":{"$ref":"#/$defs/hash"}}},"description":"A signed finding (format 1.4; review_finding), in a findings run: the records of the analysed run it rests on, by run_seq and record hash. A review without cites (a migrated v1 judge verdict) is imported, not checked."}}},"trace_tamper":{"type":"object","additionalProperties":false,"required":["path","before","after"],"properties":{"tenant_level":{"type":"boolean"},"path":{"type":"string","maxLength":4096},"before":{"type":"object","properties":{"hash":{"$ref":"#/$defs/hash"},"length":{"type":"integer"}}},"after":{"type":"object","properties":{"hash":{"type":["string","null"],"maxLength":4096},"length":{"type":["integer","null"]}}},"kind":{"enum":["deleted","truncated","edited","prefix_unverifiable","rollback"]}}},"capture_gap":{"type":"object","additionalProperties":false,"required":["reason"],"properties":{"tenant_level":{"type":"boolean"},"reason":{"type":"string","maxLength":4096},"from_ts":{"type":["string","null"],"maxLength":4096},"to_ts":{"type":["string","null"],"maxLength":4096},"missed_events":{"type":["integer","null"]},"kind":{"enum":["signer_unavailable","client_counter_gap","retry_conflict","run_abandoned","witness_late","witness_failed","degraded_unanchored","format_upgrade","policy_timeout","decision_flip","class_mismatch","args_coerced","reconcile.hook_missing","reconcile.fabricated","reconcile.args_mismatch","reconcile.result_without_call","reconcile.unexplained_effect","state_tamper","tailer_lost","rollback","clock_skew","key_retired","key_revoked","log_closed","quota_refused","identity_refused","approval_expired","executed_against_policy","decision_mismatch","decided_not_completed","late_activity","concurrent_run","tailer_redelegated","reask_after_reject","held_call_unresolved","observer_lost","effect_outside_run","result_unchecked","constraint_refused"],"description":"Gap and tamper kinds (signer-written only)."},"run_id":{"$ref":"#/$defs/id","description":"format 1.2: the other run the gap is about (late_activity: the final run that was written to; concurrent_run: the open run this one overlaps; client_counter_gap in a tenant's signer run: the final run whose stream it is; effect_outside_run: the run the client named, its token valid or not, or the effect was attributed to)"},"tool_use_id":{"type":["string","null"],"maxLength":4096},"sensor":{"enum":["tetragon","falco","openshell"],"description":"format 1.4: the sensor (a kernel sensor, or OpenShell) whose import an observer_lost gap is about"}}},"checkpoint":{"type":"object","additionalProperties":false,"required":["head_seq","head_hash","kid","witnesses"],"properties":{"head_seq":{"type":"integer"},"head_hash":{"type":"string","pattern":"^[0-9a-f]{64}$","maxLength":4096},"kid":{"type":"string","maxLength":4096},"witnesses":{"type":"array","items":{"type":"string","maxLength":4096},"maxItems":1024}}},"user_prompt":{"type":"object","additionalProperties":false,"required":["content"],"properties":{"content":{"$ref":"#/$defs/content"}}},"model_message":{"type":"object","additionalProperties":false,"required":["kind","content"],"description":"Self-reported model output read from the harness transcript. Opt-in, off by default, lower trust.","properties":{"kind":{"enum":["text","thinking","thinking_withheld"]},"message_id":{"type":["string","null"],"maxLength":4096},"content":{"$ref":"#/$defs/content"}}},"model_exchange":{"type":"object","additionalProperties":false,"required":["exchange_id","phase","streamed"],"description":"C3: one model API request/response recorded by the proxy (source=proxy) before the request was forwarded and when the response completed.","properties":{"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>); absent on records written before salt_id existed"},"exchange_id":{"type":"string","maxLength":4096},"phase":{"enum":["request","response"]},"model":{"type":["string","null"],"maxLength":4096},"request":{"$ref":"#/$defs/content"},"response":{"$ref":"#/$defs/content"},"status":{"type":["integer","null"]},"streamed":{"type":"boolean"},"duration_ms":{"type":["integer","null"]},"first_byte_ms":{"type":["integer","null"]},"added_latency_ms":{"type":["number","null"]},"tool_uses":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["id","name"],"properties":{"id":{"type":"string","maxLength":4096},"name":{"type":"string","maxLength":4096},"executed_by":{"enum":["client","provider"]},"args_source":{"enum":["raw","parsed","coerced"]},"args_commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76},"args_unparseable":{"const":true,"description":"the raw arguments string failed strict parsing"},"id_synthetic":{"const":true,"description":"the provider gave no id; the reporting client made this one"}}},"maxItems":1024},"said_only":{"const":true,"description":"format 1.4: a response recorded with what the model said and without the calls it asked for (a harness whose hooks name no call); reconciliation reads no call from it"},"tool_results_sent":{"type":"array","items":{"type":"string","maxLength":4096},"description":"tool_use ids whose results the harness sent back in this request","maxItems":1024},"stop_reason":{"type":["string","null"],"maxLength":4096},"upstream":{"type":"string","maxLength":4096},"error":{"type":["string","null"],"maxLength":4096},"attribution":{"enum":["metadata","tool_use_id","none"]},"usage":{"type":"object","additionalProperties":false,"required":["input_tokens","output_tokens"],"description":"Token usage on the response (optional). input_tokens excludes cache reads; reasoning_tokens is part of output_tokens.","properties":{"input_tokens":{"type":"integer","minimum":0},"output_tokens":{"type":"integer","minimum":0},"cache_read_tokens":{"type":["integer","null"],"minimum":0},"cache_write_tokens":{"type":["integer","null"],"minimum":0},"reasoning_tokens":{"type":["integer","null"],"minimum":0}}},"content_digest":{"$ref":"#/$defs/commitment"}}},"id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[A-Za-z0-9._:-]+$"},"run_registered":{"type":"object","additionalProperties":false,"required":["agent","identity"],"properties":{"agent":{"type":"object","additionalProperties":false,"required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":128},"version":{"type":"string","maxLength":64}}},"identity":{"type":"object","additionalProperties":false,"required":["scheme","subject","attested"],"properties":{"scheme":{"enum":["uid","harness","token","k8s_sa","mtls","oidc"]},"subject":{"type":"string","maxLength":256},"attested":{"type":"boolean"},"person":{"type":"string","maxLength":256,"description":"an OIDC identity's stable person id"}}},"signer_isolation":{"enum":["same-user","separate-user","remote","unknown"],"description":"measured by the signer from the caller's identity, never taken from the client"},"harness":{"type":"object","additionalProperties":false,"required":["name","exe","pid","start_time","attested"],"properties":{"name":{"type":"string","maxLength":64},"exe":{"type":"string","maxLength":4096},"pid":{"type":"integer","minimum":1},"start_time":{"type":["integer","null"]},"attested":{"const":true}},"description":"the registered harness process the caller descends from, found by the signer through the harness helper"},"fail_modes":{"type":"object","additionalProperties":{"enum":["open","closed"]},"description":"tool class -> fail mode, from the signer's config, never from the client"},"analyzes":{"$ref":"#/$defs/id","description":"a findings run: the run_id of the run of the same tenant it analyses"},"replay_of":{"$ref":"#/$defs/id","description":"a tracekit why replay run: the run_id of the run of the same tenant it replays"},"investigation":{"$ref":"#/$defs/id","description":"with replay_of: the investigation the replay run is an arm of"},"parent_run":{"$ref":"#/$defs/id","description":"format 1.2: the run of the same tenant that started this one, whose run token the caller presented"},"session":{"$ref":"#/$defs/id","description":"with parent_run: the root run of the chain of parents (a run without parent_run is its own session)"},"fidelity":{"enum":["none"],"description":"an imported run (source import): what its records say about the calls beyond their receipt"},"posture":{"type":"object","additionalProperties":false,"required":["network","tool_calls","gateway_mandatory","attested"],"description":"format 1.2: the deployment's posture as the operator asserts it in signer.yaml: whether the agent's network is enforced through the egress proxy, whether it acts only through the executor and the MCP gateway, and whether the LLM gateway is its only way to its model (gateway_mandatory); never attested","properties":{"network":{"enum":["enforced","open"]},"tool_calls":{"enum":["enforced","open"]},"gateway_mandatory":{"type":"boolean"},"attested":{"const":false}}}}},"run_closing":{"type":"object","additionalProperties":false,"required":["reason"],"properties":{"reason":{"enum":["close_run","idle_timeout","quarantine"]}}},"run_child":{"type":"object","additionalProperties":false,"required":["run_id"],"description":"format 1.2: a run bound to the same harness process registered with this run as its parent_run (signer-written)","properties":{"run_id":{"$ref":"#/$defs/id"}}},"run_final":{"type":"object","additionalProperties":false,"required":["head_run_seq","head_hash"],"properties":{"head_run_seq":{"type":"integer","minimum":0,"maximum":9007199254740991},"head_hash":{"$ref":"#/$defs/hash"},"provider_executed":{"type":"integer","minimum":1,"maximum":9007199254740991,"description":"format 1.2: tool uses the model's provider ran itself (executed_by provider), which no decide covers"},"coverage":{"type":"object","additionalProperties":false,"required":["layers","reconciled","unreconciled"],"description":"the capture layers that reported for the run and the tool calls the signer reconciled","properties":{"layers":{"type":"array","maxItems":6,"items":{"enum":["L1","L2","L3","L4","L5","L6"]}},"reconciled":{"type":"integer","minimum":0,"maximum":9007199254740991},"unreconciled":{"type":"object","additionalProperties":false,"description":"reconcile.* records of the run, by kind","properties":{"hook_missing":{"type":"integer","minimum":1,"maximum":9007199254740991},"fabricated":{"type":"integer","minimum":1,"maximum":9007199254740991},"args_mismatch":{"type":"integer","minimum":1,"maximum":9007199254740991},"args_unparseable":{"type":"integer","minimum":1,"maximum":9007199254740991},"result_without_call":{"type":"integer","minimum":1,"maximum":9007199254740991},"unexplained_effect":{"type":"integer","minimum":1,"maximum":9007199254740991},"result_mismatch":{"type":"integer","minimum":1,"maximum":9007199254740991}}}}},"paired_by_args":{"type":"integer","minimum":1,"maximum":9007199254740991,"description":"format 1.2: calls reconciled by pairing a decide with the model's tool use of the same turn by their arguments, the adapter having had no tool-call id"},"matrix":{"type":"object","additionalProperties":false,"required":["model_calls","tool_calls","network","processes","services"],"description":"format 1.2: per action class, how the run was covered: enforced (custody: the only way to act records it), observed (an observer outside the agent saw it and it was reconciled), reported (only the agent's own process reported it) or none; and the layers behind it","properties":{"model_calls":{"$ref":"#/$defs/coverage_class"},"tool_calls":{"$ref":"#/$defs/coverage_class"},"network":{"$ref":"#/$defs/coverage_class"},"processes":{"$ref":"#/$defs/coverage_class"},"services":{"$ref":"#/$defs/coverage_class"}}}}},"approval_request":{"type":"object","additionalProperties":false,"required":["approval_id","policy_hash","rule_ids","expires_at","requester","decision_id","binding","binding_digest"],"properties":{"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>); absent on records written before salt_id existed"},"approval_id":{"$ref":"#/$defs/id"},"policy_hash":{"$ref":"#/$defs/hash"},"rule_ids":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":64}},"expires_at":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":4096},"requester":{"type":"string","maxLength":256},"requester_person":{"type":"string","maxLength":256,"description":"the requester's stable person id, when an OIDC identity"},"decision_id":{"$ref":"#/$defs/id"},"binding":{"type":"object","additionalProperties":false,"required":["v","approval_id","tenant","run_id","tool_call_id","attempt","tool","args_commitment","args_source","policy_hash","nonce","expires_at"],"description":"what the approval is bound to; binding_digest is sha256(JCS(binding)), the args bound through args_commitment","properties":{"v":{"const":1},"approval_id":{"$ref":"#/$defs/id"},"tenant":{"$ref":"#/$defs/id"},"run_id":{"$ref":"#/$defs/id"},"tool_call_id":{"$ref":"#/$defs/id"},"attempt":{"type":"integer","minimum":0,"maximum":9007199254740991},"tool":{"type":"string","maxLength":256},"args_commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76},"args_source":{"enum":["raw","parsed","coerced"]},"policy_hash":{"$ref":"#/$defs/hash"},"nonce":{"type":"string","pattern":"^[0-9a-f]{32}$","maxLength":32},"expires_at":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":4096}}},"binding_digest":{"$ref":"#/$defs/hash"},"executor":{"enum":["t1","t2"],"description":"t2: approval_consume returns the approved arguments and its caller runs exactly those"},"agent":{"type":"object","additionalProperties":false,"required":["eligible","reason","rules","conditions_hash","args_key"],"description":"format 1.2: whether an agent approver may answer this approval, computed by the signer from the call's arguments and the run","properties":{"eligible":{"type":"boolean"},"reason":{"type":"string","maxLength":1024},"rules":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":64},"description":"the rules it asked by with approval.agent"},"conditions_hash":{"$ref":"#/$defs/hash","description":"sha256 of JCS of those rules' approval.agent sections"},"args_key":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76,"description":"an HMAC of the call's arguments under a key of this run: the same in this run for the same arguments, so a call an agent said no to is a person's to answer when asked again"}}},"via":{"type":"string","maxLength":256,"description":"format 1.2: the gateway or tool executor (its identity, scheme:subject) that wrote this record for the run's client (source gateway, tier T2); the signer takes the call's later steps from it only"},"incident_id":{"$ref":"#/$defs/id","description":"format 1.2: the call is held by this incident: only a person who is not the run's owner, principal or requester answers it"},"passkey":{"const":true,"description":"the approval needs a passkey (approval: {passkey: required})"}}},"approval_consumed":{"type":"object","additionalProperties":false,"required":["approval_id"],"properties":{"approval_id":{"$ref":"#/$defs/id"}}},"approval_expired":{"type":"object","additionalProperties":false,"required":["approval_id"],"properties":{"approval_id":{"$ref":"#/$defs/id"},"reason":{"enum":["ttl","run_final","consume_window","hold","quarantine"],"description":"format 1.2, on approvals an agent may answer: approvals.ttl_s passed, the run ended, or an agent's approval was not consumed within approvals.agent_consume_s; on any approval: its run was held or quarantined (hold, quarantine)"}}},"approval_refused":{"type":"object","additionalProperties":false,"required":["tool_use_id","rule_ids","reason"],"properties":{"tool_use_id":{"$ref":"#/$defs/id"},"approval_id":{"$ref":"#/$defs/id"},"rule_ids":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":64}},"reason":{"type":"string","maxLength":1024}}},"approval_binding_mismatch":{"type":"object","additionalProperties":false,"required":["approval_id","tool_use_id","approved_commitment","args_commitment"],"properties":{"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>); absent on records written before salt_id existed"},"approval_id":{"$ref":"#/$defs/id"},"tool_use_id":{"$ref":"#/$defs/id"},"approved_commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76},"args_commitment":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76},"decision_id":{"$ref":"#/$defs/id","description":"the decision the approval was for (without salt_id: the decision whose salt both commitments are under)"}}},"approval_abandoned":{"type":"object","additionalProperties":false,"required":["approval_id"],"properties":{"approval_id":{"$ref":"#/$defs/id"},"reason":{"type":"string","maxLength":1024}},"description":"the adapter will never resume the paused call; the approval ends at once"},"approval_escalated":{"type":"object","additionalProperties":false,"required":["approval_id","by"],"properties":{"approval_id":{"$ref":"#/$defs/id"},"by":{"enum":["agent","signer"]},"reason":{"type":"string","maxLength":1024},"approver_identity":{"type":"object","additionalProperties":false,"required":["scheme","subject","attested"],"properties":{"scheme":{"type":"string","maxLength":32},"subject":{"type":"string","maxLength":256},"attested":{"type":"boolean"},"person":{"type":"string","maxLength":256,"description":"an OIDC identity's stable person id"}},"description":"the approver as the signer's transport established it"}},"description":"format 1.2: only people answer this approval from now on: an agent approver handed it over, or the signer did (no agent answered within agent_timeout_s, a budget ran out)"},"approval_revoked":{"type":"object","additionalProperties":false,"required":["approval_id","by"],"properties":{"approval_id":{"$ref":"#/$defs/id"},"by":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024}},"description":"format 1.2: a person (or the kill switch, by signer) vetoed an agent's approval before it was consumed"},"approval_suggested":{"type":"object","additionalProperties":false,"required":["tool_use_id","approval_id","decision","approver","approver_identity","channel","approver_kind"],"properties":{"tool_use_id":{"$ref":"#/$defs/id"},"approval_id":{"$ref":"#/$defs/id"},"decision":{"enum":["approve","reject","escalate"]},"approver":{"type":"string","maxLength":256},"approver_identity":{"type":"object","additionalProperties":false,"required":["scheme","subject","attested"],"properties":{"scheme":{"type":"string","maxLength":32},"subject":{"type":"string","maxLength":256},"attested":{"type":"boolean"},"person":{"type":"string","maxLength":256,"description":"an OIDC identity's stable person id"}},"description":"the approver as the signer's transport established it"},"channel":{"type":"string","maxLength":64},"approver_kind":{"const":"agent"},"asserted":{"type":"object","additionalProperties":false,"required":["attested"],"properties":{"model":{"type":"string","maxLength":128},"version":{"type":"string","maxLength":64},"prompt_hash":{"$ref":"#/$defs/hash"},"attested":{"const":false}},"description":"format 1.2: what the agent approver says it runs; its own word, never checked"},"reason":{"type":"string","maxLength":1024}},"description":"format 1.2: a shadow-mode agent approver's answer, recorded only: it changes nothing"},"approval_agents":{"type":"object","additionalProperties":false,"required":["enabled","by"],"properties":{"enabled":{"type":"boolean"},"by":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024}},"description":"format 1.2: the operator's kill switch for agent approvals (in the signer run); switching off revokes every agent approval not consumed yet"},"input_observed":{"type":"object","additionalProperties":false,"description":"content that entered the run other than as a tool result (observe RPC), indexed for provenance; the value itself is only committed to","required":["source","trust","output","redaction","salt_id"],"properties":{"source":{"type":"string","maxLength":256,"description":"the caller's name for where the content came from"},"trust":{"enum":["trusted","untrusted"],"description":"the caller's claim; it only decides how this content's own values count"},"output":{"$ref":"#/$defs/content"},"redaction":{"$ref":"#/$defs/redaction"},"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>)"},"via":{"type":"string","maxLength":256,"description":"format 1.2: the gateway or tool executor (its identity, scheme:subject) that wrote this record for the run's client (source gateway, tier T2); the signer takes the call's later steps from it only"}}},"state_write":{"type":"object","additionalProperties":false,"required":["store","key","digest"],"properties":{"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>); absent on records written before salt_id existed"},"store":{"type":"string","maxLength":256},"key":{"type":"string","maxLength":256},"prev_digest":{"oneOf":[{"$ref":"#/$defs/commitment"},{"type":"null"}]},"digest":{"$ref":"#/$defs/commitment"}}},"signer_epoch":{"type":"object","additionalProperties":false,"required":["keys"],"properties":{"keys":{"type":"array","maxItems":16,"items":{"type":"object","additionalProperties":false,"required":["kid","alg","spki"],"properties":{"kid":{"$ref":"#/$defs/hash"},"alg":{"type":"string","maxLength":32,"pattern":"^[a-z0-9-]+$"},"spki":{"type":"string","maxLength":4096,"pattern":"^[A-Za-z0-9+/]+={0,2}$","description":"base64 DER SubjectPublicKeyInfo"},"cert":{"type":"object","additionalProperties":false,"required":["certificate","index","inclusion","checkpoint"],"description":"the key's issuance entry from a record-key issuer (tracekit.format.cert, docs/issuer.md)","properties":{"certificate":{"type":"object","additionalProperties":false,"required":["cert","sig"],"properties":{"cert":{"type":"object","additionalProperties":false,"required":["kid","spki","log_id","tenants","not_before","not_after","serial"],"properties":{"kid":{"$ref":"#/$defs/hash"},"spki":{"type":"string","maxLength":4096,"pattern":"^[A-Za-z0-9+/]+={0,2}$"},"log_id":{"type":"string","maxLength":64},"tenants":{"type":"array","maxItems":256,"items":{"type":"string","maxLength":256}},"not_before":{"type":"integer","minimum":0,"maximum":9007199254740991},"not_after":{"type":"integer","minimum":0,"maximum":9007199254740991},"serial":{"type":"string","maxLength":64,"pattern":"^[0-9a-f]+$"}}},"sig":{"type":"string","maxLength":128,"pattern":"^[A-Za-z0-9+/]+={0,2}$"}}},"index":{"type":"integer","minimum":0,"maximum":9007199254740991},"inclusion":{"type":"array","maxItems":64,"items":{"type":"string","maxLength":64,"pattern":"^[A-Za-z0-9+/]+={0,2}$"}},"checkpoint":{"type":"string","maxLength":16384}}}}}},"bridge":{"type":"object","additionalProperties":false,"required":["v1_kid","v1_last_seq","v1_head"],"description":"the v1 ledger this log continues: its key, the seq and hash of its last (key retirement) record","properties":{"v1_kid":{"type":"string","maxLength":24,"pattern":"^ed25519:[0-9a-f]{16}$"},"v1_last_seq":{"type":"integer","minimum":0,"maximum":9007199254740991},"v1_head":{"type":"string","maxLength":64,"pattern":"^[0-9a-f]{64}$"}}}}},"key_retire":{"type":"object","additionalProperties":false,"required":["kid","last_seq"],"properties":{"kid":{"$ref":"#/$defs/hash"},"last_seq":{"type":"integer","minimum":0,"maximum":9007199254740991}}},"log_closed":{"type":"object","additionalProperties":false,"required":["final_seq"],"properties":{"final_seq":{"type":"integer","minimum":0,"maximum":9007199254740991}}},"reconcile":{"type":"object","additionalProperties":false,"required":["layers"],"properties":{"layers":{"type":"array","maxItems":6,"items":{"enum":["L1","L2","L3","L4","L5","L6"]}},"detail":{"type":"string","maxLength":1024}}},"refusal_summary":{"type":"object","additionalProperties":false,"required":["code","count","from_ts","to_ts"],"properties":{"code":{"type":"string","maxLength":64},"count":{"type":"integer","minimum":1,"maximum":9007199254740991},"from_ts":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":4096},"to_ts":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":4096},"identity":{"type":"string","maxLength":256}}},"policy_external":{"type":"object","additionalProperties":false,"required":["system","decision","tool_use_id","tool","rule_ids","signature","record","identity","salt_id"],"description":"Another system's decision about a tool call, imported by an identity authorized for decision_import (tier T3). The signer's own policy.decision stays authoritative.","properties":{"system":{"type":"string","minLength":1,"maxLength":64},"decision":{"enum":["allow","deny","ask"]},"tool_use_id":{"$ref":"#/$defs/id"},"tool":{"type":"string","minLength":1,"maxLength":256},"rule_ids":{"type":"array","items":{"type":"string","maxLength":64},"maxItems":32},"reason":{"type":"string","maxLength":4096,"description":"the system's reason, redacted"},"signature":{"enum":["verified","unverified"],"description":"verified: the system's Ed25519 signature over its record holds under the key the signer's config pins for it"},"record":{"type":"object","additionalProperties":false,"required":["hash","size"],"description":"the system's own record: an HMAC of its sha256 under this record's salt (salt_id), and its size in bytes","properties":{"hash":{"type":"string","pattern":"^hmac-sha256:[0-9a-f]{64}$","maxLength":76},"size":{"type":"integer","minimum":0,"maximum":9007199254740991}}},"identity":{"type":"object","additionalProperties":false,"required":["scheme","subject","attested"],"properties":{"scheme":{"enum":["uid","harness","token","k8s_sa","mtls","oidc"]},"subject":{"type":"string","maxLength":256},"attested":{"type":"boolean"}}},"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>)"}}},"content_accessed":{"type":"object","additionalProperties":false,"required":["principal","run_id","seqs","purpose"],"description":"The signer returned retained content of records of a run of this tenant (format 1.1; content_get): who asked, the run, its run_seqs and why. Never the content.","properties":{"principal":{"type":"string","maxLength":256},"run_id":{"type":"string","maxLength":128},"seqs":{"type":"array","maxItems":1000,"items":{"type":"integer","minimum":0,"maximum":9007199254740991}},"purpose":{"type":"string","maxLength":512}}},"content_erased":{"type":"object","additionalProperties":false,"required":["run_id","reason","by"],"description":"The signer deleted the retained content of a run of this tenant (format 1.1; content_erase or retention); the run's records are unchanged.","properties":{"run_id":{"type":"string","maxLength":128},"reason":{"type":"string","maxLength":1024},"by":{"type":"string","maxLength":256}}},"why_finding":{"type":"object","additionalProperties":false,"required":["target","intervention","n","p_with","p_without","effect","interval","verdict","replay_runs","models","rung","off_tape","investigation","looks","family","min_effect","applied","stopped"],"description":"A finished tracekit why test (format 1.1; why_finding), written in a findings run about the run it analyses: how often the target happened in the control arm (p_with) and with the intervention (p_without) over n paired replays, the interval and verdict with what they were decided on (looks: the sequential test's planned looks, family: the tests corrected for together, min_effect, applied: whether the intervention matched anything, stopped: why the test ended), every replay run with its arm and outcome, and the replay runs of the investigation it does not count (uncounted: the surrogate's ranking calls, arms a budget stop abandoned). rung 1: one recorded model call re-sent; rung 2: the agent loop re-simulated from the recorded run.","properties":{"target":{"type":"string","maxLength":1024},"intervention":{"type":"string","maxLength":1024},"n":{"type":"integer","minimum":0,"maximum":9007199254740991},"p_with":{"type":"number","minimum":0,"maximum":1},"p_without":{"type":"number","minimum":0,"maximum":1},"effect":{"type":"number","minimum":-1,"maximum":1},"interval":{"type":"array","minItems":2,"maxItems":2,"items":{"type":"number","minimum":-1,"maximum":1}},"verdict":{"enum":["causal","suppressive","ruled-out","inconclusive","not-applied"]},"replay_runs":{"type":"array","maxItems":4096,"items":{"type":"object","additionalProperties":false,"required":["run_id","arm","hit"],"properties":{"run_id":{"$ref":"#/$defs/id"},"arm":{"enum":["control","ablated"]},"hit":{"type":"boolean"}}}},"models":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":128}},"rung":{"enum":[1,2]},"off_tape":{"type":"integer","minimum":0,"maximum":9007199254740991},"investigation":{"$ref":"#/$defs/id"},"looks":{"type":"integer","minimum":1,"maximum":4096},"family":{"type":"integer","minimum":1,"maximum":4096},"min_effect":{"type":"number","minimum":0,"maximum":1},"applied":{"type":"boolean"},"stopped":{"enum":["decisive","futility","n_max","budget"]},"uncounted":{"type":"array","maxItems":4096,"items":{"type":"object","additionalProperties":false,"required":["run_id","reason"],"properties":{"run_id":{"$ref":"#/$defs/id"},"reason":{"enum":["ranking","budget"]}}}}}},"incident_opened":{"type":"object","additionalProperties":false,"required":["incident_id","trigger","rule_ids","scope","cites","mode","actions","would"],"description":"format 1.2: a trigger of the policy's detection fired (a sequence completed, a limit was exceeded, or a respond trigger), an operator held or quarantined a run (trigger operator), or the detector could not vouch for a decision under an enforced response (detector_unavailable); one open incident per scope, trigger and rule, later matches are incident.updated. mode shadow: no action taken, `would` the configured one; mode enforce: `actions` the ones taken (none while the response engine is paused: then `would` and `reason` say so)","properties":{"incident_id":{"$ref":"#/$defs/id"},"trigger":{"enum":["sequence","limit","executed_against_policy","denied_calls","provenance","class_mismatch","refusal_burst","tamper","operator","detector_unavailable","unexplained_effect"]},"rule_ids":{"type":"array","maxItems":1024,"items":{"type":"string","maxLength":256}},"scope":{"type":"object","additionalProperties":false,"required":["kind","id"],"properties":{"kind":{"enum":["run","session","owner","principal"]},"id":{"type":"string","maxLength":256}}},"cites":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["run_id","run_seq"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":200},"run_seq":{"type":"integer","minimum":0}}},"description":"the records that make up the match"},"mode":{"enum":["shadow","enforce"]},"actions":{"type":"array","maxItems":3,"items":{"enum":["notify","hold","quarantine"]}},"would":{"type":"array","maxItems":4,"items":{"enum":["notify","hold","quarantine","suspend"]}},"reason":{"type":"string","maxLength":1024,"description":"why the actions differ from the configured one (paused, no person approvers), or an operator's reason"},"in_flight":{"type":"array","maxItems":1024,"description":"format 1.2: calls allowed (or approved and consumed) and not completed when the response took effect: they may complete after it","items":{"type":"object","additionalProperties":false,"required":["run_id","tool_call_id","decision_id"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":200},"tool_call_id":{"$ref":"#/$defs/id"},"decision_id":{"$ref":"#/$defs/id"}}}}}},"incident_updated":{"type":"object","additionalProperties":false,"required":["incident_id","cites"],"description":"format 1.2: another match of an open incident (at most one per window of its trigger), written in the run whose record matched","properties":{"incident_id":{"$ref":"#/$defs/id"},"cites":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["run_id","run_seq"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":200},"run_seq":{"type":"integer","minimum":0}}}}}},"incident_capped":{"type":"object","additionalProperties":false,"required":["cap","max","trigger","reason"],"description":"format 1.2: a cap on opened incidents was reached: the run's per trigger (cap run), or per minute the tenant's or one run's share of it (cap tenant); the incidents after it are not written","properties":{"cap":{"enum":["run","tenant"]},"trigger":{"enum":["sequence","limit","executed_against_policy","denied_calls","provenance","class_mismatch","refusal_burst","tamper","operator","detector_unavailable","unexplained_effect"],"description":"the trigger of the first incident not written"},"max":{"type":"integer","minimum":1},"reason":{"type":"string","maxLength":512}}},"run_held":{"type":"object","additionalProperties":false,"required":["incident_id","scope","by","reason"],"description":"format 1.2: the run is held: every further call is asked of a person (TK-HOLD) and its live approvals expired; written in each run the incident's scope reaches","properties":{"incident_id":{"$ref":"#/$defs/id"},"scope":{"type":"object","additionalProperties":false,"required":["kind","id"],"properties":{"kind":{"enum":["run","session","owner","principal"]},"id":{"type":"string","maxLength":256}}},"by":{"type":"string","maxLength":256,"description":"signer, or the operator's identity"},"reason":{"type":"string","maxLength":1024},"in_flight":{"type":"array","maxItems":1024,"description":"format 1.2: calls allowed (or approved and consumed) and not completed when the response took effect: they may complete after it","items":{"type":"object","additionalProperties":false,"required":["run_id","tool_call_id","decision_id"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":200},"tool_call_id":{"$ref":"#/$defs/id"},"decision_id":{"$ref":"#/$defs/id"}}}},"inherited":{"const":true,"description":"a new run of a held owner and principal (or of a held parent run) took the hold over"},"inherit_until":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27,"description":"until when new runs of this run's owner and attested principal inherit the hold"}}},"run_resumed":{"type":"object","additionalProperties":false,"required":["incident_id","by","person","held_by"],"description":"format 1.2: a person ended the hold of one incident on the run (run_resume, which ends each, or incident_resolve of that incident); held_by: the incidents that still hold it","properties":{"incident_id":{"$ref":"#/$defs/id"},"by":{"type":"string","maxLength":256},"person":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024},"break_glass":{"const":true},"held_by":{"type":"array","maxItems":1024,"items":{"$ref":"#/$defs/id"}}}},"run_quarantined":{"type":"object","additionalProperties":false,"required":["incident_id","scope","by","reason"],"description":"format 1.2: the run is quarantined for good: decisions are deny (TK-QUARANTINE), approvals are refused, a gateway forwards no more model requests","properties":{"incident_id":{"$ref":"#/$defs/id"},"scope":{"type":"object","additionalProperties":false,"required":["kind","id"],"properties":{"kind":{"enum":["run","session","owner","principal"]},"id":{"type":"string","maxLength":256}}},"by":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024},"in_flight":{"type":"array","maxItems":1024,"description":"format 1.2: calls allowed (or approved and consumed) and not completed when the response took effect: they may complete after it","items":{"type":"object","additionalProperties":false,"required":["run_id","tool_call_id","decision_id"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":200},"tool_call_id":{"$ref":"#/$defs/id"},"decision_id":{"$ref":"#/$defs/id"}}}},"inherited":{"const":true},"inherit_until":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27,"description":"until when new runs of this run's owner and attested principal inherit the hold"}}},"incident_resolved":{"type":"object","additionalProperties":false,"required":["incident_id","by","person"],"description":"format 1.2: a person resolved the incident (in the tenant's signer run): its held runs resume, quarantines stay","properties":{"incident_id":{"$ref":"#/$defs/id"},"by":{"type":"string","maxLength":256},"person":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024},"break_glass":{"const":true},"resumed":{"type":"array","maxItems":4096,"items":{"type":"string","maxLength":200}}}},"incident_response":{"type":"object","additionalProperties":false,"required":["enabled","by"],"description":"format 1.2: an operator paused (enabled false) or resumed the tenant's response engine; detection keeps recording","properties":{"enabled":{"type":"boolean"},"by":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024}}},"identity_suspension":{"type":"object","additionalProperties":false,"required":["identity","state","by"],"description":"format 1.2: an identity of the tenant is suspended by an operator (written by the signer that changed the shared list), enforced (each signer that starts enforcing an entry) or lifted","properties":{"identity":{"type":"string","maxLength":256},"state":{"enum":["suspended","enforced","lifted"]},"by":{"type":"string","maxLength":256},"reason":{"type":"string","maxLength":1024}}},"effect_observed":{"type":"object","additionalProperties":false,"required":["observer","kind","host","port","bytes_out","bytes_in","connections","first_ts","last_ts","attribution"],"description":"format 1.2: network traffic an egress observer outside the agent saw for the run (tracekit.egress), aggregated per host, port and minute; signer-written","properties":{"observer":{"type":"string","maxLength":256,"description":"the egress observer's signer identity (scheme:subject)"},"kind":{"enum":["net"]},"host":{"type":"string","minLength":1,"maxLength":253},"port":{"type":"integer","minimum":1,"maximum":65535},"bytes_out":{"type":"integer","minimum":0,"maximum":9007199254740991},"bytes_in":{"type":"integer","minimum":0,"maximum":9007199254740991},"connections":{"type":"integer","minimum":0,"maximum":9007199254740991},"first_ts":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27},"last_ts":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27},"attribution":{"enum":["token","identity"],"description":"token: the client presented the run's token; identity: the observer authenticated the run's owner and the signer picked that owner's latest open run"},"explained_by":{"enum":["call","explains"],"description":"what explains the host: a recorded http or model call to it, or the policy's explains; absent: unexplained (a reconcile.unexplained_effect follows)"}}},"sensor_effect":{"type":"object","additionalProperties":false,"required":["observer","sensor","kind","ts","pid","binary","attribution"],"description":"format 1.4: what a kernel sensor (Tetragon, Falco) reported a process of the run did, or what OpenShell reported of its sandbox (its connections allowed and blocked, its entrypoint's start), imported under the import's own signer identity (an observer's assertion); signer-written","properties":{"observer":{"type":"string","maxLength":256,"description":"the import's signer identity (scheme:subject)"},"sensor":{"enum":["tetragon","falco","openshell"]},"kind":{"enum":["exec","exit","connect","file"],"description":"exec: the process started; exit: it ended; connect: it connected to host:port; file: the sensor's policy selected a file event of it (a write outside the workspace, in the shipped samples)"},"ts":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$","maxLength":27},"pid":{"type":"integer","minimum":0,"maximum":4294967295},"binary":{"type":"string","minLength":1,"maxLength":4096},"exec_id":{"type":"string","minLength":1,"maxLength":256,"description":"the sensor's id of the process (Tetragon's exec_id)"},"parent_exec_id":{"type":"string","minLength":1,"maxLength":256,"description":"exec: the sensor's id of the process that started this one (Tetragon's parent_exec_id)"},"args_commitment":{"$ref":"#/$defs/commitment","description":"commitment to sha256 of the arguments string as the sensor reported it"},"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitments (salt label <type>:<salt_id>)"},"status":{"type":"integer","minimum":0,"maximum":4294967295},"signal":{"type":"string","maxLength":32},"host":{"type":"string","minLength":1,"maxLength":253},"port":{"type":"integer","minimum":0,"maximum":65535},"path":{"type":"string","minLength":1,"maxLength":4096},"tool_call_id":{"$ref":"#/$defs/id","description":"the tool call id the process's environment carried (CODEX_TOOL_CALL_ID): the agent's claim, recorded as it came and checked by no signer (TK-X206 checks it)"},"denied":{"const":true,"description":"the sensor (OpenShell's network policy) blocked it"},"attribution":{"enum":["token","identity"],"description":"identity: the import mapped the process to the run's owner and the signer picked that owner's latest open run"}}},"reconcile_effect":{"type":"object","additionalProperties":false,"required":["layers","observer","kind","host"],"description":"format 1.2: a host an observer saw the run reach that no record of the run explains","properties":{"layers":{"type":"array","maxItems":6,"items":{"enum":["L1","L2","L3","L4","L5","L6"]}},"detail":{"type":"string","maxLength":1024},"observer":{"type":"string","maxLength":256,"description":"the egress observer's signer identity (scheme:subject)"},"kind":{"enum":["net"]},"host":{"type":"string","minLength":1,"maxLength":253},"port":{"type":"integer","minimum":1,"maximum":65535}}},"coverage_class":{"type":"object","additionalProperties":false,"required":["status","layers"],"properties":{"status":{"enum":["enforced","observed","reported","none"]},"layers":{"type":"array","maxItems":8,"items":{"type":"string","maxLength":32}},"asserted":{"const":true,"description":"the status rests on the operator's posture (signer.yaml), not on anything the signer measured"}}},"constraint_added":{"type":"object","additionalProperties":false,"required":["kind"],"description":"format 1.4: a live constraint of the run (constraint_add): what the person's request said not to do in the rest of it, as its client reported it (the Claude Code hook, at UserPromptSubmit). From then on the signer raises a call that breaks it to an ask (rule TK-CONSTRAINT) and never lowers a decision, so a made-up constraint can only hold more calls","properties":{"kind":{"enum":["keep_away","only_change","read_only","no_push","no_network"],"description":"keep_away: writes of the file; only_change: writes of any other file; read_only: any write; no_push: a git push; no_network: a call that reaches the network"},"object_commitment":{"$ref":"#/$defs/commitment","description":"keep_away and only_change: commitment to sha256 of the file's lower-cased basename; never the name"},"salt_id":{"$ref":"#/$defs/id","description":"the signer's salt id for this record's commitment (salt label <type>:<salt_id>)"}}},"ext_name":{"type":"string","pattern":"^[a-z][a-z0-9_.-]*$","maxLength":64,"description":"The name of a non-critical field or record (format 1.4)."},"ext":{"type":"object","required":["name"],"properties":{"name":{"$ref":"#/$defs/ext_name"}},"description":"A non-critical record (format 1.4): its name and any other strict JSON fields, checked as the ext field is."}}};
|
|
805
|
+
const KEYWORDS = new Set(["type", "const", "enum", "required", "properties", "additionalProperties", "propertyNames",
|
|
806
|
+
"items", "oneOf", "allOf", "if", "then", "$ref", "pattern", "minimum", "maximum", "minLength", "maxLength", "minItems",
|
|
807
|
+
"maxItems", "$schema", "$id", "$defs", "title", "description"]);
|
|
808
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
809
|
+
const isNumber = (v) => typeof v === "number";
|
|
810
|
+
const PATTERNS = new Map();
|
|
811
|
+
/** JSON equality with numbers and booleans compared as numbers, as the reference's Python does. */
|
|
812
|
+
function same(a, b) {
|
|
813
|
+
if ((isNumber(a) || typeof a === "boolean") && (isNumber(b) || typeof b === "boolean"))
|
|
814
|
+
return Number(a) === Number(b);
|
|
815
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
816
|
+
return a.length === b.length && a.every((x, i) => same(x, b[i]));
|
|
817
|
+
if (isObject(a) && isObject(b)) {
|
|
818
|
+
const k = Object.keys(a);
|
|
819
|
+
return k.length === Object.keys(b).length && k.every((x) => Object.hasOwn(b, x) && same(a[x], b[x]));
|
|
820
|
+
}
|
|
821
|
+
return a === b;
|
|
822
|
+
}
|
|
823
|
+
function check(v, s, path, errs, float, floats) {
|
|
824
|
+
const unknown = Object.keys(s).filter((k) => !KEYWORDS.has(k));
|
|
825
|
+
if (unknown.length)
|
|
826
|
+
throw new Error(`unknown schema keyword(s) ${unknown} at ${path}`);
|
|
827
|
+
if ("$ref" in s)
|
|
828
|
+
return check(v, SCHEMA.$defs[s.$ref.slice("#/$defs/".length)], path, errs, float, floats);
|
|
829
|
+
if ("type" in s) {
|
|
830
|
+
const ts = Array.isArray(s.type) ? s.type : [s.type];
|
|
831
|
+
const is = (t) => t === "integer" ? isNumber(v) && Number.isInteger(v) && !float : t === "number" ? isNumber(v)
|
|
832
|
+
: t === "object" ? isObject(v) : t === "array" ? Array.isArray(v) : t === "null" ? v === null : typeof v === t;
|
|
833
|
+
if (!ts.some(is))
|
|
834
|
+
return errs.push(`${path}: expected ${ts.join("/")}`);
|
|
835
|
+
}
|
|
836
|
+
if ("const" in s && !same(v, s.const))
|
|
837
|
+
errs.push(`${path}: must be ${JSON.stringify(s.const)}`);
|
|
838
|
+
if ("enum" in s && !s.enum.some((x) => same(v, x)))
|
|
839
|
+
errs.push(`${path}: ${JSON.stringify(v)} not in ${JSON.stringify(s.enum)}`);
|
|
840
|
+
if (typeof v === "string") {
|
|
841
|
+
if ("pattern" in s) {
|
|
842
|
+
if (!PATTERNS.has(s.pattern))
|
|
843
|
+
PATTERNS.set(s.pattern, new RegExp(`^(?:${s.pattern})$`));
|
|
844
|
+
if (!PATTERNS.get(s.pattern).test(v))
|
|
845
|
+
errs.push(`${path}: does not match ${s.pattern}`);
|
|
846
|
+
}
|
|
847
|
+
const n = [...v].length;
|
|
848
|
+
if ("minLength" in s && n < s.minLength)
|
|
849
|
+
errs.push(`${path}: shorter than ${s.minLength}`);
|
|
850
|
+
if ("maxLength" in s && n > s.maxLength)
|
|
851
|
+
errs.push(`${path}: longer than ${s.maxLength}`);
|
|
852
|
+
}
|
|
853
|
+
if (isNumber(v) && "minimum" in s && v < s.minimum)
|
|
854
|
+
errs.push(`${path}: below ${s.minimum}`);
|
|
855
|
+
if (isNumber(v) && "maximum" in s && v > s.maximum)
|
|
856
|
+
errs.push(`${path}: above ${s.maximum}`);
|
|
857
|
+
if (Array.isArray(v) && "maxItems" in s && v.length > s.maxItems)
|
|
858
|
+
errs.push(`${path}: more than ${s.maxItems} items`);
|
|
859
|
+
if (Array.isArray(v) && "minItems" in s && v.length < s.minItems)
|
|
860
|
+
errs.push(`${path}: fewer than ${s.minItems} items`);
|
|
861
|
+
if (isObject(v)) {
|
|
862
|
+
for (const k of s.required ?? [])
|
|
863
|
+
if (!Object.hasOwn(v, k))
|
|
864
|
+
errs.push(`${path}: missing required '${k}'`);
|
|
865
|
+
const props = s.properties ?? {}, ap = s.additionalProperties ?? true;
|
|
866
|
+
for (const [k, x] of Object.entries(v)) {
|
|
867
|
+
const f = floats.get(v)?.has(k) ?? false;
|
|
868
|
+
if (Object.hasOwn(props, k))
|
|
869
|
+
check(x, props[k], `${path}.${k}`, errs, f, floats);
|
|
870
|
+
else if (ap === false)
|
|
871
|
+
errs.push(`${path}: unexpected field '${k}'`);
|
|
872
|
+
else if (isObject(ap))
|
|
873
|
+
check(x, ap, `${path}.${k}`, errs, f, floats);
|
|
874
|
+
if ("propertyNames" in s)
|
|
875
|
+
check(k, s.propertyNames, `${path}.${k}`, errs, false, floats);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (Array.isArray(v) && "items" in s)
|
|
879
|
+
v.forEach((x, i) => check(x, s.items, `${path}[${i}]`, errs, floats.get(v)?.has(i) ?? false, floats));
|
|
880
|
+
if ("oneOf" in s) {
|
|
881
|
+
const matched = s.oneOf.filter((sub) => {
|
|
882
|
+
const e = [];
|
|
883
|
+
check(v, sub, path, e, float, floats);
|
|
884
|
+
return !e.length;
|
|
885
|
+
}).length;
|
|
886
|
+
if (matched !== 1)
|
|
887
|
+
errs.push(`${path}: must match exactly one alternative (matched ${matched})`);
|
|
888
|
+
}
|
|
889
|
+
for (const sub of s.allOf ?? [])
|
|
890
|
+
check(v, sub, path, errs, float, floats);
|
|
891
|
+
if ("if" in s) {
|
|
892
|
+
const e = [];
|
|
893
|
+
check(v, s.if, path, e, float, floats);
|
|
894
|
+
if (!e.length && "then" in s)
|
|
895
|
+
check(v, s.then, path, errs, float, floats);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/** Error strings of a tracekit.event.v2 event (empty when valid); `floats` from strictParse. */
|
|
899
|
+
function validateEvent(event, floats) {
|
|
900
|
+
const errs = [];
|
|
901
|
+
check(event, SCHEMA, "event", errs, false, floats);
|
|
902
|
+
return errs;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return { same, validateEvent };
|
|
906
|
+
})();
|
|
907
|
+
const __zip = (() => {
|
|
908
|
+
/**
|
|
909
|
+
* The zip reader of docs/format-v2.md §9: one canonical layout, stored and deflated entries (Web Crypto's sibling
|
|
910
|
+
* DecompressionStream), no zip64, under the bundle limits. Names that could escape a directory, symlinks and duplicate
|
|
911
|
+
* entries are refused.
|
|
912
|
+
*/
|
|
913
|
+
const { repr: repr } = __jcs;
|
|
914
|
+
const MAX_ENTRIES = 10_000;
|
|
915
|
+
const MAX_ENTRY = 64 << 20;
|
|
916
|
+
const MAX_TOTAL = 512 << 20;
|
|
917
|
+
const NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*(\/[A-Za-z0-9._-]+)*$/;
|
|
918
|
+
class Unusable extends Error {
|
|
919
|
+
}
|
|
920
|
+
/** An entry whose bytes don't match its CRC-32: Python's zipfile names it so, and the reports print the name. */
|
|
921
|
+
class BadZipFile extends Unusable {
|
|
922
|
+
}
|
|
923
|
+
let CRC;
|
|
924
|
+
function crc32(data) {
|
|
925
|
+
if (!CRC) {
|
|
926
|
+
CRC = new Uint32Array(256);
|
|
927
|
+
for (let n = 0; n < 256; n++) {
|
|
928
|
+
let c = n;
|
|
929
|
+
for (let k = 0; k < 8; k++)
|
|
930
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
931
|
+
CRC[n] = c >>> 0;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
let c = 0xffffffff;
|
|
935
|
+
for (const b of data)
|
|
936
|
+
c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
937
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
938
|
+
}
|
|
939
|
+
/** The first `limit` bytes a raw deflate stream inflates to; stops reading there, so a zip bomb costs nothing. */
|
|
940
|
+
async function inflate(raw, limit) {
|
|
941
|
+
const reader = new Blob([raw]).stream().pipeThrough(new DecompressionStream("deflate-raw")).getReader();
|
|
942
|
+
const parts = [];
|
|
943
|
+
let n = 0;
|
|
944
|
+
while (n < limit) {
|
|
945
|
+
const { done, value } = await reader.read();
|
|
946
|
+
if (done)
|
|
947
|
+
break;
|
|
948
|
+
parts.push(value);
|
|
949
|
+
n += value.length;
|
|
950
|
+
}
|
|
951
|
+
if (n >= limit)
|
|
952
|
+
await reader.cancel().catch(() => { });
|
|
953
|
+
const out = new Uint8Array(n);
|
|
954
|
+
let at = 0;
|
|
955
|
+
for (const p of parts)
|
|
956
|
+
out.set(p, (at += p.length) - p.length);
|
|
957
|
+
return out.subarray(0, limit);
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* {name: bytes} of a zip in the one layout of docs/format-v2.md §9, entry by entry under the count and size caps: the
|
|
961
|
+
* end record ends the file, with no comment; the central directory sits between the last entry and it; the entries are
|
|
962
|
+
* contiguous from offset 0, each local header repeating its central entry; no zip64, extra or comment field, or
|
|
963
|
+
* general-purpose flag.
|
|
964
|
+
*/
|
|
965
|
+
async function readZip(data) {
|
|
966
|
+
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
967
|
+
const need = (at, n) => {
|
|
968
|
+
if (at < 0 || at + n > data.length)
|
|
969
|
+
throw new Unusable("truncated zip");
|
|
970
|
+
};
|
|
971
|
+
const eocd = data.length - 22;
|
|
972
|
+
if (eocd < 0 || v.getUint32(eocd, true) !== 0x06054b50)
|
|
973
|
+
throw new Unusable("not a zip file, or bytes after its end record");
|
|
974
|
+
const count = v.getUint16(eocd + 10, true), cdSize = v.getUint32(eocd + 12, true), cdOffset = v.getUint32(eocd + 16, true);
|
|
975
|
+
if (v.getUint16(eocd + 4, true) || v.getUint16(eocd + 6, true) || v.getUint16(eocd + 8, true) !== count
|
|
976
|
+
|| v.getUint16(eocd + 20, true) || cdOffset + cdSize !== eocd)
|
|
977
|
+
throw new Unusable("not the canonical zip layout: end record");
|
|
978
|
+
if (count > MAX_ENTRIES)
|
|
979
|
+
throw new Unusable("too many entries");
|
|
980
|
+
const entries = [];
|
|
981
|
+
for (let at = cdOffset; at < eocd;) {
|
|
982
|
+
if (at + 46 > eocd)
|
|
983
|
+
throw new Unusable("truncated zip");
|
|
984
|
+
const n = v.getUint16(at + 28, true);
|
|
985
|
+
if (v.getUint32(at, true) !== 0x02014b50 || v.getUint16(at + 30, true) || v.getUint16(at + 32, true) || at + 46 + n > eocd)
|
|
986
|
+
throw new Unusable("bad central directory entry");
|
|
987
|
+
const raw = data.subarray(at + 46, at + 46 + n);
|
|
988
|
+
entries.push({ name: new TextDecoder().decode(raw), raw, flags: v.getUint16(at + 8, true),
|
|
989
|
+
method: v.getUint16(at + 10, true), crc: v.getUint32(at + 16, true), csize: v.getUint32(at + 20, true),
|
|
990
|
+
usize: v.getUint32(at + 24, true), attr: v.getUint32(at + 38, true), local: v.getUint32(at + 42, true) });
|
|
991
|
+
at += 46 + n;
|
|
992
|
+
}
|
|
993
|
+
if (entries.length !== count)
|
|
994
|
+
throw new Unusable("not the canonical zip layout: entry count");
|
|
995
|
+
let pos = 0;
|
|
996
|
+
for (const e of entries) {
|
|
997
|
+
const n = repr(e.name);
|
|
998
|
+
if (e.csize === 0xffffffff || e.usize === 0xffffffff || e.local === 0xffffffff)
|
|
999
|
+
throw new Unusable("zip64 is not supported");
|
|
1000
|
+
if (e.method !== 0 && e.method !== 8 || e.method === 0 && e.csize !== e.usize)
|
|
1001
|
+
throw new Unusable(`${n}: compression method ${e.method} (only stored and deflate)`);
|
|
1002
|
+
if (e.flags & 1)
|
|
1003
|
+
throw new Unusable(`${n} is encrypted`);
|
|
1004
|
+
if (e.flags)
|
|
1005
|
+
throw new Unusable(`${n}: general-purpose flags 0x${e.flags.toString(16)}`);
|
|
1006
|
+
if (e.local !== pos)
|
|
1007
|
+
throw new Unusable(`not the canonical zip layout: ${n} does not follow the entry before it`);
|
|
1008
|
+
need(e.local, 30 + e.raw.length);
|
|
1009
|
+
const local = data.subarray(e.local + 30, e.local + 30 + e.raw.length);
|
|
1010
|
+
if (v.getUint32(e.local, true) !== 0x04034b50 || v.getUint16(e.local + 6, true) !== e.flags
|
|
1011
|
+
|| v.getUint16(e.local + 8, true) !== e.method || v.getUint32(e.local + 14, true) !== e.crc
|
|
1012
|
+
|| v.getUint32(e.local + 18, true) !== e.csize || v.getUint32(e.local + 22, true) !== e.usize
|
|
1013
|
+
|| v.getUint16(e.local + 26, true) !== e.raw.length || v.getUint16(e.local + 28, true)
|
|
1014
|
+
|| local.some((b, i) => b !== e.raw[i]))
|
|
1015
|
+
throw new Unusable(`bad local header for ${n}`);
|
|
1016
|
+
pos = e.local + 30 + e.raw.length + e.csize;
|
|
1017
|
+
}
|
|
1018
|
+
if (pos !== cdOffset)
|
|
1019
|
+
throw new Unusable("not the canonical zip layout: bytes before the central directory");
|
|
1020
|
+
const files = new Map();
|
|
1021
|
+
let total = 0;
|
|
1022
|
+
for (const e of entries) {
|
|
1023
|
+
const n = e.name;
|
|
1024
|
+
if (!NAME.test(n) || n.split("/").some((p) => /^\.+$/.test(p)))
|
|
1025
|
+
throw new Unusable(`bad entry name ${repr(n.slice(0, 80))}`);
|
|
1026
|
+
if (files.has(n))
|
|
1027
|
+
throw new Unusable(`duplicate entry ${n}`);
|
|
1028
|
+
if (((e.attr >>> 16) & 0o170000) === 0o120000)
|
|
1029
|
+
throw new Unusable(`symlink entry ${n}`);
|
|
1030
|
+
const start = e.local + 30 + e.raw.length;
|
|
1031
|
+
need(start, e.csize);
|
|
1032
|
+
const body = data.subarray(start, start + e.csize);
|
|
1033
|
+
const cap = Math.min(MAX_ENTRY, MAX_TOTAL - total);
|
|
1034
|
+
const limit = Math.min(e.usize, cap + 1);
|
|
1035
|
+
let out;
|
|
1036
|
+
if (e.method === 0)
|
|
1037
|
+
out = body.subarray(0, limit);
|
|
1038
|
+
else {
|
|
1039
|
+
try {
|
|
1040
|
+
out = await inflate(body, limit);
|
|
1041
|
+
}
|
|
1042
|
+
catch (x) {
|
|
1043
|
+
// lean: the decompressor's own message, which differs from zlib's in the reference verifier's report
|
|
1044
|
+
throw new Unusable(`${n}: ${x.message}`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (out.length > cap)
|
|
1048
|
+
throw new Unusable(`${n} is larger than the bundle limits`);
|
|
1049
|
+
if (crc32(out) !== e.crc)
|
|
1050
|
+
throw new BadZipFile(`Bad CRC-32 for file ${repr(n)}`); // first, as zipfile does
|
|
1051
|
+
if (out.length !== e.usize)
|
|
1052
|
+
throw new Unusable(`${n}: ${out.length} bytes, not the ${e.usize} its entry says`);
|
|
1053
|
+
files.set(n, out);
|
|
1054
|
+
total += out.length;
|
|
1055
|
+
}
|
|
1056
|
+
return files;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return { MAX_ENTRIES, MAX_ENTRY, MAX_TOTAL, Unusable, readZip };
|
|
1060
|
+
})();
|
|
1061
|
+
const __index = (() => {
|
|
1062
|
+
/**
|
|
1063
|
+
* An independent verifier of `tracekit.bundle.v2` bundles, written from docs/format-v2.md (§11 is the algorithm) and
|
|
1064
|
+
* checked against the reference (tracekit/verify/v2.py) by tests/test_ts_verifier.py: same checks, integrity, assurance
|
|
1065
|
+
* and exit codes. Web Crypto and DecompressionStream only, so it runs in Node ≥ 20 and current browsers.
|
|
1066
|
+
*
|
|
1067
|
+
* import { verify } from "@cygnuxlabs/tracekit/verify";
|
|
1068
|
+
* const { report, code } = await verify(bundleBytes, trustConfigBytes);
|
|
1069
|
+
*
|
|
1070
|
+
* Not implemented here, so never VERIFIED when a bundle relies on them (integrity `UNVERIFIABLE (...)` names them):
|
|
1071
|
+
* SLH-DSA-SHA2-128s hybrid checkpoint lines (Web Crypto has no SLH-DSA), Rekor v2 anchors, record-key certificates,
|
|
1072
|
+
* tracekit why findings and replay runs.
|
|
1073
|
+
* Monitor reports, revocations and the v1 format bridge are inputs of the reference's CLI that this one doesn't take.
|
|
1074
|
+
* A bundle that needs a newer verifier is UNVERIFIABLE, with the commands that verify it; one whose records carry ext
|
|
1075
|
+
* names this verifier doesn't interpret (format 1.4's non-critical slot) verifies, with a `not interpreted` warning
|
|
1076
|
+
* per name; it interprets `amended` (the fields an LLM gateway changed in a request to show reasoning) and
|
|
1077
|
+
* `openshell.policy` (OpenShell's sandbox policy changes, from the OpenShell middleware's OCSF import).
|
|
1078
|
+
* `verify(bundle, null)` (no trust config) checks what needs no pinned key and is never VERIFIED. web.ts is this
|
|
1079
|
+
* verifier as a browser page (verify.html).
|
|
1080
|
+
*/
|
|
1081
|
+
const { canonicalize: canonicalize, repr: quoted, strictParseBytes: strictParseBytes } = __jcs;
|
|
1082
|
+
const { HYBRID: HYBRID, NoteError: NoteError, SIGNER_LEAVES: SIGNER_LEAVES, b64decode: b64decode, bytes: bytes, equal: equal, keyAlg: keyAlg, leafHash: leafHash, openNote: openNote, parseLeaf: parseLeaf, parseVkey: parseVkey, registryOrigin: registryOrigin, runHash: runHash, sha256hex: sha256hex, spkiKid: spkiKid, unhex: unhex, verifyConsistency: verifyConsistency, verifyInclusion: verifyInclusion, verifyV2: verifyV2, vkey: vkey } = __primitives;
|
|
1083
|
+
const { Detector: Detector } = __detect;
|
|
1084
|
+
const { same: same, validateEvent: validateEvent } = __schema;
|
|
1085
|
+
const { MAX_ENTRY: MAX_ENTRY, Unusable: Unusable, readZip: readZip } = __zip;
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
/** The bundle format version this verifier implements: a bundle needing a later one is UNVERIFIABLE. */
|
|
1089
|
+
const VERSION = "1.4.1";
|
|
1090
|
+
/** The browser verifier of the latest release (docs/RELEASING.md). */
|
|
1091
|
+
const PAGE = "https://cygnux-labs.github.io/Tracekit/verify/";
|
|
1092
|
+
const EXIT_OK = 0, EXIT_FAIL = 1, EXIT_BAD = 2, EXIT_WARN = 3;
|
|
1093
|
+
const FORMAT = "tracekit.bundle.v2";
|
|
1094
|
+
const MAX_LINE = 1 << 20;
|
|
1095
|
+
const MIN_RECORD = 256; // no signed record's line is shorter: its hash, signature, ids and timestamp alone are longer
|
|
1096
|
+
const MAX_LINES = MAX_ENTRY / MIN_RECORD; // per JSON lines file
|
|
1097
|
+
const CLASSES = ["public", "customer", "tracekit", "operator"];
|
|
1098
|
+
const KEY_TYPES = ["signer.epoch", "key.retire"];
|
|
1099
|
+
const ZERO_HASH = "sha256:" + "0".repeat(64);
|
|
1100
|
+
const MATRIX = ["model_calls", "tool_calls", "network", "processes", "services"];
|
|
1101
|
+
const COVERED = ["none", "reported", "observed", "enforced"]; // weakest first
|
|
1102
|
+
const SEVERITIES = ["critical", "high", "medium", "low", "info"]; // a signed finding's, worst first
|
|
1103
|
+
// the closed fields of a signed finding's verdict (tracekit/signer/rpc_schema.py REVIEW) and the values each may take
|
|
1104
|
+
const FAMILIES = ["anthropic", "openai", "google", "meta", "mistral", "deepseek", "qwen", "xai"];
|
|
1105
|
+
const VERDICT = {
|
|
1106
|
+
kind: ["finding", "coverage"], severity: SEVERITIES, tier: ["gateway", "harness", "transcript", "sdk"],
|
|
1107
|
+
order: ["signed_before", "transcript_order", "after"], alignment: ["ok", "drift", "misaligned"],
|
|
1108
|
+
reason: ["none", "unrequested_action", "unexplained_action", "contradicted_reasoning", "scope_creep", "workaround",
|
|
1109
|
+
"false_success", "followed_tool_output", "injection", "other"],
|
|
1110
|
+
};
|
|
1111
|
+
/** The reference verifier's exception names, which reports print: a bundle the format refuses, a key it lacks. */
|
|
1112
|
+
class ValueError extends Error {
|
|
1113
|
+
}
|
|
1114
|
+
class KeyError extends Error {
|
|
1115
|
+
}
|
|
1116
|
+
class Report {
|
|
1117
|
+
checks = [];
|
|
1118
|
+
failures = [];
|
|
1119
|
+
warnings = [];
|
|
1120
|
+
notes = [];
|
|
1121
|
+
integrity = "UNUSABLE BUNDLE";
|
|
1122
|
+
assurance = "none";
|
|
1123
|
+
/** The Findings line: signed findings, an opinion of the run kept apart from its integrity (null: none). */
|
|
1124
|
+
findings = null;
|
|
1125
|
+
/** Checks the bundle needs that this verifier does not implement. */
|
|
1126
|
+
unsupported = [];
|
|
1127
|
+
check(check, ok, detail = "", problems = [], warn = false) {
|
|
1128
|
+
this.checks.push({ check, status: ok ? "pass" : warn ? "warn" : "fail", detail, problems });
|
|
1129
|
+
if (!ok)
|
|
1130
|
+
(warn ? this.warnings : this.failures).push(check);
|
|
1131
|
+
}
|
|
1132
|
+
unsupport(what) {
|
|
1133
|
+
if (this.unsupported.includes(what))
|
|
1134
|
+
return;
|
|
1135
|
+
this.unsupported.push(what);
|
|
1136
|
+
this.check(what, false, "not implemented by this verifier", [], true);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1140
|
+
/** The manifest check's detail: on a failure, the files that differ from the manifest (the first 5) and how. */
|
|
1141
|
+
function manifestDetail(listed, hashes, bad) {
|
|
1142
|
+
if (!isObj(listed))
|
|
1143
|
+
return "the manifest lists no files";
|
|
1144
|
+
if (bad.length === 0)
|
|
1145
|
+
return "every file is listed with its SHA-256 and nothing else is in the bundle";
|
|
1146
|
+
const how = bad.slice(0, 5).map((n) => `${n} (${Object.hasOwn(listed, n) && hashes.has(n) ? "changed"
|
|
1147
|
+
: Object.hasOwn(listed, n) ? "missing" : "not listed"})`);
|
|
1148
|
+
return "files differ from the manifest: " + how.join(", ") + (bad.length > 5 ? ` and ${bad.length - 5} more` : "");
|
|
1149
|
+
}
|
|
1150
|
+
const kind = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
|
|
1151
|
+
function at(o, k) {
|
|
1152
|
+
if (!isObj(o))
|
|
1153
|
+
throw new TypeError(`${kind(o)} is not an object`);
|
|
1154
|
+
if (!Object.hasOwn(o, k))
|
|
1155
|
+
throw new KeyError(repr(k));
|
|
1156
|
+
return o[k];
|
|
1157
|
+
}
|
|
1158
|
+
function get(o, k, missing = null) {
|
|
1159
|
+
if (!isObj(o))
|
|
1160
|
+
throw new TypeError(`${kind(o)} is not an object`);
|
|
1161
|
+
return Object.hasOwn(o, k) ? o[k] : missing;
|
|
1162
|
+
}
|
|
1163
|
+
function has(o, k) {
|
|
1164
|
+
if (isObj(o))
|
|
1165
|
+
return Object.hasOwn(o, k);
|
|
1166
|
+
if (Array.isArray(o))
|
|
1167
|
+
return o.some((x) => x === k);
|
|
1168
|
+
if (typeof o === "string")
|
|
1169
|
+
return o.includes(k);
|
|
1170
|
+
throw new TypeError(`${kind(o)} is not a container`);
|
|
1171
|
+
}
|
|
1172
|
+
function list(v) {
|
|
1173
|
+
if (!Array.isArray(v))
|
|
1174
|
+
throw new TypeError(`${kind(v)} is not a list`);
|
|
1175
|
+
return v;
|
|
1176
|
+
}
|
|
1177
|
+
function num(v) {
|
|
1178
|
+
if (typeof v === "number")
|
|
1179
|
+
return v;
|
|
1180
|
+
if (typeof v === "boolean")
|
|
1181
|
+
return Number(v);
|
|
1182
|
+
throw new TypeError(`${kind(v)} is not a number`);
|
|
1183
|
+
}
|
|
1184
|
+
function text(v) {
|
|
1185
|
+
if (typeof v !== "string")
|
|
1186
|
+
throw new TypeError(`${kind(v)} is not a string`);
|
|
1187
|
+
return v;
|
|
1188
|
+
}
|
|
1189
|
+
const truthy = (v) => Array.isArray(v) ? v.length > 0 : isObj(v) ? Object.keys(v).length > 0 : !!v;
|
|
1190
|
+
const str = (v) => v === null ? "None" : v === true ? "True" : v === false ? "False"
|
|
1191
|
+
: typeof v === "string" ? v : JSON.stringify(v);
|
|
1192
|
+
const repr = (v) => typeof v === "string" ? quoted(v) : str(v);
|
|
1193
|
+
const cut = (s, n) => [...s].slice(0, n).join("");
|
|
1194
|
+
/** `xs` appended to `to` one by one: a spread call (`push(...xs)`, `Math.max(...xs)`) puts every element on the stack,
|
|
1195
|
+
* which a bundle of 60k records overflows. */
|
|
1196
|
+
const extend = (to, xs) => {
|
|
1197
|
+
for (const x of xs)
|
|
1198
|
+
to.push(x);
|
|
1199
|
+
};
|
|
1200
|
+
/** Python's order of strings, by code point (JavaScript's `<` and sort() compare UTF-16 code units, which put a
|
|
1201
|
+
* character above U+FFFF before one in U+E000..U+FFFF). */
|
|
1202
|
+
const byCodePoint = (a, b) => {
|
|
1203
|
+
const unit = (u) => u >= 0xd800 && u < 0xe000 ? u + 0x2000 : u >= 0xe000 ? u - 0x800 : u;
|
|
1204
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++)
|
|
1205
|
+
if (a.charCodeAt(i) !== b.charCodeAt(i))
|
|
1206
|
+
return unit(a.charCodeAt(i)) - unit(b.charCodeAt(i));
|
|
1207
|
+
return a.length - b.length;
|
|
1208
|
+
};
|
|
1209
|
+
const byKey = ([a], [b]) => byCodePoint(a, b);
|
|
1210
|
+
const count = (xs) => {
|
|
1211
|
+
const m = new Map();
|
|
1212
|
+
for (const x of xs)
|
|
1213
|
+
m.set(x, (m.get(x) ?? 0) + 1);
|
|
1214
|
+
return [...m].sort(byKey);
|
|
1215
|
+
};
|
|
1216
|
+
const fmt = (ts) => {
|
|
1217
|
+
if (ts > 253402300799)
|
|
1218
|
+
throw new RangeError("year is out of range");
|
|
1219
|
+
return new Date(Number(ts) * 1000).toISOString().slice(0, 19) + "Z";
|
|
1220
|
+
};
|
|
1221
|
+
async function loadTrust(data, name) {
|
|
1222
|
+
const floats = new WeakMap();
|
|
1223
|
+
const t = strictParseBytes(data.subarray(0, MAX_LINE), floats);
|
|
1224
|
+
const int = (o, k, d) => !Object.hasOwn(o, k) ? d
|
|
1225
|
+
: Number.isInteger(o[k]) && !floats.get(o)?.has(k) ? o[k] : NaN;
|
|
1226
|
+
const exactly = (o, ks) => isObj(o) && Object.keys(o).length === ks.length && ks.every((k) => Object.hasOwn(o, k));
|
|
1227
|
+
const strings = (a) => Array.isArray(a) && a.length > 0 && a.every((x) => typeof x === "string");
|
|
1228
|
+
const opt = (o, k) => Object.hasOwn(o, k) ? o[k] : [];
|
|
1229
|
+
const r = isObj(t) ? get(t, "rekor", {}) : null;
|
|
1230
|
+
const ok = isObj(t) && Object.keys(t).every((k) => ["logs", "witnesses", "algs", "witnesses_required", "rekor",
|
|
1231
|
+
"monitors", "issuers", "analyzers"].includes(k))
|
|
1232
|
+
&& isObj(r) && (!Object.keys(r).length || exactly(r, ["trusted_root", "publishing_key", "class"])
|
|
1233
|
+
&& isObj(r.trusted_root) && typeof r.publishing_key === "string" && CLASSES.includes(r.class))
|
|
1234
|
+
&& strings(t.logs)
|
|
1235
|
+
&& Array.isArray(opt(t, "witnesses")) && opt(t, "witnesses").every((w) => exactly(w, ["vkey", "class"])
|
|
1236
|
+
&& typeof w.vkey === "string" && CLASSES.includes(w.class))
|
|
1237
|
+
&& strings(t.algs) && int(t, "witnesses_required", 0) >= 0
|
|
1238
|
+
&& Array.isArray(opt(t, "monitors")) && opt(t, "monitors").every((m) => exactly(m, ["vkey", "class", "max_age_s"])
|
|
1239
|
+
&& typeof m.vkey === "string" && CLASSES.includes(m.class) && int(m, "max_age_s", 0) > 0)
|
|
1240
|
+
&& Array.isArray(opt(t, "issuers")) && opt(t, "issuers").every((i) => exactly(i, ["vkey", "issuance_log_vkey"])
|
|
1241
|
+
&& Object.values(i).every((v) => typeof v === "string"))
|
|
1242
|
+
&& Array.isArray(opt(t, "analyzers"))
|
|
1243
|
+
&& opt(t, "analyzers").every((a) => typeof a === "string" && /^[a-z0-9_]+:.+$/.test(a));
|
|
1244
|
+
if (!ok)
|
|
1245
|
+
throw new ValueError(`${name}: not a v2 trust config (logs, witnesses, algs, witnesses_required, rekor, monitors, `
|
|
1246
|
+
+ "issuers, analyzers)");
|
|
1247
|
+
for (const k of [...t.logs, ...opt(t, "witnesses").map((w) => w.vkey), ...opt(t, "monitors").map((m) => m.vkey),
|
|
1248
|
+
...opt(t, "issuers").flatMap((i) => Object.values(i))])
|
|
1249
|
+
await parseVkey(k);
|
|
1250
|
+
t.witnesses ??= [];
|
|
1251
|
+
t.witnesses_required ??= 0;
|
|
1252
|
+
t.issuers ??= [];
|
|
1253
|
+
t.analyzers ??= [];
|
|
1254
|
+
return t;
|
|
1255
|
+
}
|
|
1256
|
+
/** `s` as one shell argument, as the reference quotes it (shlex.quote; on Windows subprocess.list2cmdline). */
|
|
1257
|
+
function quote(s) {
|
|
1258
|
+
if (globalThis.process?.platform === "win32")
|
|
1259
|
+
return /[ \t]/.test(s) || !s ? `"${s}"` : s;
|
|
1260
|
+
return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
1261
|
+
}
|
|
1262
|
+
/** Ways to verify the bundle with a verifier of version `need` or later, as commands with the user's own paths. */
|
|
1263
|
+
function upgrade(need, names) {
|
|
1264
|
+
const [b, t] = [quote(names.bundle), quote(names.trust)];
|
|
1265
|
+
return [`npx -y @cygnuxlabs/tracekit@latest verify ${b} --trust ${t}`,
|
|
1266
|
+
`uvx --from "tracekit-ai>=${need}" tracekit verify ${b} --trust ${t}`,
|
|
1267
|
+
`or open ${PAGE} and choose both files: the page verifies them in your browser and sends nothing`];
|
|
1268
|
+
}
|
|
1269
|
+
function newer(need, have) {
|
|
1270
|
+
const a = need.split(".").map(Number), b = have.split(".").map(Number);
|
|
1271
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++)
|
|
1272
|
+
if (a[i] !== b[i])
|
|
1273
|
+
return a[i] > b[i];
|
|
1274
|
+
return a.length > b.length;
|
|
1275
|
+
}
|
|
1276
|
+
const errorText = (e) => `${e?.constructor?.name ?? "Error"}: ${e?.message ?? e}`;
|
|
1277
|
+
/** Without a trust config: the bundle against its own checkpoint, whose signature no pinned key checks. */
|
|
1278
|
+
const UNANCHORED = "UNVERIFIABLE (no trust config: the records match their own checkpoint, which no pinned key checked)";
|
|
1279
|
+
/**
|
|
1280
|
+
* Verify a v2 bundle (zip bytes) against the verifier's own pinned trust config (JSON bytes). Never throws. `names`: the
|
|
1281
|
+
* files' paths, for the commands a bundle that needs a newer verifier is told to run. `trust` null (the browser page,
|
|
1282
|
+
* when no trust config is chosen): every check that needs no pinned key, and never VERIFIED: a changed record still
|
|
1283
|
+
* FAILS, and an intact bundle is UNVERIFIABLE, since anyone can sign a bundle and its checkpoint with keys of their own.
|
|
1284
|
+
*/
|
|
1285
|
+
async function verify(bundle, trust, names = { bundle: "BUNDLE", trust: "TRUST.json" }) {
|
|
1286
|
+
const rep = new Report();
|
|
1287
|
+
let t, files, manifest, v1 = false;
|
|
1288
|
+
if (trust === null) {
|
|
1289
|
+
t = { logs: [], witnesses: [], algs: ["ed25519"], witnesses_required: 0, issuers: [], analyzers: [] };
|
|
1290
|
+
rep.check("trust config", false, "none: the checkpoint's signature and its witnesses are not checked, so nothing ties "
|
|
1291
|
+
+ "these records to a signer you trust", [], true);
|
|
1292
|
+
}
|
|
1293
|
+
else
|
|
1294
|
+
try {
|
|
1295
|
+
t = await loadTrust(trust, names.trust);
|
|
1296
|
+
}
|
|
1297
|
+
catch (e) {
|
|
1298
|
+
rep.check("trust config", false, errorText(e));
|
|
1299
|
+
return { report: rep, code: EXIT_BAD };
|
|
1300
|
+
}
|
|
1301
|
+
const floats = new WeakMap();
|
|
1302
|
+
try {
|
|
1303
|
+
files = await readZip(bundle);
|
|
1304
|
+
const m = files.get("manifest.json");
|
|
1305
|
+
if (!m)
|
|
1306
|
+
throw new KeyError(repr("manifest.json"));
|
|
1307
|
+
files.delete("manifest.json");
|
|
1308
|
+
manifest = strictParseBytes(m, floats);
|
|
1309
|
+
v1 = isObj(manifest) && manifest.format === "tracekit.bundle.v1";
|
|
1310
|
+
if (!isObj(manifest) || manifest.format !== FORMAT)
|
|
1311
|
+
throw new Unusable(`format is not ${FORMAT}`);
|
|
1312
|
+
const need = manifest.verifier_min_version;
|
|
1313
|
+
if (!(typeof need === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(need)))
|
|
1314
|
+
throw new Unusable("verifier_min_version is not a version number");
|
|
1315
|
+
if (newer(need, VERSION)) {
|
|
1316
|
+
rep.integrity = `UNVERIFIABLE (needs tracekit >= ${need})`;
|
|
1317
|
+
rep.check("bundle readable", false, rep.integrity, upgrade(need, names));
|
|
1318
|
+
return { report: rep, code: EXIT_BAD };
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
catch (e) {
|
|
1322
|
+
// a format v1 bundle (the laptop signer's): the reference verifier checks it, against the signer.pub the sender pins
|
|
1323
|
+
rep.check("bundle readable", false, `cannot read bundle: ${errorText(e)}`, v1 ? ["a format v1 bundle: verify it with "
|
|
1324
|
+
+ `the signer's public key (signer.pub, from the sender): uvx --from tracekit-ai tracekit verify ${quote(names.bundle)}`
|
|
1325
|
+
+ " --key signer.pub"] : []);
|
|
1326
|
+
return { report: rep, code: EXIT_BAD };
|
|
1327
|
+
}
|
|
1328
|
+
try {
|
|
1329
|
+
await check(rep, manifest, files, t, floats);
|
|
1330
|
+
}
|
|
1331
|
+
catch (e) {
|
|
1332
|
+
rep.check("bundle structure", false, "", [`malformed and could not be fully checked: ${errorText(e)}`]);
|
|
1333
|
+
}
|
|
1334
|
+
if (rep.failures.length)
|
|
1335
|
+
rep.integrity = "FAILED";
|
|
1336
|
+
else if (trust === null)
|
|
1337
|
+
rep.integrity = UNANCHORED;
|
|
1338
|
+
else if (rep.unsupported.length)
|
|
1339
|
+
rep.integrity = `UNVERIFIABLE (not checked by this verifier: ${rep.unsupported.join(", ")})`;
|
|
1340
|
+
return { report: rep, code: rep.failures.length ? EXIT_FAIL : rep.integrity.startsWith("UNVERIFIABLE") ? EXIT_BAD : EXIT_OK };
|
|
1341
|
+
}
|
|
1342
|
+
async function check(rep, manifest, files, trust, floats) {
|
|
1343
|
+
const file = (name) => {
|
|
1344
|
+
const f = files.get(name);
|
|
1345
|
+
if (!f)
|
|
1346
|
+
throw new KeyError(repr(name));
|
|
1347
|
+
return f;
|
|
1348
|
+
};
|
|
1349
|
+
const json = (name) => strictParseBytes(file(name), floats);
|
|
1350
|
+
/** A JSON lines file's texts, each line at least MIN_RECORD bytes: checked before any is parsed, so a bundle's lines
|
|
1351
|
+
* can't outnumber MAX_TOTAL / MIN_RECORD, whatever it unzips to. */
|
|
1352
|
+
const jsonl = (name) => {
|
|
1353
|
+
const data = file(name);
|
|
1354
|
+
if (data.length && data.at(-1) !== 0x0a)
|
|
1355
|
+
throw new ValueError("a JSON lines file must end in a newline");
|
|
1356
|
+
const lines = [];
|
|
1357
|
+
for (let i = 0, j; (j = data.indexOf(0x0a, i)) >= 0; i = j + 1) {
|
|
1358
|
+
// checked before any is parsed: a run of tiny lines costs no memory
|
|
1359
|
+
if (lines.length === MAX_LINES)
|
|
1360
|
+
throw new ValueError("more lines than the bundle limits");
|
|
1361
|
+
lines.push(data.subarray(i, j));
|
|
1362
|
+
}
|
|
1363
|
+
if (lines.some((l) => l.length > MAX_LINE))
|
|
1364
|
+
throw new ValueError("line longer than the bundle limits");
|
|
1365
|
+
if (lines.some((l) => l.length < MIN_RECORD))
|
|
1366
|
+
throw new ValueError("line shorter than a signed record");
|
|
1367
|
+
return lines.map((l) => strictParseBytes(l, floats));
|
|
1368
|
+
};
|
|
1369
|
+
const listed = manifest.files, hashes = new Map();
|
|
1370
|
+
for (const [n, b] of files)
|
|
1371
|
+
hashes.set(n, await sha256hex(b));
|
|
1372
|
+
const bad = isObj(listed) ? [...new Set([...Object.keys(listed), ...hashes.keys()])]
|
|
1373
|
+
.filter((n) => !(Object.hasOwn(listed, n) && hashes.get(n) === listed[n])).sort() : [];
|
|
1374
|
+
rep.check("manifest", isObj(listed) && bad.length === 0, manifestDetail(listed, hashes, bad));
|
|
1375
|
+
// the checkpoint: the only thing the records' trust hangs on
|
|
1376
|
+
const proofs = json("proofs/records.json");
|
|
1377
|
+
const name = at(proofs, "checkpoint");
|
|
1378
|
+
if (!(typeof name === "string" && name.startsWith("checkpoints/") && name.endsWith(".note")))
|
|
1379
|
+
throw new ValueError("proofs name no checkpoint note");
|
|
1380
|
+
const witnesses = new Map(trust.witnesses.map((w) => [w.vkey, w.class]));
|
|
1381
|
+
const anchored = trust.logs.length > 0; // a trust config pins at least one log key
|
|
1382
|
+
let note;
|
|
1383
|
+
try {
|
|
1384
|
+
note = await openNote(file(name), trust.logs, [...witnesses.keys()], anchored);
|
|
1385
|
+
}
|
|
1386
|
+
catch (e) {
|
|
1387
|
+
if (!(e instanceof NoteError))
|
|
1388
|
+
throw e;
|
|
1389
|
+
rep.check("checkpoint", false, e.message);
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
const { origin, size, root, cosigs } = note;
|
|
1393
|
+
rep.check("checkpoint", same(size, at(proofs, "tree_size")), `${origin} at tree size ${size}, `
|
|
1394
|
+
+ (anchored ? "signed by its pinned log key" : "its signature not checked (no trust config)"));
|
|
1395
|
+
if (note.unchecked)
|
|
1396
|
+
rep.unsupport("SLH-DSA-SHA2-128s checkpoint signature");
|
|
1397
|
+
if (anchored)
|
|
1398
|
+
rep.check("witness quorum", cosigs.length >= trust.witnesses_required, `${cosigs.length} pinned cosignature(s), ${trust.witnesses_required} required`);
|
|
1399
|
+
if (truthy(trust.rekor) && files.has(`rekor/${size}.json`))
|
|
1400
|
+
rep.unsupport("rekor anchor");
|
|
1401
|
+
const included = async (r) => {
|
|
1402
|
+
const seq = at(at(r, "event"), "seq");
|
|
1403
|
+
const path = get(at(proofs, "inclusion"), str(seq));
|
|
1404
|
+
return Array.isArray(path) && verifyInclusion(num(seq), size, await leafHash(unhex(text(at(r, "hash")).slice(7))), path.map(b64decode), root);
|
|
1405
|
+
};
|
|
1406
|
+
// keys: declared and retired by records the checkpointed tree includes, so the log key vouches for them
|
|
1407
|
+
const keyRecords = jsonl("keys/records.jsonl"), keyProblems = [], timeline = new Map();
|
|
1408
|
+
const logId = keyRecords.length ? get(at(keyRecords[0], "event"), "log_id") : null;
|
|
1409
|
+
const keysAt = (seq) => [...timeline.values()].filter((k) => num(k.from) <= seq
|
|
1410
|
+
&& (k.until === null || seq <= num(k.until))).map((k) => k.spki);
|
|
1411
|
+
/** Signature, schema and log membership of one record under `keys`, the SPKIs valid at its position. */
|
|
1412
|
+
const checkRecord = async (r, keys, problems) => {
|
|
1413
|
+
const err = await verifyRecord(r, keys, trust.algs);
|
|
1414
|
+
if (err) {
|
|
1415
|
+
const seq = err === "unknown kid" ? get(r.event, "seq") : null; // a well-formed record by then
|
|
1416
|
+
const retired = [...timeline.values()].filter((k) => Number.isInteger(seq) && k.until !== null && k.until < seq);
|
|
1417
|
+
if (retired.length && !await verifyRecord(r, retired.map((k) => k.spki), trust.algs))
|
|
1418
|
+
problems.push(`seq ${seq}: signed by key ${cut(r.kid, 80)} after its key.retire`);
|
|
1419
|
+
else
|
|
1420
|
+
problems.push(`seq ${repr(get(get(r, "event", {}), "seq"))}: ${err}`);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
const e = r.event;
|
|
1424
|
+
const errs = get(e, "schema_version") === "tracekit.event.v2" ? validateEvent(e, floats) : ["not a tracekit.event.v2 event"];
|
|
1425
|
+
problems.push(...errs.map((x) => `seq ${repr(get(e, "seq"))}: ${x}`));
|
|
1426
|
+
if (!errs.length && !same(get(e, "log_id"), logId))
|
|
1427
|
+
problems.push(`seq ${e.seq}: another log's record`);
|
|
1428
|
+
};
|
|
1429
|
+
let last = -1;
|
|
1430
|
+
for (const r of keyRecords) {
|
|
1431
|
+
const e = at(r, "event");
|
|
1432
|
+
if (!KEY_TYPES.includes(at(e, "type")) || num(at(e, "seq")) <= last || !await included(r)) {
|
|
1433
|
+
keyProblems.push(`seq ${str(at(e, "seq"))}: not a key record of the checkpointed tree, in order`);
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
last = e.seq;
|
|
1437
|
+
const declared = new Map();
|
|
1438
|
+
if (e.type === "signer.epoch") {
|
|
1439
|
+
for (const k of list(get(at(e, "data"), "keys", []))) {
|
|
1440
|
+
const der = b64decode(at(k, "spki"));
|
|
1441
|
+
if (await spkiKid(der) !== at(k, "kid") || keyAlg(der) !== at(k, "alg"))
|
|
1442
|
+
keyProblems.push(`seq ${e.seq}: key ${str(k.kid)} does not match its SPKI`);
|
|
1443
|
+
if (!has(k, "cert") && trust.issuers.length && !truthy(get(e.data, "bridge"))) {
|
|
1444
|
+
keyProblems.push(`seq ${e.seq}: key ${cut(str(k.kid), 80)} has no certificate, and the trust config pins issuers`);
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
// lean: a certified key is taken as declared and the bundle left UNVERIFIABLE; port docs/issuer.md's checks
|
|
1448
|
+
// (issuance log notes, validity, tenants, revocations) when certified record keys are in use
|
|
1449
|
+
if (has(k, "cert"))
|
|
1450
|
+
rep.unsupport("record-key certificates");
|
|
1451
|
+
declared.set(k.kid, { spki: der, from: e.seq, until: null });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
await checkRecord(r, [...keysAt(e.seq), ...[...declared.values()].map((k) => k.spki)], keyProblems);
|
|
1455
|
+
if (e.type === "key.retire") {
|
|
1456
|
+
const kid = at(at(e, "data"), "kid");
|
|
1457
|
+
if (!timeline.has(kid))
|
|
1458
|
+
keyProblems.push(`seq ${e.seq}: retires an unknown key`);
|
|
1459
|
+
else
|
|
1460
|
+
timeline.get(kid).until = at(e.data, "last_seq");
|
|
1461
|
+
}
|
|
1462
|
+
for (const [kid, k] of declared)
|
|
1463
|
+
timeline.set(kid, k);
|
|
1464
|
+
}
|
|
1465
|
+
// the runs: one, or with a run-set any number, or one with its findings runs (and replay runs)
|
|
1466
|
+
// lean: tracekit why findings (verify/v2.py's _findings) are not checked here: a bundle with a why.finding or a replay
|
|
1467
|
+
// run is UNVERIFIABLE; port it when TS users verify tracekit why bundles
|
|
1468
|
+
const names = runFiles(files.keys());
|
|
1469
|
+
const runSet = files.has("registry/run-set.json");
|
|
1470
|
+
const runs = new Map(), problems = [], chain = [], outside = [];
|
|
1471
|
+
const layout = [], seen = new Set();
|
|
1472
|
+
for (const [name, parts] of names) {
|
|
1473
|
+
const pieces = parts.map(jsonl), records = pieces.flat(), whole = parts.length === 1 && parts[0] === name;
|
|
1474
|
+
runs.set(name, records);
|
|
1475
|
+
extend(layout, parted(name, parts, pieces));
|
|
1476
|
+
if (!records.length)
|
|
1477
|
+
throw new ValueError("a run has no records");
|
|
1478
|
+
const head = at(records[0], "event"), tenant = get(head, "tenant"), runId = get(head, "run_id");
|
|
1479
|
+
if (typeof tenant === "string" && typeof runId === "string") { // a second file of one run would count it twice
|
|
1480
|
+
const key = JSON.stringify([tenant, runId]);
|
|
1481
|
+
if (seen.has(key))
|
|
1482
|
+
chain.push(`run ${repr(cut(runId, 200))} of tenant ${repr(cut(tenant, 64))} again in ${name}`);
|
|
1483
|
+
seen.add(key);
|
|
1484
|
+
}
|
|
1485
|
+
for (const [i, r] of records.entries()) {
|
|
1486
|
+
await checkRecord(r, keysAt(num(at(at(r, "event"), "seq"))), problems);
|
|
1487
|
+
const e = r.event, prev = i ? records[i - 1] : null;
|
|
1488
|
+
if (!same(get(e, "run_seq"), i) || !same(get(e, "run_prev_hash"), prev ? at(prev, "hash") : ZERO_HASH)
|
|
1489
|
+
|| !same([get(e, "tenant"), get(e, "run_id")], [get(head, "tenant"), get(head, "run_id")])
|
|
1490
|
+
|| prev && (num(at(e, "seq")) <= num(at(at(prev, "event"), "seq")) || get(prev.event, "type") === "run.final"))
|
|
1491
|
+
chain.push(`run ${repr(cut(str(get(head, "run_id")), 200))} run_seq ${i} (seq ${repr(get(e, "seq"))}) does not `
|
|
1492
|
+
+ "continue the run");
|
|
1493
|
+
}
|
|
1494
|
+
if (!(await included(records[0]) && await included(records.at(-1))))
|
|
1495
|
+
outside.push(`run ${repr(cut(str(get(head, "run_id")), 200))}` + (!whole && await included(records[0])
|
|
1496
|
+
? `: the last record of ${parts.at(-1)} is not in the checkpointed tree, so a later part may be missing` : ""));
|
|
1497
|
+
}
|
|
1498
|
+
const why = [...runs.values()].some((rs) => rs.some((r) => get(r.event, "type") === "why.finding") || about(rs)[1] !== null);
|
|
1499
|
+
if (why)
|
|
1500
|
+
rep.unsupport("tracekit why findings");
|
|
1501
|
+
else if (runs.size !== 1 && !runSet && !aboutOne(runs))
|
|
1502
|
+
throw new ValueError("a v2 bundle holds exactly one run, one run with its findings and replay runs, or a run-set");
|
|
1503
|
+
let retired = [], provenTo = -1, regCosigs = [];
|
|
1504
|
+
if (runSet) {
|
|
1505
|
+
[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);
|
|
1506
|
+
const keyed = new Set(keyRecords.map((r) => `${at(at(r, "event"), "seq")} ${at(r, "hash")}`));
|
|
1507
|
+
extend(keyProblems, retired.filter(([seq, h]) => !keyed.has(`${seq} ${h}`))
|
|
1508
|
+
.map(([seq]) => `seq ${seq}: key.retire withheld (the registry log has it)`));
|
|
1509
|
+
}
|
|
1510
|
+
rep.check("keys", timeline.size > 0 && !keyProblems.length, `${timeline.size} record key(s) from ${keyRecords.length} key `
|
|
1511
|
+
+ "record(s)", keyProblems);
|
|
1512
|
+
const every = [...runs.values()].flat();
|
|
1513
|
+
const seqs = [...keyRecords, ...every].map((r) => num(at(at(r, "event"), "seq")));
|
|
1514
|
+
if (!seqs.length)
|
|
1515
|
+
throw new ValueError("the bundle holds no key or run records");
|
|
1516
|
+
const relied = seqs.reduce((a, b) => b > a ? b : a);
|
|
1517
|
+
const rotated = timeline.size > 1 || keyRecords.some((r) => get(r.event, "type") === "key.retire");
|
|
1518
|
+
if (provenTo < relied && rotated) // one key never retired: the assurance line notes it, no warning
|
|
1519
|
+
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);
|
|
1520
|
+
const bridge = keyRecords.map((r) => r.event).find((e) => e.type === "signer.epoch" && has(at(e, "data"), "bridge"));
|
|
1521
|
+
if (bridge && truthy(bridge.data.bridge))
|
|
1522
|
+
rep.notes.push(`this log continues the v1 ledger of key ${str(at(bridge.data.bridge, "v1_kid"))}; pass the v1 ledger and `
|
|
1523
|
+
+ "its signer.pub to the reference verifier to check the format bridge");
|
|
1524
|
+
const inParts = [...names].filter(([n, ps]) => !(ps.length === 1 && ps[0] === n)).map(([, ps]) => ps.length);
|
|
1525
|
+
if (inParts.length)
|
|
1526
|
+
rep.check("run parts", !layout.length, `${inParts.length} run(s) in ${inParts.reduce((a, b) => a + b, 0)} parts, `
|
|
1527
|
+
+ "each part continuing the one before it", layout);
|
|
1528
|
+
rep.check("signatures", !problems.length, `${every.length} record(s), keys valid at their position`, problems);
|
|
1529
|
+
const only = runs.size === 1 ? [...runs.values()][0][0].event : null;
|
|
1530
|
+
rep.check("run chain", !chain.length, only ? `run ${repr(cut(str(get(only, "run_id")), 200))} of tenant `
|
|
1531
|
+
+ `${repr(get(only, "tenant"))}, contiguous from run_seq 0` : `${runs.size} run(s), each contiguous from run_seq 0`, chain);
|
|
1532
|
+
rep.check("inclusion", !outside.length, `first and last records of ${runs.size} run(s) are in the checkpointed tree of `
|
|
1533
|
+
+ `size ${size}`, outside);
|
|
1534
|
+
// policy_hash -> the bundled snapshot named by it: the one set checkIncidents and approvals check against
|
|
1535
|
+
const policies = new Map();
|
|
1536
|
+
for (const p of [...files.keys()].filter((n) => n.startsWith("policies/")).sort()) {
|
|
1537
|
+
const hex = await sha256hex(file(p));
|
|
1538
|
+
rep.check("policy snapshot", p === `policies/${hex}.json`, p);
|
|
1539
|
+
if (p === `policies/${hex}.json`)
|
|
1540
|
+
policies.set(`sha256:${hex}`, file(p));
|
|
1541
|
+
}
|
|
1542
|
+
const open = [...runs.values()].filter((rs) => get(rs.at(-1).event, "type") !== "run.final");
|
|
1543
|
+
rep.integrity = !open.length ? "VERIFIED" : only ? `VERIFIED TO HEAD ${str(get(open[0].at(-1).event, "run_seq"))} (open)`
|
|
1544
|
+
: `VERIFIED (${open.length} run(s) open)`;
|
|
1545
|
+
const events = every.map((r) => r.event), typed = (t) => events.filter((e) => get(e, "type") === t);
|
|
1546
|
+
const selfApproved = typed("approval").some((e) => get(at(e, "data"), "self_approved") === true);
|
|
1547
|
+
const gaps = (k) => typed("capture.gap").filter((e) => get(at(e, "data"), "kind") === k)
|
|
1548
|
+
.map((e) => `seq ${e.seq}: ${cut(str(get(e.data, "reason")), 200)}`);
|
|
1549
|
+
const against = gaps("executed_against_policy");
|
|
1550
|
+
if (against.length)
|
|
1551
|
+
rep.check("policy", false, `${against.length} tool call(s) ran against a deny or an unapproved ask`, against.slice(0, 20), true);
|
|
1552
|
+
const unresolved = gaps("held_call_unresolved"); // the signer's word that a held call ended its run without a result
|
|
1553
|
+
if (unresolved.length)
|
|
1554
|
+
rep.check("held calls", false, `${unresolved.length} tool call(s) a gateway or executor decided have no result`, unresolved.slice(0, 20), true);
|
|
1555
|
+
// capture.gap kinds that say nothing against a run's own records, and those warned on lines of their own
|
|
1556
|
+
const info = ["witness_late", "witness_failed", "degraded_unanchored", "clock_skew", "format_upgrade", "key_retired",
|
|
1557
|
+
"args_coerced", "approval_expired", "executed_against_policy", "held_call_unresolved", "decision_mismatch",
|
|
1558
|
+
"observer_lost"];
|
|
1559
|
+
const alarms = events.filter((e) => ["approval.binding_mismatch", "trace.tamper"].includes(get(e, "type"))
|
|
1560
|
+
|| get(e, "type") === "capture.gap" && !info.includes(get(at(e, "data"), "kind")));
|
|
1561
|
+
if (alarms.length) {
|
|
1562
|
+
const kindOf = (e) => e.type === "capture.gap" ? cut(str(get(e.data, "kind")), 64) : e.type;
|
|
1563
|
+
rep.check("gaps", false, `${alarms.length} signed gap, tamper or mismatch record(s): `
|
|
1564
|
+
+ count(alarms.map(kindOf)).map(([k, n]) => `${k} ${n}`).join(", "), alarms.map((e) => `seq ${e.seq}: ${kindOf(e)}: ` + (e.type === "approval.binding_mismatch"
|
|
1565
|
+
? `approval ${cut(str(get(e.data, "approval_id")), 200)} of call ${cut(str(get(e.data, "tool_use_id")), 200)}`
|
|
1566
|
+
+ ": the arguments differ from the approved ones"
|
|
1567
|
+
: cut(str(get(e.data, "reason", get(e.data, "path"))), 200))).slice(0, 20), true);
|
|
1568
|
+
}
|
|
1569
|
+
const external = typed("policy.external").map((e) => at(e, "data")), mismatched = gaps("decision_mismatch");
|
|
1570
|
+
if (external.length || mismatched.length) {
|
|
1571
|
+
const systems = count(external.map((d) => cut(str(at(d, "system")), 64)));
|
|
1572
|
+
const verified = external.filter((d) => at(d, "signature") === "verified").length;
|
|
1573
|
+
rep.check("external decisions", !mismatched.length, `${external.length} imported: `
|
|
1574
|
+
+ (systems.map(([k, n]) => `${k} ${n}`).join(", ") || "none") + `; signatures verified ${verified}, unverified `
|
|
1575
|
+
+ `${external.length - verified}; ${mismatched.length} disagree with the signer`, mismatched.slice(0, 20), true);
|
|
1576
|
+
}
|
|
1577
|
+
const finals = [...runs.values()].map((rs) => rs.at(-1).event)
|
|
1578
|
+
.filter((e) => get(e, "type") === "run.final" && has(at(e, "data"), "coverage")).map((e) => e.data.coverage);
|
|
1579
|
+
const reconciles = events.filter((e) => str(get(e, "type")).startsWith("reconcile."));
|
|
1580
|
+
const paired = [...runs.values()].map((rs) => rs.at(-1).event).filter((e) => get(e, "type") === "run.final")
|
|
1581
|
+
.reduce((n, e) => n + num(get(e.data, "paired_by_args", 0)), 0);
|
|
1582
|
+
if (finals.length || reconciles.length) {
|
|
1583
|
+
const unreconciled = count(reconciles.map((e) => e.type.slice("reconcile.".length)));
|
|
1584
|
+
const layers = [...new Set(finals.flatMap((c) => list(at(c, "layers"))))].sort();
|
|
1585
|
+
rep.check("coverage", !reconciles.length, `layers ${layers.join("+") || "none"}` + (layers.includes("L3") ? "" : " (L3 absent)")
|
|
1586
|
+
+ `; ${finals.reduce((n, c) => n + num(at(c, "reconciled")), 0)} call(s) reconciled`
|
|
1587
|
+
+ (paired ? ` (${paired} paired by arguments: no tool-call id)` : "") + "; unreconciled: "
|
|
1588
|
+
+ (unreconciled.map(([k, n]) => `${k} ${n}`).join(", ") || "none"), reconciles.map((e) => `seq ${e.seq}: ${e.type}`
|
|
1589
|
+
+ (has(e, "tool_call_id") ? ` ${cut(str(e.tool_call_id), 200)}` : "")
|
|
1590
|
+
+ `: ${cut(str(get(at(e, "data"), "detail")), 200)}`).slice(0, 20), true);
|
|
1591
|
+
}
|
|
1592
|
+
// the coverage matrix: per action class its weakest status across the runs, the layers behind it; never "complete"
|
|
1593
|
+
const matrices = [...runs.values()].map((rs) => rs.at(-1).event)
|
|
1594
|
+
.filter((e) => get(e, "type") === "run.final" && has(at(e, "data"), "matrix")).map((e) => e.data.matrix);
|
|
1595
|
+
if (matrices.length)
|
|
1596
|
+
rep.check("coverage matrix", true, (matrices.length > 1 ? `weakest of ${matrices.length} runs: ` : "")
|
|
1597
|
+
+ MATRIX.map((k) => {
|
|
1598
|
+
const cs = matrices.map((m) => at(m, k));
|
|
1599
|
+
const status = cs.map((c) => str(at(c, "status"))).reduce((a, b) => COVERED.indexOf(b) < COVERED.indexOf(a) ? b : a);
|
|
1600
|
+
const layers = [...new Set(cs.flatMap((c) => list(at(c, "layers")).map(str)))].sort(byCodePoint)
|
|
1601
|
+
.concat(cs.some((c) => get(c, "asserted") === true) ? ["asserted"] : []);
|
|
1602
|
+
return `${k.replace("_", " ")} ` + (status === "none" ? "not covered"
|
|
1603
|
+
: status + (layers.length ? ` (${layers.join(", ")})` : ""));
|
|
1604
|
+
}).join(", "));
|
|
1605
|
+
const lost = gaps("observer_lost");
|
|
1606
|
+
if (lost.length)
|
|
1607
|
+
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);
|
|
1608
|
+
checkReviews(rep, runs, trust.analyzers);
|
|
1609
|
+
checkConstraints(rep, runs);
|
|
1610
|
+
checkIncidents(rep, runs, policies, floats);
|
|
1611
|
+
const accessed = typed("content.accessed").length, erased = typed("content.erased").length;
|
|
1612
|
+
if (accessed || erased) // signer records of the content vault (docs/privacy.md): who read or erased retained content
|
|
1613
|
+
rep.check("content vault", true, `${accessed} access(es), ${erased} erasure(s) of retained content, each signed by the signer`);
|
|
1614
|
+
// a tool call's tier is the weakest its records claim (untiered < T3, receipt only, < T1 < T2): each tier a record
|
|
1615
|
+
// names, and untiered for a decision or result that names none, so a call decided at T2 and completed elsewhere
|
|
1616
|
+
// isn't T2;
|
|
1617
|
+
// paths: the (source, tier)s of its decisions and results, which differ only when another layer answered for it
|
|
1618
|
+
const order = [null, "T3", "T1", "T2"];
|
|
1619
|
+
const tiers = new Map(), claims = new Map(), paths = new Map();
|
|
1620
|
+
for (const e of events) {
|
|
1621
|
+
if (get(e, "tool_call_id") === null)
|
|
1622
|
+
continue;
|
|
1623
|
+
const k = JSON.stringify([get(e, "run_id"), e.tool_call_id]);
|
|
1624
|
+
const decided = ["policy.decision", "tool.result"].includes(get(e, "type"));
|
|
1625
|
+
const tier = order.includes(get(e, "tier")) ? get(e, "tier") : null;
|
|
1626
|
+
tiers.set(k, null);
|
|
1627
|
+
if (tier !== null || decided) {
|
|
1628
|
+
const had = claims.has(k) ? claims.get(k) : tier;
|
|
1629
|
+
claims.set(k, order.indexOf(had) <= order.indexOf(tier) ? had : tier);
|
|
1630
|
+
}
|
|
1631
|
+
if (decided)
|
|
1632
|
+
paths.set(k, (paths.get(k) ?? new Set()).add(`${cut(str(get(e, "source")), 64)} ${tier ?? "untiered"}`));
|
|
1633
|
+
}
|
|
1634
|
+
for (const [k, t] of claims)
|
|
1635
|
+
tiers.set(k, t);
|
|
1636
|
+
const split = [...paths].filter(([, p]) => p.size > 1).map(([k, p]) => {
|
|
1637
|
+
const [run, tcid] = JSON.parse(k);
|
|
1638
|
+
return `${cut(str(run), 200)} ${cut(str(tcid), 200)}: ${[...p].sort(byCodePoint).join(", ")}`;
|
|
1639
|
+
});
|
|
1640
|
+
if (tiers.size) {
|
|
1641
|
+
const n = (t) => [...tiers.values()].filter((x) => x === t).length;
|
|
1642
|
+
rep.check("tiers", !split.length, `${tiers.size} tool call(s): ` + ["T1", "T2", "T3"].map((t) => `${t} ${n(t)}`).join(", ")
|
|
1643
|
+
+ (n(null) ? `, untiered ${n(null)}` : "") + (split.length ? `; ${split.length} decided and completed by different capture paths` : ""), split.slice(0, 20), true);
|
|
1644
|
+
}
|
|
1645
|
+
const sources = events.filter((e) => has(e, "args_source")).map((e) => e.args_source);
|
|
1646
|
+
if (sources.length)
|
|
1647
|
+
rep.check("args source", true, `${sources.length} record(s): `
|
|
1648
|
+
+ ["raw", "parsed", "coerced"].map((s) => `${s} ${sources.filter((x) => x === s).length}`).join(", "));
|
|
1649
|
+
const firsts = [...runs.values()].map((rs) => rs[0].event);
|
|
1650
|
+
const registered = firsts.map((e) => get(e, "type") === "run.registered" ? at(e, "data") : {});
|
|
1651
|
+
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(", "));
|
|
1652
|
+
const principals = firsts.filter((e) => get(e, "type") === "run.registered" && has(e, "principal"))
|
|
1653
|
+
.map((e) => `${cut(str(e.principal), 256)} (${get(e, "principal_attested") === true ? "attested" : "app-asserted"})`);
|
|
1654
|
+
if (principals.length)
|
|
1655
|
+
rep.check("principals", true, principals.slice(0, 20).join(", ") + (principals.length > 20 ? ", ..." : ""));
|
|
1656
|
+
if (registered.some((d) => has(d, "harness"))) {
|
|
1657
|
+
const bound = firsts.map((e, i) => `${cut(str(get(e, "run_id")), 200)} `
|
|
1658
|
+
+ (isObj(get(registered[i], "harness")) ? cut(str(get(registered[i].harness, "name")), 64) : "none"));
|
|
1659
|
+
rep.check("harness", true, "signer-attested: " + bound.slice(0, 20).join(", ") + (bound.length > 20 ? ", ..." : ""));
|
|
1660
|
+
}
|
|
1661
|
+
const failOpen = [...new Set(registered.flatMap((d) => {
|
|
1662
|
+
const modes = get(d, "fail_modes");
|
|
1663
|
+
if (!truthy(modes))
|
|
1664
|
+
return [];
|
|
1665
|
+
if (!isObj(modes))
|
|
1666
|
+
throw new TypeError("fail_modes is not an object");
|
|
1667
|
+
return Object.entries(modes).filter(([, m]) => m === "open").map(([c]) => cut(c, 64));
|
|
1668
|
+
}))].sort(byCodePoint);
|
|
1669
|
+
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);
|
|
1670
|
+
rep.check("key assurance", true, "asserted: the log declares its record keys; none is attested");
|
|
1671
|
+
const algs = new Set([...every, ...keyRecords].map((r) => text(at(r, "alg"))));
|
|
1672
|
+
rep.assurance = !anchored ? "none: no trust config pins a log key or witness"
|
|
1673
|
+
: await assurance(origin, cosigs, witnesses, trust, algs, selfApproved, regCosigs)
|
|
1674
|
+
+ (provenTo < relied ? "; key retirements not proven complete" : "");
|
|
1675
|
+
approvals(rep, runs, policies, floats);
|
|
1676
|
+
const glass = typed("approval").filter((e) => get(at(e, "data"), "break_glass") === true);
|
|
1677
|
+
if (glass.length)
|
|
1678
|
+
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 `
|
|
1679
|
+
+ `${cut(text(at(e.data, "approver")), 256)}: ${repr(cut(str(get(e.data, "reason")), 200))}`).slice(0, 20), true);
|
|
1680
|
+
const pointed = runSet ? jsonl("registry/records.jsonl") : []; // the records a run-set's leaves name
|
|
1681
|
+
checkExt(rep, [...new Map([...keyRecords, ...pointed, ...every].map((r) => [at(at(r, "event"), "seq"), r.event]))
|
|
1682
|
+
.values()], get(manifest, "verifier_version"));
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* ext.amended: the JSON pointers of the fields an LLM gateway changed in a model request before sending it, to show the
|
|
1686
|
+
* reasoning the request has the model do. The signer writes it on a gateway's request record only, whose content digest
|
|
1687
|
+
* is of the request as sent; anywhere else, or not a list of pointers, it is a warning.
|
|
1688
|
+
*/
|
|
1689
|
+
function checkAmended(rep, events) {
|
|
1690
|
+
const fields = (e) => {
|
|
1691
|
+
const v = isObj(get(e, "ext")) ? get(e.ext, "amended") : null;
|
|
1692
|
+
const data = get(e, "data", {});
|
|
1693
|
+
return get(e, "type") === "model.exchange" && get(e, "source") === "gateway" && isObj(data)
|
|
1694
|
+
&& get(data, "phase") === "request" && Array.isArray(v) && v.length > 0 && v.every((p) => typeof p === "string"
|
|
1695
|
+
&& [...p].length <= 256 && /^(\/([^~/]|~[01])*)+$/u.test(p)) ? v : null;
|
|
1696
|
+
};
|
|
1697
|
+
const good = events.filter((e) => fields(e)), bad = events.filter((e) => !fields(e));
|
|
1698
|
+
const n = new Map();
|
|
1699
|
+
for (const e of good)
|
|
1700
|
+
for (const p of fields(e))
|
|
1701
|
+
n.set(p, (n.get(p) ?? 0) + 1);
|
|
1702
|
+
const shown = [...n].sort(byKey).slice(0, 8).map(([p, c]) => `${p}: ${c}`);
|
|
1703
|
+
rep.check("gateway amendments", bad.length === 0, `gateway amended ${good.length} request(s) to show reasoning`
|
|
1704
|
+
+ (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`)
|
|
1705
|
+
.slice(0, 20), true);
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* ext.openshell.policy: a change of the sandbox's policy or configuration that OpenShell reported (its name for the
|
|
1709
|
+
* change, its hash of the policy, who made it; its message committed), an `ext` record the signer writes from the
|
|
1710
|
+
* OpenShell middleware's OCSF import, that observer's assertion; anything else carrying the name is a warning.
|
|
1711
|
+
*/
|
|
1712
|
+
function checkOpenshellPolicy(rep, events) {
|
|
1713
|
+
const change = (e) => {
|
|
1714
|
+
const d = get(e, "data");
|
|
1715
|
+
return get(e, "type") === "ext" && get(e, "source") === "signer" && isObj(d)
|
|
1716
|
+
&& get(d, "name") === "openshell.policy" && typeof get(d, "change") === "string"
|
|
1717
|
+
&& /^[a-z0-9_.-]{1,64}$/u.test(d.change) && typeof get(d, "observer") === "string" && d.observer !== ""
|
|
1718
|
+
? d.change : null;
|
|
1719
|
+
};
|
|
1720
|
+
const good = events.filter((e) => change(e)), bad = events.filter((e) => !change(e));
|
|
1721
|
+
const n = new Map();
|
|
1722
|
+
for (const e of good)
|
|
1723
|
+
n.set(change(e), (n.get(change(e)) ?? 0) + 1);
|
|
1724
|
+
const shown = [...n].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).slice(0, 8).map(([c, k]) => `${c}: ${k}`);
|
|
1725
|
+
rep.check("openshell policy", bad.length === 0, "OpenShell changed the sandbox policy or configuration "
|
|
1726
|
+
+ `${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 `
|
|
1727
|
+
+ "recorded").slice(0, 20), true);
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* The ext names this verifier interprets (the VERSIONS rows' "ext" in tracekit/bundle_v2.py): name -> its check, whose
|
|
1731
|
+
* lines are its own: every other line of the report reads the same without them (docs/format-v2.md §9).
|
|
1732
|
+
*/
|
|
1733
|
+
const EXT_CHECKS = new Map([["amended", checkAmended],
|
|
1734
|
+
["openshell.policy", checkOpenshellPolicy]]);
|
|
1735
|
+
const MAX_UNREAD = 20; // `not interpreted` lines, one per ext name; the rest on one line
|
|
1736
|
+
/** The ext names an event carries: its `ext` field's keys, and an `ext` record's name. */
|
|
1737
|
+
const extNames = (e) => [...Object.keys(isObj(get(e, "ext")) ? e.ext : {}),
|
|
1738
|
+
...get(e, "type") === "ext" ? [at(at(e, "data"), "name")] : []];
|
|
1739
|
+
/**
|
|
1740
|
+
* A line per ext name the records carry: its EXT_CHECKS check, or a `not interpreted` warning. The records were checked
|
|
1741
|
+
* like every other (signature, chain, inclusion); `full`: the manifest's advisory verifier_version.
|
|
1742
|
+
*/
|
|
1743
|
+
function checkExt(rep, events, full) {
|
|
1744
|
+
const carry = new Map(); // name -> the events carrying it
|
|
1745
|
+
for (const e of events)
|
|
1746
|
+
for (const n of new Set(extNames(e).map((x) => cut(str(x), 64)))) {
|
|
1747
|
+
if (!carry.has(n))
|
|
1748
|
+
carry.set(n, []);
|
|
1749
|
+
carry.get(n).push(e);
|
|
1750
|
+
}
|
|
1751
|
+
const later = typeof full === "string" && /^\d{1,9}(\.\d{1,9}){0,2}$/.test(full) && newer(full, VERSION)
|
|
1752
|
+
? `; tracekit >= ${full} checks it` : "";
|
|
1753
|
+
let shown = 0, more = 0;
|
|
1754
|
+
for (const [n, es] of [...carry].sort(byKey)) {
|
|
1755
|
+
const interpret = EXT_CHECKS.get(n);
|
|
1756
|
+
if (interpret)
|
|
1757
|
+
interpret(rep, es);
|
|
1758
|
+
else if (shown < MAX_UNREAD) {
|
|
1759
|
+
shown++;
|
|
1760
|
+
rep.check("not interpreted", false, `${es.length} record(s) carry ext.${n}: their signatures, chain and `
|
|
1761
|
+
+ `inclusion are checked above; what ext.${n} means is not checked${later}`, [], true);
|
|
1762
|
+
}
|
|
1763
|
+
else
|
|
1764
|
+
more++; // a line each would bury the report
|
|
1765
|
+
}
|
|
1766
|
+
if (more)
|
|
1767
|
+
rep.check("not interpreted", false, `${more} more ext name(s), on records whose signatures, chain and inclusion `
|
|
1768
|
+
+ `are checked above; what they mean is not checked${later}`, [], true);
|
|
1769
|
+
}
|
|
1770
|
+
/** Approvals by people and by agents, each on a line of its own. An agent's approval fails the line unless its run
|
|
1771
|
+
* holds the approval.request and policy.decision it answered, the signer called the request agent-eligible
|
|
1772
|
+
* (agent.eligible) and no approval.escalated or approval.revoked of it comes first, the decision has no provenance and
|
|
1773
|
+
* a complete provenance_state (or none: the signer leaves it out then), its approval needs no passkey, and the agent is
|
|
1774
|
+
* neither the requester nor the run's owner; with the policy snapshot of the decision's policy_hash in `policies` (the
|
|
1775
|
+
* 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
|
|
1776
|
+
* passkey (else a warning: bounds unchecked). Argument bounds are never checkable offline (salted commitments). */
|
|
1777
|
+
function approvals(rep, runs, policies, floats) {
|
|
1778
|
+
let people = 0, agents = 0;
|
|
1779
|
+
const problems = [], unchecked = [];
|
|
1780
|
+
for (const rs of runs.values()) {
|
|
1781
|
+
const events = rs.map((r) => r.event);
|
|
1782
|
+
const ident = get(events[0], "type") === "run.registered" ? get(at(events[0], "data"), "identity") ?? {} : {};
|
|
1783
|
+
const owner = `${str(get(ident, "scheme"))}:${cut(str(get(ident, "subject")), 256)}`;
|
|
1784
|
+
const asked = new Map(), decided = new Map();
|
|
1785
|
+
for (const e of events) {
|
|
1786
|
+
if (e.type === "approval.request")
|
|
1787
|
+
asked.set(get(e.data, "approval_id"), e.data);
|
|
1788
|
+
if (e.type === "policy.decision" && has(e.data, "decision_id"))
|
|
1789
|
+
decided.set(e.data.decision_id, e.data);
|
|
1790
|
+
}
|
|
1791
|
+
const toPeople = new Set(); // approval ids escalated or revoked so far: only people answer them
|
|
1792
|
+
for (const e of events) {
|
|
1793
|
+
const d = e.data;
|
|
1794
|
+
if (e.type === "approval.escalated" || e.type === "approval.revoked")
|
|
1795
|
+
toPeople.add(get(d, "approval_id"));
|
|
1796
|
+
if (e.type !== "approval" || get(d, "decision") !== "approve")
|
|
1797
|
+
continue;
|
|
1798
|
+
if (get(d, "approver_kind") !== "agent") {
|
|
1799
|
+
people++;
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
agents++;
|
|
1803
|
+
const where = `seq ${e.seq}: ${cut(str(get(d, "approval_id")), 128)}`;
|
|
1804
|
+
const req = asked.get(get(d, "approval_id")) ?? {};
|
|
1805
|
+
const dec = decided.get(get(req, "decision_id"));
|
|
1806
|
+
if (dec === undefined) {
|
|
1807
|
+
problems.push(`${where}: its approval.request and policy.decision are not in the run`);
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
const ai = get(d, "approver_identity") ?? {};
|
|
1811
|
+
if (get(get(req, "agent") ?? {}, "eligible") !== true)
|
|
1812
|
+
problems.push(`${where}: the signer did not route its approval.request to agents (agent.eligible)`);
|
|
1813
|
+
if (toPeople.has(get(d, "approval_id")))
|
|
1814
|
+
problems.push(`${where}: it was escalated or revoked before the agent approved it`);
|
|
1815
|
+
if (truthy(get(dec, "provenance")) || get(dec, "provenance_state", "complete") !== "complete")
|
|
1816
|
+
problems.push(`${where}: the decision's arguments carry provenance or it is incomplete`);
|
|
1817
|
+
if (get(req, "passkey") === true)
|
|
1818
|
+
problems.push(`${where}: a rule of the call needs a passkey`);
|
|
1819
|
+
if (get(d, "approver") === get(req, "requester") || `${str(get(ai, "scheme"))}:${cut(str(get(ai, "subject")), 256)}` === owner)
|
|
1820
|
+
problems.push(`${where}: the agent is the requester or the run's owner`);
|
|
1821
|
+
const hash = str(get(dec, "policy_hash")), snap = policies.get(hash);
|
|
1822
|
+
if (snap === undefined) {
|
|
1823
|
+
unchecked.push(`${where}: no policy snapshot of ${cut(hash, 80)}; export the run with the signer's config, which `
|
|
1824
|
+
+ "bundles the policies its records cite");
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
let asks, flags;
|
|
1828
|
+
try {
|
|
1829
|
+
const pol = strictParseBytes(snap, floats);
|
|
1830
|
+
asks = new Map(list(get(pol, "ask", [])).map((r) => [at(r, "id"), r]));
|
|
1831
|
+
flags = new Set(list(get(pol, "flag", [])).map((r) => at(r, "id")));
|
|
1832
|
+
}
|
|
1833
|
+
catch {
|
|
1834
|
+
problems.push(`${where}: the policy snapshot is not a policy`);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
for (const rid of get(dec, "rule_ids") ?? []) {
|
|
1838
|
+
const approval = get(asks.get(rid) ?? {}, "approval") ?? {};
|
|
1839
|
+
if (!flags.has(rid) && (!isObj(approval) || !has(approval, "agent") || approval.passkey === "required"))
|
|
1840
|
+
problems.push(`${where}: rule ${cut(str(rid), 64)} does not permit agents`);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (!agents)
|
|
1845
|
+
return; // a bundle without agent approvals keeps the report it always had
|
|
1846
|
+
rep.check("approvals by people", true, `${people} approval(s) answered by people`);
|
|
1847
|
+
rep.check("approvals by agents", !problems.length && !unchecked.length, `${agents} approval(s) answered by agents (model `
|
|
1848
|
+
+ "output); argument bounds are not checkable offline (salted commitments): audit one with `tracekit signer reveal`"
|
|
1849
|
+
+ (unchecked.length ? `; agent approval bounds unchecked for ${unchecked.length}` : ""), (problems.length ? problems : unchecked).slice(0, 20), !problems.length);
|
|
1850
|
+
}
|
|
1851
|
+
const CONTAINMENT = ["incident.opened", "incident.updated", "run.held", "run.quarantined", "run.resumed"];
|
|
1852
|
+
const FIRES = ["policy.decision", "approval.consumed", "capture.gap", "trace.tamper", "refusal.summary",
|
|
1853
|
+
"reconcile.unexplained_effect"];
|
|
1854
|
+
const RECOMPUTED = ["sequence", "limit", "denied_calls", "provenance", "executed_against_policy", "class_mismatch"];
|
|
1855
|
+
/** [analyzes, replay_of, investigation] of a run's run.registered (nulls when it is not one). */
|
|
1856
|
+
function about(rs) {
|
|
1857
|
+
const e = at(rs[0], "event"), d = get(e, "type") === "run.registered" ? get(e, "data") : null;
|
|
1858
|
+
return ["analyzes", "replay_of", "investigation"].map((k) => truthy(d) ? get(d, k) : null);
|
|
1859
|
+
}
|
|
1860
|
+
/** Whether every run but one of the same tenant is a findings or replay run of that one. */
|
|
1861
|
+
function aboutOne(runs) {
|
|
1862
|
+
const all = [...runs.values()], main = all.filter((rs) => about(rs)[0] === null && about(rs)[1] === null);
|
|
1863
|
+
if (main.length !== 1)
|
|
1864
|
+
return false;
|
|
1865
|
+
const head = main[0][0].event;
|
|
1866
|
+
return all.every((rs) => rs === main[0] || same(get(rs[0].event, "tenant"), get(head, "tenant"))
|
|
1867
|
+
&& about(rs).slice(0, 2).some((x) => same(x, get(head, "run_id"))));
|
|
1868
|
+
}
|
|
1869
|
+
/** ["scheme:subject", signer_isolation] of a run's run.registered (nulls when it is not one). */
|
|
1870
|
+
function author(rs) {
|
|
1871
|
+
const e = at(rs[0], "event"), d = get(e, "type") === "run.registered" ? get(e, "data") : {};
|
|
1872
|
+
const i = get(d, "identity");
|
|
1873
|
+
return [truthy(i) ? `${str(at(i, "scheme"))}:${str(at(i, "subject"))}` : null, get(d, "signer_isolation")];
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Signed findings (format 1.4, docs/format-v2.md §11): each `review` with `cites`, in a findings run, cites records of
|
|
1877
|
+
* the analysed run that are in the bundle with those hashes (else `reviews` fails), and is labelled by its author (the
|
|
1878
|
+
* findings run's registered identity), positive-only: independent only when the trust config's `analyzers` pins it;
|
|
1879
|
+
* self-asserted (a warning) when it is the analysed run's identity or either run's signer_isolation is same-user; else
|
|
1880
|
+
* unpinned. Without its analysed run: not counted, author not checked (`reviews counted`). No cites: imported.
|
|
1881
|
+
*/
|
|
1882
|
+
/** What is wrong with a signed finding's verdict `v` (VERDICT; its asserted model, families and window), or null. */
|
|
1883
|
+
function verdictProblem(v) {
|
|
1884
|
+
if (!isObj(v) || typeof v.rule !== "string" || !/^TK-[A-Z][0-9]{3}$/.test(v.rule)
|
|
1885
|
+
|| !["kind", "severity"].every((k) => VERDICT[k].includes(get(v, k))))
|
|
1886
|
+
return "its verdict has no kind, rule and severity of a signed finding";
|
|
1887
|
+
const a = get(v, "asserted"), f = get(v, "families"), w = get(v, "window");
|
|
1888
|
+
const name = (x) => typeof x === "string" && /^[A-Za-z0-9._:/@+-]{1,128}$/.test(x);
|
|
1889
|
+
const seconds = (x) => typeof x === "number" && x >= 0;
|
|
1890
|
+
const checks = [
|
|
1891
|
+
["asserted", isObj(a) && name(get(a, "model")) && name(get(a, "version"))
|
|
1892
|
+
&& typeof get(a, "prompt_hash") === "string" && /^sha256:[0-9a-f]{64}$/.test(str(get(a, "prompt_hash")))],
|
|
1893
|
+
["families", isObj(f) && (get(f, "reviewer") === null || FAMILIES.includes(str(get(f, "reviewer"))))
|
|
1894
|
+
&& Array.isArray(get(f, "agent")) && list(get(f, "agent")).every((x) => typeof x === "string" && FAMILIES.includes(x))],
|
|
1895
|
+
["window", isObj(w) && seconds(get(w, "grace_s")) && seconds(get(w, "skew_s"))],
|
|
1896
|
+
];
|
|
1897
|
+
const bad = [...Object.keys(VERDICT).filter((k) => has(v, k) && !VERDICT[k].includes(v[k])),
|
|
1898
|
+
...checks.filter(([k, ok]) => has(v, k) && !ok).map(([k]) => k)];
|
|
1899
|
+
return bad.length ? `its verdict's ${bad.join(", ")} is not what a signed finding holds` : null;
|
|
1900
|
+
}
|
|
1901
|
+
function checkReviews(rep, runs, pinned) {
|
|
1902
|
+
const byId = new Map([...runs.values()].map((rs) => [get(rs[0].event, "run_id"), rs]));
|
|
1903
|
+
const labels = [], problems = [], selfs = [], uncounted = [];
|
|
1904
|
+
const hashesOf = new Map(); // analysed run id -> {run_seq: record hash}, built once
|
|
1905
|
+
let total = 0;
|
|
1906
|
+
for (const rs of runs.values()) {
|
|
1907
|
+
const reviews = rs.map((r) => r.event).filter((e) => get(e, "type") === "review");
|
|
1908
|
+
const signed = reviews.filter((e) => has(at(e, "data"), "cites"));
|
|
1909
|
+
total += reviews.length;
|
|
1910
|
+
extend(labels, Array(reviews.length - signed.length).fill("imported"));
|
|
1911
|
+
if (!signed.length)
|
|
1912
|
+
continue;
|
|
1913
|
+
for (const e of signed) {
|
|
1914
|
+
const why = verdictProblem(get(at(e, "data"), "verdict"));
|
|
1915
|
+
if (why)
|
|
1916
|
+
problems.push(`seq ${e.seq}: ${why}`);
|
|
1917
|
+
}
|
|
1918
|
+
const rid = cut(str(get(rs[0].event, "run_id")), 128), analyzes = about(rs)[0];
|
|
1919
|
+
if (analyzes === null) {
|
|
1920
|
+
extend(problems, signed.map((e) => `seq ${e.seq}: a signed finding outside a findings run`));
|
|
1921
|
+
continue;
|
|
1922
|
+
}
|
|
1923
|
+
const target = byId.get(analyzes);
|
|
1924
|
+
if (!target) {
|
|
1925
|
+
extend(labels, Array(signed.length).fill("author not checked"));
|
|
1926
|
+
uncounted.push(`findings run ${rid}: run ${cut(str(analyzes), 128)} is not in the bundle`);
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
if (!hashesOf.has(analyzes))
|
|
1930
|
+
hashesOf.set(analyzes, new Map(target.map((r) => [get(r.event, "run_seq"), r.hash])));
|
|
1931
|
+
const hashes = hashesOf.get(analyzes);
|
|
1932
|
+
for (const e of signed)
|
|
1933
|
+
for (const c of list(at(e.data, "cites")))
|
|
1934
|
+
if (hashes.get(at(c, "run_seq")) !== at(c, "hash"))
|
|
1935
|
+
problems.push(`seq ${e.seq}: cites run_seq ${str(c.run_seq)} of run ${cut(str(analyzes), 128)}, `
|
|
1936
|
+
+ (hashes.has(c.run_seq) ? "whose hash differs" : "which it does not have"));
|
|
1937
|
+
const [mine, isolation] = author(rs), [theirs, theirIsolation] = author(target);
|
|
1938
|
+
let label;
|
|
1939
|
+
if (mine === theirs || [isolation, theirIsolation].includes("same-user")) {
|
|
1940
|
+
label = "self-asserted";
|
|
1941
|
+
selfs.push(`findings run ${rid}: ` + (mine === theirs
|
|
1942
|
+
? `its author ${cut(str(mine), 256)} is the analysed run's identity`
|
|
1943
|
+
: "the signer runs as the same user as a client (signer_isolation same-user)"));
|
|
1944
|
+
}
|
|
1945
|
+
else
|
|
1946
|
+
label = pinned.includes(mine) ? "independent" : `unpinned author ${cut(str(mine), 256)}`;
|
|
1947
|
+
extend(labels, Array(signed.length).fill(label));
|
|
1948
|
+
}
|
|
1949
|
+
if (total) {
|
|
1950
|
+
rep.check("reviews", !problems.length && !selfs.length, `${total} review(s): `
|
|
1951
|
+
+ count(labels).map(([k, n]) => `${n} ${k}`).join(", "), (problems.length ? problems : selfs).slice(0, 20), !problems.length);
|
|
1952
|
+
const verdicts = [...runs.values()].flatMap((rs) => rs.map((r) => r.event)) // signed findings only
|
|
1953
|
+
.filter((e) => get(e, "type") === "review" && has(at(e, "data"), "cites")).map((e) => get(e.data, "verdict"));
|
|
1954
|
+
const sev = count(verdicts.filter((v) => isObj(v) && get(v, "kind") === "finding")
|
|
1955
|
+
.map((v) => cut(str(get(v, "severity")), 16)));
|
|
1956
|
+
const n = sev.reduce((a, [, k]) => a + k, 0), cov = verdicts.filter((v) => isObj(v) && get(v, "kind") === "coverage").length;
|
|
1957
|
+
rep.findings = `${n} finding(s)` + (n ? " (" + SEVERITIES.flatMap((s) => sev.filter(([k]) => k === s))
|
|
1958
|
+
.map(([k, c]) => `${c} ${k}`).join(", ") + ")" : "") + (cov ? ` and ${cov} coverage review(s)` : "") + "; "
|
|
1959
|
+
+ count(labels).map(([k, c]) => `${c} ${k}`).join(", ") + " (an opinion of the run, not its integrity)";
|
|
1960
|
+
}
|
|
1961
|
+
if (uncounted.length)
|
|
1962
|
+
rep.check("reviews counted", false, `${labels.filter((l) => l === "author not checked").length} signed finding(s) not `
|
|
1963
|
+
+ "counted: export the analysed run with `tracekit export --v2 --run <run> --with-findings` to check their citations", uncounted.slice(0, 20), true);
|
|
1964
|
+
}
|
|
1965
|
+
/** The `constraints` line, as verify/v2.py's _constraints: live constraints added, and the calls they held, each an
|
|
1966
|
+
* ask after one in its run. */
|
|
1967
|
+
function checkConstraints(rep, runs) {
|
|
1968
|
+
let added = 0, held = 0;
|
|
1969
|
+
const problems = [];
|
|
1970
|
+
for (const rs of runs.values()) {
|
|
1971
|
+
let seen = false;
|
|
1972
|
+
for (const r of rs) {
|
|
1973
|
+
const e = r.event;
|
|
1974
|
+
if (get(e, "type") === "constraint.added") {
|
|
1975
|
+
added++;
|
|
1976
|
+
seen = true;
|
|
1977
|
+
}
|
|
1978
|
+
else if (get(e, "type") === "policy.decision" && Array.isArray(get(at(e, "data"), "rule_ids"))
|
|
1979
|
+
&& get(e.data, "rule_ids").includes("TK-CONSTRAINT")) {
|
|
1980
|
+
held++;
|
|
1981
|
+
const decision = get(e.data, "decision");
|
|
1982
|
+
if (!seen || decision !== "ask")
|
|
1983
|
+
problems.push(`seq ${e.seq}: a ${cut(str(decision), 16)} held by TK-CONSTRAINT `
|
|
1984
|
+
+ (!seen ? "before any constraint.added in its run" : "is no ask"));
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (added || held)
|
|
1989
|
+
rep.check("constraints", !problems.length, `${added} live constraint(s) added; ${held} call(s) held by them`, problems.slice(0, 20));
|
|
1990
|
+
}
|
|
1991
|
+
/** The `incidents` line, as verify/v2.py's _incidents: cited records, incidents recomputed from their run under the
|
|
1992
|
+
* bundled compiled policy (`policies`, by policy_hash), responses honoured. */
|
|
1993
|
+
function checkIncidents(rep, runs, policies, floats) {
|
|
1994
|
+
const byRun = new Map([...runs.values()].map((rs) => [get(rs[0].event, "run_id"), rs.map((r) => r.event)]));
|
|
1995
|
+
if (![...byRun.values()].some((es) => es.some((e) => CONTAINMENT.includes(get(e, "type")))))
|
|
1996
|
+
return;
|
|
1997
|
+
const problems = [], pointers = new Set(), opened = new Set(), unbundled = new Set();
|
|
1998
|
+
let checked = 0;
|
|
1999
|
+
for (const es of byRun.values())
|
|
2000
|
+
for (const e of es)
|
|
2001
|
+
if (e.type === "incident.opened")
|
|
2002
|
+
opened.add(e.data.incident_id);
|
|
2003
|
+
for (const [rid, events] of byRun) {
|
|
2004
|
+
const mine = [];
|
|
2005
|
+
for (const e of events) {
|
|
2006
|
+
const d = e.data;
|
|
2007
|
+
if (!["incident.opened", "incident.updated"].includes(e.type))
|
|
2008
|
+
continue;
|
|
2009
|
+
for (const c of list(d.cites)) {
|
|
2010
|
+
const cited = byRun.get(c.run_id);
|
|
2011
|
+
if (cited && (c.run_seq >= cited.length || !FIRES.includes(cited[c.run_seq].type)))
|
|
2012
|
+
problems.push(`seq ${e.seq}: incident ${d.incident_id} cites ${cut(c.run_id, 128)} run_seq ${c.run_seq}, which is `
|
|
2013
|
+
+ "not a record that fires a trigger");
|
|
2014
|
+
}
|
|
2015
|
+
if (e.type === "incident.updated")
|
|
2016
|
+
continue;
|
|
2017
|
+
if (d.trigger === "detector_unavailable") {
|
|
2018
|
+
const c = d.cites.length === 1 ? d.cites[0] : {}, cited = byRun.get(c.run_id) ?? [];
|
|
2019
|
+
const ok = (c.run_seq ?? cited.length) < cited.length && cited[c.run_seq].type === "policy.decision"
|
|
2020
|
+
&& cited[c.run_seq].data.detect?.state === "unavailable";
|
|
2021
|
+
if (ok)
|
|
2022
|
+
checked++;
|
|
2023
|
+
else
|
|
2024
|
+
problems.push(`seq ${e.seq}: incident ${d.incident_id} (detector_unavailable) cites no decision whose detection `
|
|
2025
|
+
+ "state was unavailable");
|
|
2026
|
+
}
|
|
2027
|
+
else if (d.scope.kind === "run" && RECOMPUTED.includes(d.trigger) && d.cites.every((c) => c.run_id === rid))
|
|
2028
|
+
mine.push(e);
|
|
2029
|
+
}
|
|
2030
|
+
const hashes = new Set(events.filter((e) => e.type === "policy.decision").map((e) => get(e.data, "policy_hash")));
|
|
2031
|
+
let det = null;
|
|
2032
|
+
if (mine.length && hashes.size === 1) {
|
|
2033
|
+
const h = str([...hashes][0]), data = policies.get(h);
|
|
2034
|
+
if (!data)
|
|
2035
|
+
unbundled.add(cut(h, 80));
|
|
2036
|
+
else
|
|
2037
|
+
det = new Detector(strictParseBytes(data, floats));
|
|
2038
|
+
if (det) {
|
|
2039
|
+
const st = Detector.state(), expected = [], key = (d) => JSON.stringify([d.trigger, d.rule_ids,
|
|
2040
|
+
d.cites.map((c) => [c.run_id, c.run_seq]), d.mode]);
|
|
2041
|
+
for (const e of events) {
|
|
2042
|
+
if (mine.includes(e)) {
|
|
2043
|
+
const i = expected.findIndex((x) => key(x) === key(e.data));
|
|
2044
|
+
if (i >= 0) {
|
|
2045
|
+
const [hit] = expected.splice(i, 1);
|
|
2046
|
+
checked++;
|
|
2047
|
+
if (hit.mode === "enforce" && hit.would.length && !list(e.data.actions).length && !has(e.data, "reason"))
|
|
2048
|
+
problems.push(`seq ${e.seq}: incident ${e.data.incident_id} (${e.data.trigger}) is enforced under policy `
|
|
2049
|
+
+ `${cut(h, 80)} but records no response (${hit.would.join(", ")}) and no reason`);
|
|
2050
|
+
}
|
|
2051
|
+
else
|
|
2052
|
+
problems.push(`seq ${e.seq}: incident ${e.data.incident_id} (${e.data.trigger}) does not follow from the `
|
|
2053
|
+
+ `run's records under policy ${cut(h, 80)}`);
|
|
2054
|
+
}
|
|
2055
|
+
expected.push(...det.observe(st, e).filter(([t]) => t === "incident.opened").map(([, d]) => d));
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
extend(problems, honoured(events, pointers));
|
|
2060
|
+
}
|
|
2061
|
+
const n = opened.size + [...pointers].filter((p) => !opened.has(p)).length;
|
|
2062
|
+
rep.check("incidents", !problems.length, `${n} incident(s): ${checked} checked against the bundle, ${n - checked} `
|
|
2063
|
+
+ "signer-asserted (operator, cross-run, under several policies, or under a policy not in the bundle)", problems.slice(0, 20));
|
|
2064
|
+
if (unbundled.size)
|
|
2065
|
+
rep.check("incident policies", false, `${unbundled.size} cited policy(ies) not in the bundle: their incidents are `
|
|
2066
|
+
+ "signer-asserted, not recomputed (export the run with the signer's config, which bundles the policies its "
|
|
2067
|
+
+ "records cite)", [...unbundled].sort(byCodePoint).slice(0, 20), true);
|
|
2068
|
+
}
|
|
2069
|
+
/** The problems of one run's records with the holds and quarantines they record (verify/v2.py's _honoured). */
|
|
2070
|
+
function honoured(events, pointers) {
|
|
2071
|
+
const problems = [], flight = new Set(), asked = new Map(), consumed = new Set();
|
|
2072
|
+
const due = new Map();
|
|
2073
|
+
let held = [], quarantined = null;
|
|
2074
|
+
const state = () => quarantined ? "quarantined" : "held";
|
|
2075
|
+
const unfollowed = () => [...due].map(([i, [seq, a]]) => `seq ${seq}: incident ${i} enforces ${a} on this run, which `
|
|
2076
|
+
+ "no run.held or run.quarantined follows");
|
|
2077
|
+
for (const e of events) {
|
|
2078
|
+
const t = e.type, d = e.data, where = `seq ${e.seq}`, on = quarantined || held.join(" and ");
|
|
2079
|
+
if (t === "policy.decision" && due.size && !quarantined) { // an enforced response is written before the next call
|
|
2080
|
+
extend(problems, unfollowed());
|
|
2081
|
+
due.clear();
|
|
2082
|
+
}
|
|
2083
|
+
if (t === "incident.opened" && d.scope.kind === "run" && d.scope.id === str(e.run_id).slice(0, 256)
|
|
2084
|
+
&& Object.keys(d.scope).length === 2 && d.actions.some((a) => a === "hold" || a === "quarantine"))
|
|
2085
|
+
due.set(d.incident_id, [e.seq, d.actions.includes("quarantine") ? "quarantine" : "hold"]);
|
|
2086
|
+
else if (t === "run.held" || t === "run.quarantined") {
|
|
2087
|
+
pointers.add(d.incident_id);
|
|
2088
|
+
due.delete(d.incident_id);
|
|
2089
|
+
for (const x of d.in_flight ?? [])
|
|
2090
|
+
flight.add(x.decision_id);
|
|
2091
|
+
if (t === "run.quarantined")
|
|
2092
|
+
[quarantined, held] = [d.incident_id, []];
|
|
2093
|
+
else if (!quarantined && !held.includes(d.incident_id))
|
|
2094
|
+
held.push(d.incident_id);
|
|
2095
|
+
}
|
|
2096
|
+
else if (t === "run.resumed") {
|
|
2097
|
+
held = held.filter((i) => i !== d.incident_id);
|
|
2098
|
+
if (JSON.stringify([...(d.held_by ?? held)].sort()) !== JSON.stringify([...held].sort()))
|
|
2099
|
+
problems.push(`${where}: run.resumed says ${d.held_by.join(" and ") || "no incident"} still holds the run, its `
|
|
2100
|
+
+ `records ${held.join(" and ") || "none"}`);
|
|
2101
|
+
}
|
|
2102
|
+
else if (t === "policy.decision" && on && (quarantined ? d.decision !== "deny"
|
|
2103
|
+
: d.decision !== "deny" && (d.decision !== "ask" || !held.includes(d.incident_id ?? null))))
|
|
2104
|
+
problems.push(`${where}: ${d.decision} while the run is ${state()} by incident ${on}`);
|
|
2105
|
+
else if (t === "approval.request") {
|
|
2106
|
+
asked.set(d.approval_id, d.incident_id ?? null);
|
|
2107
|
+
if (quarantined || held.length && !held.includes(d.incident_id ?? null))
|
|
2108
|
+
problems.push(`${where}: an approval asked while the run is ${state()} by incident ${on}, not as its held call`);
|
|
2109
|
+
}
|
|
2110
|
+
else if (t === "approval" && asked.get(d.approval_id ?? null) && d.decision === "approve" && (d.approver_kind === "agent" || d.self_approved || !Object.hasOwn(d, "person")
|
|
2111
|
+
&& !Object.hasOwn(d.approver_identity ?? {}, "person") || (d.incident_id ?? null) !== asked.get(d.approval_id)))
|
|
2112
|
+
problems.push(`${where}: a held call approved by other than a person, or without its incident id`);
|
|
2113
|
+
else if (t === "approval.consumed") {
|
|
2114
|
+
if (quarantined || held.length && !held.includes(asked.get(d.approval_id) ?? null))
|
|
2115
|
+
problems.push(`${where}: an approval consumed while the run is ${state()} by incident ${on}`);
|
|
2116
|
+
else if (asked.get(d.approval_id))
|
|
2117
|
+
consumed.add(e.tool_call_id ?? null);
|
|
2118
|
+
}
|
|
2119
|
+
else if (t === "tool.result" && on && !flight.has(d.decision_id) && (quarantined || !consumed.has(e.tool_call_id ?? null)))
|
|
2120
|
+
problems.push(`${where}: a result while the run is ${state()} by incident ${on}, of a call neither in flight then nor `
|
|
2121
|
+
+ "approved by a person for it");
|
|
2122
|
+
}
|
|
2123
|
+
if (!quarantined)
|
|
2124
|
+
extend(problems, unfollowed());
|
|
2125
|
+
return problems;
|
|
2126
|
+
}
|
|
2127
|
+
/** §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,
|
|
2128
|
+
* else why not. */
|
|
2129
|
+
async function verifyRecord(r, keys, algs) {
|
|
2130
|
+
if (!isObj(r) || Object.keys(r).length !== 6 || !["v", "event", "hash", "alg", "kid", "sig"].every((k) => Object.hasOwn(r, k))
|
|
2131
|
+
|| !same(r.v, 2) || !isObj(r.event) || !["hash", "alg", "kid", "sig"].every((k) => typeof r[k] === "string"))
|
|
2132
|
+
return "not a v2 record";
|
|
2133
|
+
let h;
|
|
2134
|
+
try {
|
|
2135
|
+
h = "sha256:" + await sha256hex(bytes(canonicalize(r.event)));
|
|
2136
|
+
}
|
|
2137
|
+
catch (e) {
|
|
2138
|
+
return `event is not canonical JSON: ${e.message}`;
|
|
2139
|
+
}
|
|
2140
|
+
if (r.hash !== h)
|
|
2141
|
+
return "hash does not match the event";
|
|
2142
|
+
if (!algs.includes(r.alg))
|
|
2143
|
+
return `algorithm ${repr(r.alg)} is not allowed`;
|
|
2144
|
+
let key;
|
|
2145
|
+
for (const k of keys)
|
|
2146
|
+
if (await spkiKid(k) === r.kid)
|
|
2147
|
+
key = k;
|
|
2148
|
+
if (!key)
|
|
2149
|
+
return "unknown kid";
|
|
2150
|
+
if (keyAlg(key) !== r.alg)
|
|
2151
|
+
return `algorithm ${repr(r.alg)} is not the key's algorithm`;
|
|
2152
|
+
let sig;
|
|
2153
|
+
try {
|
|
2154
|
+
sig = b64decode(r.sig);
|
|
2155
|
+
}
|
|
2156
|
+
catch {
|
|
2157
|
+
return "sig is not base64";
|
|
2158
|
+
}
|
|
2159
|
+
const position = Object.fromEntries(["log_id", "seq", "prev_hash", "tenant", "run_id", "run_seq", "run_prev_hash"]
|
|
2160
|
+
.map((k) => [k, Object.hasOwn(r.event, k) ? r.event[k] : null]));
|
|
2161
|
+
const message = canonicalize({ ...position, alg: r.alg, kid: r.kid, hash: h, t: "tracekit.record.v2" });
|
|
2162
|
+
return await verifyV2(r.alg, key, bytes(message), sig) ? null : "bad signature";
|
|
2163
|
+
}
|
|
2164
|
+
/** §7: the run-set line (COMPLETE or INCOMPLETE) and the log tail line. Returns the (seq, hash) of every key.retire the
|
|
2165
|
+
* range points to, the highest seq it points to when it starts at registry size 0 (else -1), and the pinned
|
|
2166
|
+
* cosignatures of each registry note. */
|
|
2167
|
+
async function checkRunSet(rep, rs, jsonl, file, trust, witnesses, origin, size, runs, included, checkRecord, floats) {
|
|
2168
|
+
const problems = [], tsalt = b64decode(at(rs, "tenant_salt"));
|
|
2169
|
+
const lo = at(rs, "from"), hi = at(rs, "to"), regOrigin = await registryOrigin(origin, tsalt);
|
|
2170
|
+
// a bound as the reference prints it: 2.0 when its token had a fraction or exponent
|
|
2171
|
+
// lean: integral floats below 1e16 only; Python's repr of others (1e+16, 1e-05) differs from JavaScript's
|
|
2172
|
+
const bound = (k, v) => floats.get(rs)?.has(k) && Number.isInteger(v) && Math.abs(v) < 1e16 ? `${v}.0` : str(v);
|
|
2173
|
+
const span = `registry ${bound("from", lo)}..${bound("to", hi)}`;
|
|
2174
|
+
if (![lo, hi].every((n) => Number.isSafeInteger(n)) || floats.get(rs)?.has("from") || floats.get(rs)?.has("to")) {
|
|
2175
|
+
rep.check("run-set", false, `INCOMPLETE, ${span}`, ["the registry range is not two integers"]);
|
|
2176
|
+
return [[], -1, []];
|
|
2177
|
+
}
|
|
2178
|
+
// the registry notes are signed by the record log's own pinned key, under the registry origin
|
|
2179
|
+
const vkeys = [];
|
|
2180
|
+
for (const k of trust.logs) {
|
|
2181
|
+
const p = await parseVkey(k);
|
|
2182
|
+
if (p.name === origin)
|
|
2183
|
+
vkeys.push(await vkey(regOrigin, p.type, p.pub));
|
|
2184
|
+
}
|
|
2185
|
+
const roots = new Map(), regCosigs = [];
|
|
2186
|
+
for (const n of [...new Set([num(lo), num(hi)])].filter((n) => n !== 0).sort((a, b) => a - b)) {
|
|
2187
|
+
let got;
|
|
2188
|
+
try {
|
|
2189
|
+
const note = await openNote(file(`checkpoints/registry-${n}.note`), vkeys, [...witnesses.keys()], trust.logs.length > 0);
|
|
2190
|
+
got = note.size;
|
|
2191
|
+
roots.set(n, note.root);
|
|
2192
|
+
regCosigs.push(note.cosigs);
|
|
2193
|
+
if (note.unchecked)
|
|
2194
|
+
rep.unsupport("SLH-DSA-SHA2-128s checkpoint signature");
|
|
2195
|
+
if (note.cosigs.length < trust.witnesses_required)
|
|
2196
|
+
problems.push(`registry checkpoint at size ${n}: ${note.cosigs.length} pinned cosignature(s), `
|
|
2197
|
+
+ `${trust.witnesses_required} required`);
|
|
2198
|
+
}
|
|
2199
|
+
catch (e) {
|
|
2200
|
+
got = `unusable (${errorText(e)})`;
|
|
2201
|
+
}
|
|
2202
|
+
if (got !== n)
|
|
2203
|
+
problems.push(`registry checkpoint at size ${n}: ${got}`);
|
|
2204
|
+
}
|
|
2205
|
+
if (problems.length || !(0 <= lo && lo <= hi) || hi === 0) {
|
|
2206
|
+
rep.check("run-set", false, `INCOMPLETE, ${span}`, problems.length ? problems : [`bad registry range ${lo}..${hi}`]);
|
|
2207
|
+
return [[], -1, regCosigs];
|
|
2208
|
+
}
|
|
2209
|
+
if (0 < lo && lo < hi && !await verifyConsistency(lo, hi, roots.get(lo), roots.get(hi), list(at(rs, "consistency")).map(b64decode)))
|
|
2210
|
+
problems.push(`registry checkpoint ${lo} is not a prefix of ${hi}`);
|
|
2211
|
+
const leaves = list(at(rs, "leaves"));
|
|
2212
|
+
const pointed = new Map(jsonl("registry/records.jsonl").map((r) => [at(at(r, "event"), "seq"), r]));
|
|
2213
|
+
if (leaves.length !== hi - lo)
|
|
2214
|
+
problems.push(`${hi - lo} leaves in ${lo}..${hi}, ${leaves.length} in the bundle: a leaf is missing`);
|
|
2215
|
+
const registered = new Map(), finals = new Map(), retired = [], targets = new Set();
|
|
2216
|
+
const gaps = new Map();
|
|
2217
|
+
let closed = null, tenant = null, top = -1;
|
|
2218
|
+
for (const [j, x] of leaves.slice(0, hi - lo).entries()) {
|
|
2219
|
+
const i = lo + j, leaf = b64decode(at(x, "leaf"));
|
|
2220
|
+
if (!await verifyInclusion(i, hi, await leafHash(leaf), list(at(x, "inclusion")).map(b64decode), roots.get(hi))) {
|
|
2221
|
+
problems.push(`leaf ${i} is not in the registry tree of size ${hi}`);
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
const { type, runHash: rh, logId, seq, hash } = parseLeaf(leaf);
|
|
2225
|
+
const r = pointed.get(seq) ?? null, e = r && truthy(r) ? at(r, "event") : r;
|
|
2226
|
+
if (r === null || at(r, "hash") !== hash || at(e, "type") !== type || at(e, "log_id") !== logId || !await included(r)
|
|
2227
|
+
|| !equal(await runHash(tsalt, text(at(e, "run_id"))), rh)
|
|
2228
|
+
|| !SIGNER_LEAVES.includes(type) && tenant !== null && !same(tenant, at(e, "tenant"))) {
|
|
2229
|
+
problems.push(`leaf ${i} points to a missing or different record (seq ${seq})`);
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
await checkRecord(r, problems);
|
|
2233
|
+
top = Math.max(top, seq);
|
|
2234
|
+
if (type === "run.registered" || type === "run.final")
|
|
2235
|
+
targets.add(await runName(at(e, "tenant"), e.run_id));
|
|
2236
|
+
if (type === "run.registered") {
|
|
2237
|
+
tenant = e.tenant;
|
|
2238
|
+
registered.set(e.run_id, r);
|
|
2239
|
+
}
|
|
2240
|
+
else if (type === "run.final") {
|
|
2241
|
+
tenant = e.tenant;
|
|
2242
|
+
if (finals.has(e.run_id))
|
|
2243
|
+
problems.push(`a second run.final for run ${repr(cut(e.run_id, 200))} (seq ${seq})`);
|
|
2244
|
+
finals.set(e.run_id, r);
|
|
2245
|
+
}
|
|
2246
|
+
else if (type === "key.retire")
|
|
2247
|
+
retired.push([seq, hash]);
|
|
2248
|
+
else if (type === "log.closed")
|
|
2249
|
+
closed = e;
|
|
2250
|
+
else {
|
|
2251
|
+
const k = at(at(e, "data"), "kind");
|
|
2252
|
+
gaps.set(k, (gaps.get(k) ?? 0) + 1);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
for (const [runId, final] of finals) {
|
|
2256
|
+
const recs = runs.get(await runName(tenant, runId));
|
|
2257
|
+
if (!recs?.length || at(recs.at(-1), "hash") !== at(final, "hash")
|
|
2258
|
+
|| registered.has(runId) && at(recs[0], "hash") !== at(registered.get(runId), "hash"))
|
|
2259
|
+
problems.push(`run ${repr(cut(runId, 200))} is final in the range but its records are not in the bundle`);
|
|
2260
|
+
}
|
|
2261
|
+
if ([...runs.values()].some((rs) => !same(get(at(rs[0], "event"), "tenant"), tenant)))
|
|
2262
|
+
problems.push("a run in the bundle is not of the run-set's tenant");
|
|
2263
|
+
const untargeted = [...runs.keys()].filter((n) => !targets.has(n)).length;
|
|
2264
|
+
if (untargeted > 1)
|
|
2265
|
+
problems.push(`${untargeted} runs in the bundle, but only the selected run may be in no leaf of the range`);
|
|
2266
|
+
if (truthy(closed) && size > num(at(at(closed, "data"), "final_seq")) + 1)
|
|
2267
|
+
problems.push(`records after log.closed at seq ${str(at(closed, "seq"))}`);
|
|
2268
|
+
const counted = [...gaps].sort(byKey).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
2269
|
+
const named = tenant === null ? "" : ` of tenant ${repr(cut(tenant, 64))}`;
|
|
2270
|
+
const open = [...registered.keys()].filter((k) => !finals.has(k)).length;
|
|
2271
|
+
rep.check("run-set", !problems.length, !problems.length ? `COMPLETE, ${span}${named} (${registered.size} runs registered, `
|
|
2272
|
+
+ `${finals.size} final, ${open} open` + (gaps.size ? `; tenant-level gaps: ${counted}` : "") + ")" : `INCOMPLETE, ${span}`, problems);
|
|
2273
|
+
if (gaps.size)
|
|
2274
|
+
rep.check("tenant-level gaps", false, `${[...gaps.values()].reduce((a, b) => a + b, 0)} in the range: ${counted}`, [], true);
|
|
2275
|
+
rep.check("log tail", truthy(closed), truthy(closed) ? `none: log.closed at seq ${str(closed.seq)} is the last record`
|
|
2276
|
+
: `records after tree size ${size} are unproven (no log.closed in the range)`, [], true);
|
|
2277
|
+
return [retired, lo === 0 ? top : -1, regCosigs];
|
|
2278
|
+
}
|
|
2279
|
+
/** A run's part: the run's file name, the part's number (tracekit/bundle_v2.py PART). */
|
|
2280
|
+
const PART = /^(runs\/.+)\.part-([0-9]+)\.jsonl$/;
|
|
2281
|
+
/** {run file: its entries in order} of a bundle's `names`: the run's file, runs/<id>.jsonl, or its parts,
|
|
2282
|
+
* runs/<id>.part-0001.jsonl, ..., whose records read in that order are the run's (format-v2.md §9). */
|
|
2283
|
+
function runFiles(names) {
|
|
2284
|
+
const out = new Map();
|
|
2285
|
+
for (const n of [...names].filter((x) => x.startsWith("runs/")).sort()) {
|
|
2286
|
+
const m = PART.exec(n), run = m ? `${m[1]}.jsonl` : n;
|
|
2287
|
+
if (!out.has(run))
|
|
2288
|
+
out.set(run, []);
|
|
2289
|
+
out.get(run).push(n);
|
|
2290
|
+
}
|
|
2291
|
+
return out;
|
|
2292
|
+
}
|
|
2293
|
+
/** What is wrong with the parts of `run` (runFiles): `names`, its files in order, and `pieces`, their records. A run in
|
|
2294
|
+
* one file has none; one in parts has part-0001 to part-n and no other file, each holding records, each one's first
|
|
2295
|
+
* record the successor of the last of the part before it. */
|
|
2296
|
+
function parted(run, names, pieces) {
|
|
2297
|
+
if (names.length === 1 && names[0] === run)
|
|
2298
|
+
return [];
|
|
2299
|
+
const stem = run.slice(0, -".jsonl".length), digits = names.filter((n) => n !== run).map((n) => [n, PART.exec(n)[2]]);
|
|
2300
|
+
const numbered = new Set(digits.filter(([, d]) => d.length === 4 && d !== "0000").map(([, d]) => Number(d)));
|
|
2301
|
+
const gaps = [];
|
|
2302
|
+
for (let i = 1; i < Math.max(0, ...numbered); i++)
|
|
2303
|
+
if (!numbered.has(i))
|
|
2304
|
+
gaps.push(i);
|
|
2305
|
+
const held = names.map((n, i) => [n, pieces[i]]).filter(([, p]) => p.length);
|
|
2306
|
+
return [
|
|
2307
|
+
...names.includes(run) ? [`${run} and ${names[1]}: one run in one file and in parts`] : [],
|
|
2308
|
+
...digits.filter(([, d]) => d.length !== 4 || d === "0000").map(([n]) => `${n}: not a part's name (part-0001, part-0002, ...)`),
|
|
2309
|
+
...gaps.length ? [`${stem}.part-${String(gaps[0]).padStart(4, "0")}.jsonl is missing`
|
|
2310
|
+
+ (gaps.length > 1 ? `, and ${gaps.length - 1} more part(s)` : "")] : [],
|
|
2311
|
+
...names.filter((_, i) => !pieces[i].length).map((n) => `${n} holds no records`),
|
|
2312
|
+
...held.slice(1).flatMap(([b, y], i) => same(get(at(y[0], "event"), "run_prev_hash"), at(held[i][1].at(-1), "hash"))
|
|
2313
|
+
? [] : [`${b} does not continue ${held[i][0]}`]),
|
|
2314
|
+
];
|
|
2315
|
+
}
|
|
2316
|
+
const runName = async (tenant, runId) => `runs/${(await sha256hex(bytes(`${str(tenant)}/${str(runId)}`))).slice(0, 32)}.jsonl`;
|
|
2317
|
+
const LEVELS = ["dev", "local", "witnessed"];
|
|
2318
|
+
function level(cosigs, witnesses, trust) {
|
|
2319
|
+
const independent = cosigs.filter(([k]) => witnesses.get(k) !== "operator").length;
|
|
2320
|
+
return independent >= Math.max(1, trust.witnesses_required) ? "witnessed" : cosigs.length ? "local" : "dev";
|
|
2321
|
+
}
|
|
2322
|
+
/** dev: no pinned witness cosigned, or a self-approval in the run; local: only operator-run ones; witnessed: enough
|
|
2323
|
+
* independent ones. A run-set's registry notes cap the level by the same rule. */
|
|
2324
|
+
async function assurance(origin, cosigs, witnesses, trust, algs, selfApproved, registryNotes) {
|
|
2325
|
+
let hybrid = false;
|
|
2326
|
+
for (const k of trust.logs) {
|
|
2327
|
+
const p = await parseVkey(k);
|
|
2328
|
+
hybrid ||= p.name === origin && p.type === HYBRID;
|
|
2329
|
+
}
|
|
2330
|
+
const times = cosigs.filter(([k]) => witnesses.get(k) !== "operator").map(([, ts]) => ts);
|
|
2331
|
+
let lvl = selfApproved ? "dev" : level(cosigs, witnesses, trust);
|
|
2332
|
+
let capped = registryNotes.map((c) => level(c, witnesses, trust))
|
|
2333
|
+
.reduce((a, b) => LEVELS.indexOf(b) < LEVELS.indexOf(a) ? b : a, "witnessed");
|
|
2334
|
+
if (LEVELS.indexOf(capped) < LEVELS.indexOf(lvl))
|
|
2335
|
+
lvl = capped;
|
|
2336
|
+
else
|
|
2337
|
+
capped = null;
|
|
2338
|
+
const cosigned = [...cosigs].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)
|
|
2339
|
+
.map(([k, ts]) => `${k.split("+")[0]} (${witnesses.get(k)}) at ${fmt(ts)}`).join(", ");
|
|
2340
|
+
return `${lvl}; records ${[...algs].sort(byCodePoint).join("+")}; checkpoint ${hybrid ? "Ed25519 + SLH-DSA-SHA2-128s" : "Ed25519 only"} `
|
|
2341
|
+
+ `(${origin})` + (cosigned ? `; cosigned ed25519 by ${cosigned}` : "; no witness cosignature")
|
|
2342
|
+
+ (times.length ? `; earliest independent anchor ${fmt(times.reduce((a, b) => b < a ? b : a))}` : "")
|
|
2343
|
+
+ (capped ? `; capped by the run-set's registry notes (${capped})` : "") + (selfApproved ? "; approvals: self" : "");
|
|
2344
|
+
}
|
|
2345
|
+
/** `x` without control characters and the bidirectional and zero-width format characters that would reorder or hide
|
|
2346
|
+
* the text around them, as the report prints bundle-derived strings. */
|
|
2347
|
+
const clean = (x) => x.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "");
|
|
2348
|
+
/** The report as text, as the reference CLI prints it; `clean` drops characters from bundle-derived strings. */
|
|
2349
|
+
function formatReport(rep) {
|
|
2350
|
+
let s = "";
|
|
2351
|
+
for (const c of rep.checks) {
|
|
2352
|
+
s += `[${c.status.toUpperCase()}] ${c.check}` + (c.detail ? ` — ${clean(c.detail)}` : "") + "\n";
|
|
2353
|
+
for (const p of c.problems)
|
|
2354
|
+
s += ` ${clean(p)}\n`;
|
|
2355
|
+
}
|
|
2356
|
+
return s + `\nIntegrity: ${clean(rep.integrity)}.\nAssurance: ${clean(rep.assurance)}.\n`
|
|
2357
|
+
+ (rep.findings ? `Findings: ${clean(rep.findings)}.\n` : "");
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
return { EXIT_BAD, EXIT_FAIL, EXIT_OK, EXIT_WARN, Report, VERSION, clean, formatReport, runFiles, verify, verifyRecord, MAX_ENTRIES: __zip.MAX_ENTRIES, MAX_ENTRY: __zip.MAX_ENTRY, MAX_TOTAL: __zip.MAX_TOTAL, Unusable: __zip.Unusable, readZip: __zip.readZip, StrictJSONError: __jcs.StrictJSONError, canonicalize: __jcs.canonicalize, strictParse: __jcs.strictParse, strictParseBytes: __jcs.strictParseBytes };
|
|
2361
|
+
})();
|
|
2362
|
+
const __explain = (() => {
|
|
2363
|
+
/**
|
|
2364
|
+
* What the browser verifier (web.ts) says about a report, in plain words: the verdict and what it does and doesn't
|
|
2365
|
+
* mean, what to do next, a line per check on what it means (docs/verdicts.md, shortened), and a summary of the bundle's
|
|
2366
|
+
* runs. The report itself is the CLI's, unchanged: this module only reads it. No DOM, so Node tests it.
|
|
2367
|
+
*/
|
|
2368
|
+
const { EXIT_FAIL: EXIT_FAIL, EXIT_OK: EXIT_OK, VERSION: VERSION, clean: clean, formatReport: formatReport, runFiles: runFiles } = __index;
|
|
2369
|
+
/** `s` as one shell argument (POSIX shells; the page doesn't know the reader's). */
|
|
2370
|
+
const sh = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
2371
|
+
const TRUST_FILE = "the sender's trust file: a small .json that names their signer's log key (tracekit last writes it next "
|
|
2372
|
+
+ "to the bundle, as <name>.trust.json)";
|
|
2373
|
+
/** The report as `tracekit verify --json` prints it (cli.ts prints this). */
|
|
2374
|
+
const asJson = (rep, code) => ({ exit_code: code, checks: rep.checks, failures: rep.failures,
|
|
2375
|
+
warnings: rep.warnings, integrity: rep.integrity, assurance: rep.assurance, notes: rep.notes,
|
|
2376
|
+
...rep.findings ? { findings: rep.findings } : {} });
|
|
2377
|
+
/** The report as `tracekit verify` prints it, then its exit code. */
|
|
2378
|
+
const asText = (rep, code) => formatReport(rep) + `\nExit code ${code}`
|
|
2379
|
+
+ (code === EXIT_OK && rep.warnings.length ? ` (3 with --strict: ${rep.warnings.length} warning(s))` : "") + ".\n";
|
|
2380
|
+
const failed = (rep, name) => rep.checks.find((c) => c.check === name && c.status === "fail");
|
|
2381
|
+
const anchored = (rep) => !rep.checks.some((c) => c.check === "trust config" && c.status === "warn");
|
|
2382
|
+
const v1 = (rep) => rep.checks.some((c) => c.check === "bundle readable"
|
|
2383
|
+
&& c.problems.some((p) => p.startsWith("a format v1 bundle")));
|
|
2384
|
+
/** What the command-line verifiers exit with for the same files, where that is known: they refuse a v2 bundle without a
|
|
2385
|
+
* trust file, and the Python one checks what this verifier doesn't (and v1 bundles, against signer.pub). */
|
|
2386
|
+
function cliExit(rep) {
|
|
2387
|
+
if (v1(rep))
|
|
2388
|
+
return "";
|
|
2389
|
+
if (!anchored(rep))
|
|
2390
|
+
return " The command-line verifiers need the trust file: without one they exit 2.";
|
|
2391
|
+
return rep.unsupported.length ? " The TypeScript command-line verifier exits with the same code; the Python one checks "
|
|
2392
|
+
+ "what this page doesn't." : " The command-line verifiers exit with the same code.";
|
|
2393
|
+
}
|
|
2394
|
+
function verdict(rep, code) {
|
|
2395
|
+
const behaved = "It doesn't mean the agent behaved well, or that the record holds everything it did: the summary, "
|
|
2396
|
+
+ "findings and warnings below tell you that.";
|
|
2397
|
+
if (code === EXIT_OK) {
|
|
2398
|
+
const head = rep.integrity.match(/^VERIFIED TO HEAD (\d+)/);
|
|
2399
|
+
return { word: "VERIFIED", means: "The record is intact and signed by the signer your trust file names: nothing was "
|
|
2400
|
+
+ "edited, removed, reordered or added after signing."
|
|
2401
|
+
+ (head ? ` The run was still open when it was exported: its records up to number ${head[1]} are verified.` : ""),
|
|
2402
|
+
notMeans: behaved };
|
|
2403
|
+
}
|
|
2404
|
+
if (code === EXIT_FAIL) {
|
|
2405
|
+
if (failed(rep, "checkpoint") && anchored(rep))
|
|
2406
|
+
return { word: "FAILED", means: "This bundle is not signed by the signer your trust file names, or its checkpoint "
|
|
2407
|
+
+ "was changed.", notMeans: "It doesn't say who made the bundle. If the trust file is the right one, don't rely "
|
|
2408
|
+
+ "on the bundle." };
|
|
2409
|
+
return { word: "FAILED", means: "This bundle doesn't match what was signed: something in it was changed, removed or "
|
|
2410
|
+
+ `added after signing. Failed: ${rep.failures.join(", ")}.`,
|
|
2411
|
+
notMeans: "It doesn't say who changed it or why: the failed lines below show what doesn't match." };
|
|
2412
|
+
}
|
|
2413
|
+
const readable = rep.checks.find((c) => c.check === "bundle readable");
|
|
2414
|
+
const nothing = "Nothing failed: it doesn't mean the bundle is bad.";
|
|
2415
|
+
if (failed(rep, "trust config"))
|
|
2416
|
+
return { word: "UNVERIFIABLE", means: "The trust file couldn't be used, so nothing was checked.", notMeans: nothing };
|
|
2417
|
+
if (v1(rep))
|
|
2418
|
+
return { word: "UNVERIFIABLE", means: "This is a format v1 bundle (from Tracekit's laptop signer); this page checks "
|
|
2419
|
+
+ "format v2 bundles, so nothing was checked.", notMeans: nothing };
|
|
2420
|
+
if (rep.integrity.startsWith("UNVERIFIABLE (needs"))
|
|
2421
|
+
return { word: "UNVERIFIABLE", means: `This bundle needs a newer verifier than this page (Tracekit ${VERSION}).`,
|
|
2422
|
+
notMeans: nothing };
|
|
2423
|
+
if (readable)
|
|
2424
|
+
return { word: "UNVERIFIABLE", means: "This file couldn't be read as a Tracekit bundle, so nothing in it was checked.",
|
|
2425
|
+
notMeans: "Nothing in it can be relied on until a readable copy verifies." };
|
|
2426
|
+
if (!anchored(rep))
|
|
2427
|
+
return { word: "UNVERIFIABLE", means: "Signatures are intact, but you haven't said whose signer you trust.",
|
|
2428
|
+
notMeans: "It doesn't mean the bundle is genuine: anyone can sign a bundle with keys of their own. The sender's "
|
|
2429
|
+
+ "trust file says whose signer made it." };
|
|
2430
|
+
return { word: "UNVERIFIABLE", means: "This page doesn't implement a check this bundle relies on: "
|
|
2431
|
+
+ `${rep.integrity.replace(/^UNVERIFIABLE \(not checked by this verifier: (.*)\)$/, "$1")}.`, notMeans: nothing };
|
|
2432
|
+
}
|
|
2433
|
+
/** What to do after a result that isn't plainly VERIFIED: what to check or ask for, with the commands that do it. */
|
|
2434
|
+
function nextSteps(rep, code, names) {
|
|
2435
|
+
const b = sh(names.bundle), t = sh(names.trust ?? "trust.json");
|
|
2436
|
+
const cli = { text: "Check the same files with the command-line verifier (it needs Node.js, nothing else):",
|
|
2437
|
+
command: `npx -y @cygnuxlabs/tracekit@latest verify ${b} --trust ${t}` };
|
|
2438
|
+
const readable = rep.checks.find((c) => c.check === "bundle readable");
|
|
2439
|
+
if (code === EXIT_OK)
|
|
2440
|
+
return rep.warnings.length ? [{ text: `Read the ${rep.warnings.length} warning(s) below: each says what to look at. `
|
|
2441
|
+
+ "They don't change the verdict (with --strict the command line exits 3)." }] : [];
|
|
2442
|
+
if (failed(rep, "trust config"))
|
|
2443
|
+
return [{ text: `Choose the trust file again: it must be a Tracekit v2 trust config (a .json with "logs"). Ask for ${TRUST_FILE}.` }];
|
|
2444
|
+
if (readable?.problems.length)
|
|
2445
|
+
return readable.problems.map((p) => {
|
|
2446
|
+
const at = p.search(/(npx|uvx) /);
|
|
2447
|
+
const text = at < 0 ? p.replace(/^or open (\S+) /, "open the latest page, $1, ")
|
|
2448
|
+
: at ? p.slice(0, at).trimEnd() : p.startsWith("npx") ? "with Node.js:" : "or with Python, using uv:";
|
|
2449
|
+
return { text: text[0].toUpperCase() + text.slice(1), ...at < 0 ? {} : { command: p.slice(at) } };
|
|
2450
|
+
});
|
|
2451
|
+
if (readable)
|
|
2452
|
+
return [{ text: `Check that you chose the .tkb file itself (not a renamed archive or a saved web page), then ask the `
|
|
2453
|
+
+ "sender to export it again: a damaged or cut-short copy reads like this." }];
|
|
2454
|
+
if (code === EXIT_FAIL) {
|
|
2455
|
+
const steps = [];
|
|
2456
|
+
if (failed(rep, "checkpoint") && anchored(rep))
|
|
2457
|
+
steps.push({ text: `Make sure the trust file is ${TRUST_FILE.replace("the sender's", "the sender's own")}. With `
|
|
2458
|
+
+ "the right one, this bundle isn't theirs: don't rely on it." });
|
|
2459
|
+
else
|
|
2460
|
+
steps.push({ text: "Don't rely on this bundle's contents. Ask the sender for the original and compare its SHA-256 "
|
|
2461
|
+
+ "with theirs:", command: `shasum -a 256 ${b}` });
|
|
2462
|
+
if (anchored(rep))
|
|
2463
|
+
steps.push(cli);
|
|
2464
|
+
else
|
|
2465
|
+
steps.push({ text: `Add ${TRUST_FILE}, to see whose signer made it.` });
|
|
2466
|
+
return steps;
|
|
2467
|
+
}
|
|
2468
|
+
if (!anchored(rep))
|
|
2469
|
+
return [{ text: `Ask for ${TRUST_FILE}, and add it above. Get it from the sender directly, not inside the bundle's `
|
|
2470
|
+
+ "own folder or message: the trust file is what makes the check mean something." }];
|
|
2471
|
+
return [{ text: "Verify it with the reference verifier, which checks this:",
|
|
2472
|
+
command: `uvx --from tracekit-ai tracekit verify ${b} --trust ${t}` }];
|
|
2473
|
+
}
|
|
2474
|
+
/** One line per check: what it means (docs/verdicts.md#lines). */
|
|
2475
|
+
const MEANING = {
|
|
2476
|
+
"trust config": "Your trust file: the log key and witnesses you trust, never ones the bundle brings.",
|
|
2477
|
+
"bundle readable": "The file is a Tracekit v2 bundle this verifier can read.",
|
|
2478
|
+
"bundle structure": "The bundle is well formed; a failure here means it is malformed in a way no other line covers.",
|
|
2479
|
+
"manifest": "The files in the bundle are exactly those its manifest lists, with those hashes.",
|
|
2480
|
+
"checkpoint": "The log's checkpoint (the signed root of every record) is signed by the log key your trust file pins.",
|
|
2481
|
+
"witness quorum": "Enough witnesses your trust file pins cosigned the checkpoint: they vouch it was not rolled back.",
|
|
2482
|
+
"keys": "The record keys were declared by records the checkpoint includes, in order, and retired keys stop signing.",
|
|
2483
|
+
"run parts": "A long run comes in numbered parts: none is missing between the first and the last, extra or out of "
|
|
2484
|
+
+ "order, so they read as one run. Parts cut off its end leave it open, never VERIFIED.",
|
|
2485
|
+
"signatures": "Every record's signature verifies under a key valid at its position: no record was edited after signing.",
|
|
2486
|
+
"run chain": "Each run's records follow one another without a gap from the first: none deleted, swapped or added.",
|
|
2487
|
+
"inclusion": "Each run's first and last records are in the checkpointed tree: the run wasn't rebuilt.",
|
|
2488
|
+
"policy snapshot": "The bundled policy file is named by its own hash: it wasn't edited.",
|
|
2489
|
+
"policy": "A tool call ran against a deny, or an ask nobody approved, and the signer recorded it.",
|
|
2490
|
+
"held calls": "A call held for a gateway or executor got no result before its run ended.",
|
|
2491
|
+
"gaps": "The signer recorded something a person should read: a gap, a tamper attempt or a mismatched approval.",
|
|
2492
|
+
"external decisions": "Decisions imported from another policy system, and whether they disagreed with the signer's.",
|
|
2493
|
+
"coverage": "Which capture layers reported, and calls the signer could not match across them.",
|
|
2494
|
+
"coverage matrix": "How each kind of action was covered: enforced, observed, only reported by the agent, or not at all.",
|
|
2495
|
+
"observers": "An observer outside the agent went silent or restarted while the run was open.",
|
|
2496
|
+
"reviews": "Signed findings about the run, each labelled by who wrote it: independent, unpinned or self-asserted.",
|
|
2497
|
+
"reviews counted": "Findings whose analysed run isn't in the bundle, so their citations weren't checked.",
|
|
2498
|
+
"incidents": "Detections the signer opened, recomputed from the run's records where the bundle allows.",
|
|
2499
|
+
"incident policies": "Policies an incident cites that aren't in the bundle: those incidents are the signer's word.",
|
|
2500
|
+
"content vault": "Who read or erased retained content, as the signer recorded it.",
|
|
2501
|
+
"tiers": "Tool calls by how they were captured: T2 gateway or executor, T1 adapter, T3 import (receipt only).",
|
|
2502
|
+
"args source": "Whether recorded arguments are the model's exact bytes (raw), parsed, or changed by a framework.",
|
|
2503
|
+
"isolation": "How the signer measured its separation from the agent: separate-user and remote are out of its reach.",
|
|
2504
|
+
"principals": "The person or service each run acted for, attested by the signer or asserted by the app.",
|
|
2505
|
+
"harness": "The agent harness the signer bound each run to.",
|
|
2506
|
+
"fail-open classes": "Tool classes allowed to run while the signer is down, so such calls may be unrecorded.",
|
|
2507
|
+
"key assurance": "Whether the record keys are only declared by the log (asserted) or certified by an issuer.",
|
|
2508
|
+
"approvals by people": "Held calls approved by people.",
|
|
2509
|
+
"approvals by agents": "Held calls approved by agents (model output), checked against the rules that allow it.",
|
|
2510
|
+
"break-glass approvals": "Approvals given under the break-glass role, with who and why.",
|
|
2511
|
+
"not interpreted": "Records carry an extension this verifier doesn't read. They verified like every other record.",
|
|
2512
|
+
"gateway amendments": "Requests the LLM gateway changed so the provider shows its reasoning; each record commits to "
|
|
2513
|
+
+ "the request as sent.",
|
|
2514
|
+
"openshell policy": "Changes of the sandbox's policy or configuration that NVIDIA OpenShell reported, signed into "
|
|
2515
|
+
+ "the run as OpenShell's report.",
|
|
2516
|
+
"constraints": "What the person's request said not to do, added while the run was open, and the calls it held.",
|
|
2517
|
+
"run-set": "Every run of the tenant in the registry range is in the bundle (COMPLETE), or what is missing.",
|
|
2518
|
+
"tenant-level gaps": "Signer gaps in the range that concern every tenant, such as witness failures.",
|
|
2519
|
+
"log tail": "Whether records after the checkpoint are proven absent (a log.closed) or simply unproven.",
|
|
2520
|
+
"rekor anchor": "A Rekor transparency-log anchor of the checkpoint, which this page can't check.",
|
|
2521
|
+
"SLH-DSA-SHA2-128s checkpoint signature": "A post-quantum checkpoint signature, which browsers can't check yet.",
|
|
2522
|
+
"record-key certificates": "Certificates of the record keys, which this page doesn't check.",
|
|
2523
|
+
"tracekit why findings": "tracekit why investigation findings, which this page doesn't check.",
|
|
2524
|
+
};
|
|
2525
|
+
const day = (ts) => ts.slice(0, 19).replace("T", " ") + " UTC";
|
|
2526
|
+
/** A few plain facts about the bundle's runs, from their records ({name: bytes}, the runs/ files, a run's parts read as
|
|
2527
|
+
* one), cleaned as the report is; names an agent chose are quoted, so they can't read as the signer's words around them. */
|
|
2528
|
+
function summarize(files, rep) {
|
|
2529
|
+
const text = new TextDecoder();
|
|
2530
|
+
const runs = [...runFiles(files.keys()).values()].map((parts) => parts.flatMap((n) => text.decode(files.get(n))
|
|
2531
|
+
.split("\n").filter(Boolean).map((l) => JSON.parse(l).event)));
|
|
2532
|
+
const events = runs.flat(), of = (t) => events.filter((e) => e.type === t);
|
|
2533
|
+
const times = events.map((e) => e.ts).filter((t) => typeof t === "string").sort();
|
|
2534
|
+
const out = [];
|
|
2535
|
+
const [first, end] = [times[0], times[times.length - 1]], secs = (Date.parse(end) - Date.parse(first)) / 1000;
|
|
2536
|
+
out.push(["Records", `${events.length} signed record(s) in ${runs.length} run(s)` + (times.length ? `, ${day(first)}`
|
|
2537
|
+
+ ` to ${day(end)} (${secs < 120 ? `${Math.round(secs)} s` : secs < 7200 ? `${Math.round(secs / 60)} min`
|
|
2538
|
+
: `${Math.round(secs / 3600)} h`})` : "")]);
|
|
2539
|
+
const cp = rep.checks.find((c) => c.check === "checkpoint")?.detail.split(" at tree size")[0];
|
|
2540
|
+
if (cp)
|
|
2541
|
+
out.push(["Signer", `log ${cp}` + (anchored(rep) ? ", the key your trust file pins" : ", not checked (no trust file)")]);
|
|
2542
|
+
const level = rep.assurance.split(";")[0];
|
|
2543
|
+
out.push(["Assurance", level === "witnessed" ? "witnessed: independent witnesses cosigned the checkpoint"
|
|
2544
|
+
: level === "local" ? "local: only witnesses the log's operator runs cosigned the checkpoint"
|
|
2545
|
+
: level === "dev" ? "dev: no independent witness vouches for the checkpoint (or a run holds a self-approval)"
|
|
2546
|
+
: level]);
|
|
2547
|
+
const agents = runs.map((rs) => rs[0]).filter((e) => e?.type === "run.registered").map((e) => {
|
|
2548
|
+
const id = e.data?.identity;
|
|
2549
|
+
return `${JSON.stringify(String(e.data?.agent?.name ?? "agent"))}` + (id ? ` (${id.scheme}:${id.subject})` : "");
|
|
2550
|
+
});
|
|
2551
|
+
const named = [...new Set(agents)];
|
|
2552
|
+
if (named.length)
|
|
2553
|
+
out.push(["Agents", named.slice(0, 5).join(", ") + (named.length > 5 ? ", ..." : "")]);
|
|
2554
|
+
const decisions = of("policy.decision").map((e) => e.data?.decision);
|
|
2555
|
+
out.push(["Tool calls", `${decisions.length} decided` + (decisions.length ? ": " + ["allow", "deny", "ask"]
|
|
2556
|
+
.map((d) => `${decisions.filter((x) => x === d).length} ${d}`).join(", ") : "")]);
|
|
2557
|
+
const answers = new Map(of("approval").map((e) => [e.data?.approval_id, e.data]));
|
|
2558
|
+
const held = of("approval.request").map((e) => {
|
|
2559
|
+
const a = answers.get(e.data?.approval_id);
|
|
2560
|
+
const who = a ? `${a.decision === "approve" ? "approved" : a.decision === "reject" ? "rejected" : a.decision} by `
|
|
2561
|
+
+ `${a.person ?? a.approver}` + (a.approver_kind === "agent" ? " (an agent)" : "")
|
|
2562
|
+
+ (a.self_approved ? " (self-approved)" : "") + (a.break_glass ? " (break-glass)" : "") : "no answer recorded";
|
|
2563
|
+
const tool = e.data?.binding?.tool ?? e.tool_call_id;
|
|
2564
|
+
return `${tool == null ? "a call" : JSON.stringify(String(tool))}: ${who}`;
|
|
2565
|
+
});
|
|
2566
|
+
out.push(["Held for approval", held.length ? held.slice(0, 10).join("; ") + (held.length > 10 ? "; ..." : "") : "none"]);
|
|
2567
|
+
const gaps = new Map();
|
|
2568
|
+
for (const e of of("capture.gap"))
|
|
2569
|
+
gaps.set(e.data?.kind, (gaps.get(e.data?.kind) ?? 0) + 1);
|
|
2570
|
+
out.push(["Gaps", gaps.size ? [...gaps].map(([k, n]) => `${k} ${n}`).join(", ") : "none recorded"]);
|
|
2571
|
+
const findings = ["reviews", "incidents"].map((n) => rep.checks.find((c) => c.check === n)?.detail).filter(Boolean);
|
|
2572
|
+
out.push(["Findings", findings.length ? findings.join("; ") : "none recorded"]);
|
|
2573
|
+
return out.map(([k, v]) => [k, clean(v)]);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
return { MEANING, asJson, asText, cliExit, nextSteps, sh, summarize, verdict };
|
|
2577
|
+
})();
|
|
2578
|
+
const __web = (() => {
|
|
2579
|
+
/**
|
|
2580
|
+
* The browser verifier: verify.html, which scripts/verify-page.mjs builds from this module, the verifier it imports and
|
|
2581
|
+
* page.html. The bundle and trust config a person chooses are read and verified in the page, whose Content Security
|
|
2582
|
+
* Policy lets it load and send nothing, so neither file leaves the browser. It reports what the TypeScript CLI does: a
|
|
2583
|
+
* bundle that relies on a check this verifier doesn't implement is UNVERIFIABLE, never VERIFIED. Without a trust config
|
|
2584
|
+
* it checks what needs no pinned key (verify's `trust` null). explain.ts says what the report means.
|
|
2585
|
+
*
|
|
2586
|
+
* The verification runs in a worker started from this same script (a blob: URL, which inherits the page's policy), so a
|
|
2587
|
+
* large bundle doesn't freeze the page; in the page itself where the browser refuses the worker.
|
|
2588
|
+
*/
|
|
2589
|
+
const { MEANING: MEANING, asJson: asJson, asText: asText, cliExit: cliExit, nextSteps: nextSteps, sh: sh, summarize: summarize, verdict: verdict } = __explain;
|
|
2590
|
+
const { VERSION: VERSION, clean: clean, readZip: readZip, verify: verify } = __index;
|
|
2591
|
+
/** The report, and a summary of the runs when nothing failed (what a failed bundle says can't be relied on). */
|
|
2592
|
+
async function work(job) {
|
|
2593
|
+
const { report, code } = await verify(job.bundle, job.trust, job.names);
|
|
2594
|
+
const summary = !report.failures.length && report.checks.some((c) => c.check === "signatures")
|
|
2595
|
+
? summarize(await readZip(job.bundle), report) : [];
|
|
2596
|
+
return { report, code, summary };
|
|
2597
|
+
}
|
|
2598
|
+
if (typeof document === "undefined") // this script, as the page's worker: a report, or the error that stopped it
|
|
2599
|
+
addEventListener("message", async (e) => {
|
|
2600
|
+
try {
|
|
2601
|
+
postMessage(await work(e.data));
|
|
2602
|
+
}
|
|
2603
|
+
catch (err) {
|
|
2604
|
+
postMessage({ error: String(err?.message ?? err) });
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
else
|
|
2608
|
+
page();
|
|
2609
|
+
function page() {
|
|
2610
|
+
const $ = (id) => document.getElementById(id);
|
|
2611
|
+
const el = (tag, text = "", cls = "") => {
|
|
2612
|
+
const e = document.createElement(tag);
|
|
2613
|
+
if (text)
|
|
2614
|
+
e.textContent = text;
|
|
2615
|
+
if (cls)
|
|
2616
|
+
e.className = cls;
|
|
2617
|
+
return e;
|
|
2618
|
+
};
|
|
2619
|
+
let bundle = null, trust = null, turn = 0, worker = null, workers = true, url = "";
|
|
2620
|
+
let last = { text: "", json: "", name: "" };
|
|
2621
|
+
$("version").textContent = VERSION;
|
|
2622
|
+
/** `job` verified in a fresh worker (a newer job ends the one before it), else in the page. */
|
|
2623
|
+
function run(job) {
|
|
2624
|
+
worker?.terminate();
|
|
2625
|
+
if (workers)
|
|
2626
|
+
try {
|
|
2627
|
+
// this page's own script, by id: a browser extension may add a script element of its own first
|
|
2628
|
+
url ||= URL.createObjectURL(new Blob([$("verifier").textContent], { type: "text/javascript" }));
|
|
2629
|
+
const w = worker = new Worker(url); // classic: Chrome refuses module workers to file:// pages
|
|
2630
|
+
return new Promise((resolve, reject) => {
|
|
2631
|
+
w.onmessage = (e) => {
|
|
2632
|
+
w.terminate();
|
|
2633
|
+
if (e.data.error)
|
|
2634
|
+
reject(new Error(e.data.error));
|
|
2635
|
+
else
|
|
2636
|
+
resolve(e.data);
|
|
2637
|
+
};
|
|
2638
|
+
w.onerror = (e) => {
|
|
2639
|
+
e.preventDefault();
|
|
2640
|
+
workers = false;
|
|
2641
|
+
resolve(work(job));
|
|
2642
|
+
};
|
|
2643
|
+
w.postMessage(job);
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
catch {
|
|
2647
|
+
workers = false;
|
|
2648
|
+
}
|
|
2649
|
+
// lean: in the page, a bundle of 100k records holds the main thread for seconds; chunk the verifier if browsers
|
|
2650
|
+
// without blob: workers matter
|
|
2651
|
+
return work(job);
|
|
2652
|
+
}
|
|
2653
|
+
const read = async (f) => ({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
|
|
2654
|
+
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
2655
|
+
const sampleTrust = { name: "sample.trust.json", bytes: unb64(SAMPLES["sample.trust.json"]) };
|
|
2656
|
+
async function choose(files) {
|
|
2657
|
+
if (trust === sampleTrust)
|
|
2658
|
+
trust = null; // the samples' signer is not the one behind the person's own bundle
|
|
2659
|
+
for (const f of [...files ?? []]) {
|
|
2660
|
+
if (/\.json$/i.test(f.name))
|
|
2661
|
+
trust = await read(f);
|
|
2662
|
+
else
|
|
2663
|
+
bundle = await read(f);
|
|
2664
|
+
}
|
|
2665
|
+
await show();
|
|
2666
|
+
}
|
|
2667
|
+
function command(text) {
|
|
2668
|
+
const row = el("div", "", "cmd"), copy = el("button", "copy");
|
|
2669
|
+
copy.setAttribute("type", "button");
|
|
2670
|
+
copy.setAttribute("aria-label", `Copy: ${text}`);
|
|
2671
|
+
copy.addEventListener("click", () => navigator.clipboard?.writeText(text));
|
|
2672
|
+
row.append(el("code", text), copy);
|
|
2673
|
+
return row;
|
|
2674
|
+
}
|
|
2675
|
+
async function show() {
|
|
2676
|
+
$("bundle-name").textContent = bundle?.name ?? "none chosen";
|
|
2677
|
+
$("trust-name").textContent = trust?.name ?? "none";
|
|
2678
|
+
$("clear-trust").hidden = !trust;
|
|
2679
|
+
if (!bundle)
|
|
2680
|
+
return;
|
|
2681
|
+
const mine = ++turn, names = { bundle: bundle.name, trust: trust?.name ?? null };
|
|
2682
|
+
$("result").hidden = true; // the last bundle's verdict never stands under this one's name
|
|
2683
|
+
document.body.dataset.state = "verifying";
|
|
2684
|
+
$("busy").textContent = "Verifying...";
|
|
2685
|
+
$("busy").hidden = false;
|
|
2686
|
+
let result;
|
|
2687
|
+
try {
|
|
2688
|
+
result = await run({ bundle: bundle.bytes, trust: trust?.bytes ?? null,
|
|
2689
|
+
names: { bundle: names.bundle, trust: names.trust ?? "TRUST.json" } });
|
|
2690
|
+
}
|
|
2691
|
+
catch (err) { // never left at "Verifying...": say what stopped it and where else to check
|
|
2692
|
+
if (mine !== turn)
|
|
2693
|
+
return;
|
|
2694
|
+
$("result").hidden = true;
|
|
2695
|
+
$("busy").textContent = `Couldn't check this file: ${clean(String(err?.message ?? err))}. The `
|
|
2696
|
+
+ `command-line verifier may say more: npx -y @cygnuxlabs/tracekit@latest verify ${sh(names.bundle)}`;
|
|
2697
|
+
document.body.dataset.state = "error";
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
if (mine !== turn)
|
|
2701
|
+
return; // a later choice of files: its report wins
|
|
2702
|
+
const { report, code, summary } = result;
|
|
2703
|
+
const v = verdict(report, code);
|
|
2704
|
+
$("banner").className = `banner ${{ VERIFIED: "ok", FAILED: "bad", UNVERIFIABLE: "warn" }[v.word]}`;
|
|
2705
|
+
$("verdict-word").textContent = v.word;
|
|
2706
|
+
$("verdict-means").textContent = v.means;
|
|
2707
|
+
$("verdict-not").textContent = v.notMeans;
|
|
2708
|
+
const steps = nextSteps(report, code, names);
|
|
2709
|
+
$("next").hidden = !steps.length;
|
|
2710
|
+
$("steps").replaceChildren(...steps.map((s) => {
|
|
2711
|
+
const li = el("li", s.text);
|
|
2712
|
+
if (s.command)
|
|
2713
|
+
li.append(command(s.command));
|
|
2714
|
+
return li;
|
|
2715
|
+
}));
|
|
2716
|
+
$("summary-box").hidden = !summary.length;
|
|
2717
|
+
$("summary-title").textContent = code === 0 ? "what the bundle holds"
|
|
2718
|
+
: "what the bundle says it holds (not verified: see above)";
|
|
2719
|
+
$("summary").replaceChildren(...summary.flatMap(([k, val]) => [el("dt", k), el("dd", val)]));
|
|
2720
|
+
$("checks").replaceChildren(...report.checks.map((c) => {
|
|
2721
|
+
const d = el("details"), s = el("summary"), line = el("span", "", "line");
|
|
2722
|
+
const tone = { pass: "ok", warn: "warn", fail: "bad" }[c.status];
|
|
2723
|
+
line.append(el("span", `[${c.status.toUpperCase()}]`, `status ${tone}`), ` ${c.check}` + (c.detail ? ` — ${clean(c.detail)}` : ""));
|
|
2724
|
+
s.append(line);
|
|
2725
|
+
d.append(s);
|
|
2726
|
+
for (const p of c.problems)
|
|
2727
|
+
d.append(el("div", clean(p), "problem")); // one by one: a spread overflows the stack
|
|
2728
|
+
d.append(el("p", `What this means: ${MEANING[c.check] ?? "see docs/verdicts.md."}`, "means"));
|
|
2729
|
+
d.open = c.status !== "pass" && c.problems.length > 0;
|
|
2730
|
+
return d;
|
|
2731
|
+
}));
|
|
2732
|
+
$("integrity").textContent = `Integrity: ${clean(report.integrity)}.`;
|
|
2733
|
+
$("assurance").textContent = `Assurance: ${clean(report.assurance)}.`;
|
|
2734
|
+
last = { text: asText(report, code), json: JSON.stringify(asJson(report, code), null, 2) + "\n", name: bundle.name };
|
|
2735
|
+
$("exit").textContent = last.text.trim().split("\n").at(-1) + cliExit(report);
|
|
2736
|
+
$("report").textContent = last.text;
|
|
2737
|
+
$("copied").textContent = "";
|
|
2738
|
+
$("result").hidden = false;
|
|
2739
|
+
$("busy").hidden = true;
|
|
2740
|
+
$("banner").scrollIntoView?.({ block: "nearest" });
|
|
2741
|
+
document.body.dataset.state = "done";
|
|
2742
|
+
}
|
|
2743
|
+
$("bundle").addEventListener("change", () => choose($("bundle").files));
|
|
2744
|
+
$("trust").addEventListener("change", async () => {
|
|
2745
|
+
const f = $("trust").files?.[0];
|
|
2746
|
+
if (f)
|
|
2747
|
+
trust = await read(f);
|
|
2748
|
+
await show();
|
|
2749
|
+
});
|
|
2750
|
+
$("clear-trust").addEventListener("click", () => {
|
|
2751
|
+
trust = null;
|
|
2752
|
+
$("trust").value = "";
|
|
2753
|
+
return show();
|
|
2754
|
+
});
|
|
2755
|
+
for (const [id, file] of [["sample-clean", "sample.tkb"], ["sample-tampered", "sample-tampered.tkb"]])
|
|
2756
|
+
$(id).addEventListener("click", () => {
|
|
2757
|
+
bundle = { name: file, bytes: unb64(SAMPLES[file]) };
|
|
2758
|
+
trust = sampleTrust;
|
|
2759
|
+
return show();
|
|
2760
|
+
});
|
|
2761
|
+
$("copy").addEventListener("click", async () => {
|
|
2762
|
+
try {
|
|
2763
|
+
await navigator.clipboard.writeText(last.text);
|
|
2764
|
+
$("copied").textContent = "Copied.";
|
|
2765
|
+
}
|
|
2766
|
+
catch {
|
|
2767
|
+
$("report").parentElement.open = true;
|
|
2768
|
+
getSelection()?.selectAllChildren($("report"));
|
|
2769
|
+
$("copied").textContent = "Selected: press Ctrl+C (Cmd+C on a Mac) to copy.";
|
|
2770
|
+
}
|
|
2771
|
+
});
|
|
2772
|
+
$("download").addEventListener("click", () => {
|
|
2773
|
+
const a = el("a");
|
|
2774
|
+
a.href = URL.createObjectURL(new Blob([last.json], { type: "application/json" }));
|
|
2775
|
+
a.download = `${last.name.replace(/\.[^.]*$/, "")}.verify.json`;
|
|
2776
|
+
a.click();
|
|
2777
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
2778
|
+
});
|
|
2779
|
+
const drop = $("drop");
|
|
2780
|
+
for (const t of ["dragenter", "dragover"])
|
|
2781
|
+
drop.addEventListener(t, (e) => {
|
|
2782
|
+
e.preventDefault();
|
|
2783
|
+
drop.classList.add("over");
|
|
2784
|
+
});
|
|
2785
|
+
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
|
|
2786
|
+
drop.addEventListener("drop", (e) => {
|
|
2787
|
+
e.preventDefault();
|
|
2788
|
+
drop.classList.remove("over");
|
|
2789
|
+
return choose(e.dataTransfer?.files);
|
|
2790
|
+
});
|
|
2791
|
+
for (const t of ["dragover", "drop"]) // a file dropped beside the zone isn't opened in place of the page
|
|
2792
|
+
document.addEventListener(t, (e) => e.preventDefault());
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
return { };
|
|
2796
|
+
})();
|
|
2797
|
+
</script>
|
|
2798
|
+
</body>
|
|
2799
|
+
</html>
|