@zanii/blackbox 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a2a/index.d.ts +50 -0
- package/dist/a2a/index.js +202 -0
- package/dist/analysis/faults.js +40 -0
- package/dist/analysis/taxonomy.d.ts +18 -0
- package/dist/analysis/taxonomy.js +65 -0
- package/dist/archive/index.d.ts +39 -0
- package/dist/archive/index.js +96 -0
- package/dist/bom/index.d.ts +14 -0
- package/dist/bom/index.js +132 -0
- package/dist/cli.js +38 -0
- package/dist/compliance/art12.d.ts +35 -0
- package/dist/compliance/art12.js +163 -0
- package/dist/data/index.d.ts +191 -0
- package/dist/data/index.js +762 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -3
- package/dist/ocsf/index.d.ts +1 -1
- package/dist/ocsf/index.js +36 -3
- package/dist/otlp/index.d.ts +8 -1
- package/dist/otlp/index.js +231 -1
- package/dist/packs/index.d.ts +8 -0
- package/dist/packs/index.js +128 -2
- package/dist/policy/index.d.ts +20 -3
- package/dist/policy/index.js +39 -4
- package/dist/session/index.d.ts +37 -0
- package/dist/session/index.js +69 -0
- package/dist/timestamp/index.d.ts +24 -0
- package/dist/timestamp/index.js +274 -0
- package/dist/transparency/index.d.ts +188 -0
- package/dist/transparency/index.js +712 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
// Data handling (spec/data.md): what data a session touched, where it went, whether the systems it
|
|
2
|
+
// touched confirm it. Pure functions, pinned with the Python SDK by spec/vectors/data.json.
|
|
3
|
+
// Mirrors sdks/python/src/zanii_blackbox/data.py.
|
|
4
|
+
import { createHmac, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|
5
|
+
import { canonicalBytes, didFromPublicKey, publicKeyFromDid } from "@zanii/core";
|
|
6
|
+
/** spec/data/identifiers-v1.json: always on. */
|
|
7
|
+
export const CORE_IDENTIFIERS = [
|
|
8
|
+
{
|
|
9
|
+
id: "email",
|
|
10
|
+
title: "E-mail address",
|
|
11
|
+
pattern: "(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\\.[A-Za-z0-9-]{1,63}){0,8}\\.[A-Za-z]{2,63}(?![A-Za-z0-9-])",
|
|
12
|
+
normalize: "lower",
|
|
13
|
+
personal: true,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "card",
|
|
17
|
+
title: "Payment card number",
|
|
18
|
+
pattern: "(?<![0-9])(?:4[0-9]{3}|5[1-5][0-9]{2}|2[2-7][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))(?:[ -]?[0-9]){9,15}(?![0-9])",
|
|
19
|
+
normalize: "digits",
|
|
20
|
+
check: "luhn",
|
|
21
|
+
personal: true,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "iban",
|
|
25
|
+
title: "IBAN",
|
|
26
|
+
pattern: "(?<![A-Za-z0-9])[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}(?![A-Za-z0-9])",
|
|
27
|
+
normalize: "upper",
|
|
28
|
+
personal: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "phone_e164",
|
|
32
|
+
title: "Phone number (international format)",
|
|
33
|
+
pattern: "(?<![0-9+])\\+[1-9][0-9]{7,14}(?![0-9])",
|
|
34
|
+
normalize: "digits",
|
|
35
|
+
personal: true,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
/** spec/data/core-v1.json: evidence defaults for common writes (§5). */
|
|
39
|
+
export const CORE_EVIDENCE = [
|
|
40
|
+
{ tool: "send_email", field: "message_id" },
|
|
41
|
+
{ tool: "issue_refund", field: "refund_id" },
|
|
42
|
+
{ tool: "create_ticket", field: "ticket_id" },
|
|
43
|
+
{ tool: "create_payment", field: "payment_id" },
|
|
44
|
+
{ tool: "transfer", field: "transaction_id" },
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* §6: the canary values as one identifier, matched literally. Longer values first, so a value that
|
|
48
|
+
* contains another wins. ASCII punctuation is escaped the same way both languages read it.
|
|
49
|
+
*/
|
|
50
|
+
export function canaryIdentifier(values) {
|
|
51
|
+
const alts = [...new Set(values.filter((v) => v !== ""))]
|
|
52
|
+
.sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0))
|
|
53
|
+
.map((v) => v.replace(/[!-/:-@[-`{-~]/g, (c) => `\\${c}`));
|
|
54
|
+
return {
|
|
55
|
+
id: "canary",
|
|
56
|
+
title: "Canary value",
|
|
57
|
+
pattern: alts.length > 0 ? `(?:${alts.join("|")})` : "(?!)",
|
|
58
|
+
normalize: "exact",
|
|
59
|
+
personal: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const MAX_FOUND = 200;
|
|
63
|
+
const compiled = new Map();
|
|
64
|
+
const regex = (pattern) => {
|
|
65
|
+
let r = compiled.get(pattern);
|
|
66
|
+
if (!r) {
|
|
67
|
+
r = new RegExp(pattern, "g");
|
|
68
|
+
compiled.set(pattern, r);
|
|
69
|
+
}
|
|
70
|
+
r.lastIndex = 0;
|
|
71
|
+
return r;
|
|
72
|
+
};
|
|
73
|
+
export function normalizeValue(value, how) {
|
|
74
|
+
if (how === "digits")
|
|
75
|
+
return value.replace(/[^0-9]/g, "");
|
|
76
|
+
if (how === "lower")
|
|
77
|
+
return value.replace(/\s/g, "").toLowerCase();
|
|
78
|
+
if (how === "upper")
|
|
79
|
+
return value.replace(/\s/g, "").toUpperCase();
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
const keepLast = (v, n) => (n !== undefined && v.length > n ? v.slice(-n) : v);
|
|
83
|
+
export function luhn(digits) {
|
|
84
|
+
if (!/^[0-9]{2,}$/.test(digits))
|
|
85
|
+
return false;
|
|
86
|
+
let sum = 0;
|
|
87
|
+
for (let i = 0; i < digits.length; i++) {
|
|
88
|
+
let d = digits.charCodeAt(digits.length - 1 - i) - 48;
|
|
89
|
+
if (i % 2 === 1) {
|
|
90
|
+
d *= 2;
|
|
91
|
+
if (d > 9)
|
|
92
|
+
d -= 9;
|
|
93
|
+
}
|
|
94
|
+
sum += d;
|
|
95
|
+
}
|
|
96
|
+
return sum % 10 === 0;
|
|
97
|
+
}
|
|
98
|
+
/** §1: each distinct (id, normalized value) in text order, at most 200. Earlier identifiers win a span. */
|
|
99
|
+
export function dataScan(text, identifiers) {
|
|
100
|
+
const taken = [];
|
|
101
|
+
const hits = [];
|
|
102
|
+
for (const ident of identifiers) {
|
|
103
|
+
for (const m of text.matchAll(regex(ident.pattern))) {
|
|
104
|
+
const start = m.index ?? 0;
|
|
105
|
+
const end = start + m[0].length;
|
|
106
|
+
if (m[0].length === 0 || taken.some(([a, b]) => start < b && end > a))
|
|
107
|
+
continue;
|
|
108
|
+
const value = keepLast(normalizeValue(m[0], ident.normalize), ident.keep_last);
|
|
109
|
+
if (value === "" || (ident.check === "luhn" && !luhn(value)))
|
|
110
|
+
continue;
|
|
111
|
+
taken.push([start, end]);
|
|
112
|
+
hits.push({ at: start, id: ident.id, value });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
hits.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : 1));
|
|
116
|
+
const out = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const h of hits) {
|
|
119
|
+
const k = `${h.id}\n${h.value}`;
|
|
120
|
+
if (seen.has(k))
|
|
121
|
+
continue;
|
|
122
|
+
seen.add(k);
|
|
123
|
+
out.push({ id: h.id, value: h.value });
|
|
124
|
+
if (out.length === MAX_FOUND)
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** Every match counted (not only distinct ones), for `data.classes`. */
|
|
130
|
+
export function dataClasses(text, identifiers) {
|
|
131
|
+
const taken = [];
|
|
132
|
+
const counts = {};
|
|
133
|
+
for (const ident of identifiers)
|
|
134
|
+
for (const m of text.matchAll(regex(ident.pattern))) {
|
|
135
|
+
const start = m.index ?? 0;
|
|
136
|
+
const end = start + m[0].length;
|
|
137
|
+
if (m[0].length === 0 || taken.some(([a, b]) => start < b && end > a))
|
|
138
|
+
continue;
|
|
139
|
+
const value = keepLast(normalizeValue(m[0], ident.normalize), ident.keep_last);
|
|
140
|
+
if (value === "" || (ident.check === "luhn" && !luhn(value)))
|
|
141
|
+
continue;
|
|
142
|
+
taken.push([start, end]);
|
|
143
|
+
counts[ident.id] = (counts[ident.id] ?? 0) + 1;
|
|
144
|
+
}
|
|
145
|
+
return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------- §2 fingerprints
|
|
148
|
+
const hmac = (key, data) => createHmac("sha256", key).update(data).digest();
|
|
149
|
+
/** §2: the tenant's fingerprint of one identifier value. */
|
|
150
|
+
export function dataFingerprint(key, tenant, id, value) {
|
|
151
|
+
const m = hmac(key, `${id}\n${value}`).toString("hex");
|
|
152
|
+
const tk = hmac(key, `blackbox-lineage-tenant\n${tenant}`);
|
|
153
|
+
return `fp:${hmac(tk, m).toString("hex").slice(0, 32)}`;
|
|
154
|
+
}
|
|
155
|
+
/** §2: the `data` meta of a body, or undefined when it holds no identifier. */
|
|
156
|
+
export function dataMeta(text, identifiers, key, tenant) {
|
|
157
|
+
const found = dataScan(text, identifiers);
|
|
158
|
+
if (found.length === 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
return {
|
|
161
|
+
classes: dataClasses(text, identifiers),
|
|
162
|
+
fps: found.map((f) => ({ id: f.id, fp: dataFingerprint(key, tenant, f.id, f.value) })),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const parse = (lines) => lines.filter((l) => l.trim() !== "").map((l) => JSON.parse(l));
|
|
166
|
+
const fpsOf = (e) => {
|
|
167
|
+
const d = e.meta?.data;
|
|
168
|
+
return Array.isArray(d?.fps)
|
|
169
|
+
? d.fps.filter((x) => x && typeof x.fp === "string" && typeof x.id === "string")
|
|
170
|
+
: [];
|
|
171
|
+
};
|
|
172
|
+
/** Where an event sends data, or undefined if it doesn't. */
|
|
173
|
+
function sendOf(e, tools) {
|
|
174
|
+
const m = e.meta ?? {};
|
|
175
|
+
if (e.kind === "llm.request")
|
|
176
|
+
return {
|
|
177
|
+
to: `model:${String(m.provider ?? "unknown")}`,
|
|
178
|
+
...(typeof m.region === "string" ? { region: m.region } : {}),
|
|
179
|
+
};
|
|
180
|
+
if (e.kind === "tool.call") {
|
|
181
|
+
if (m.via === "broker")
|
|
182
|
+
return { to: `api:${String(m.tool ?? "unknown")}` };
|
|
183
|
+
const name = toolName(m);
|
|
184
|
+
if (name)
|
|
185
|
+
tools.set(e.seq, name);
|
|
186
|
+
return name ? { to: `tool:${name}` } : undefined;
|
|
187
|
+
}
|
|
188
|
+
if (e.kind === "sdk.event" && m.type === "tool.call" && typeof m.name === "string")
|
|
189
|
+
return { to: `tool:${m.name}` };
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const toolName = (m) => typeof m.tool === "string"
|
|
193
|
+
? typeof m.server === "string"
|
|
194
|
+
? `${m.server}__${m.tool}`
|
|
195
|
+
: m.tool
|
|
196
|
+
: undefined;
|
|
197
|
+
/** Where an event brings data in from, or undefined. */
|
|
198
|
+
function readOf(e, tools) {
|
|
199
|
+
const m = e.meta ?? {};
|
|
200
|
+
if (e.kind === "tool.result") {
|
|
201
|
+
if (m.via === "broker")
|
|
202
|
+
return `api:${String(m.tool ?? "unknown")}`;
|
|
203
|
+
const name = typeof m.call_seq === "number" ? tools.get(m.call_seq) : undefined;
|
|
204
|
+
return name ? `tool:${name}` : typeof m.server === "string" ? `tool:${m.server}` : "tool";
|
|
205
|
+
}
|
|
206
|
+
if (e.kind === "sdk.event" && m.type === "tool.result" && typeof m.name === "string")
|
|
207
|
+
return `tool:${m.name}`;
|
|
208
|
+
if (e.kind === "llm.response" || e.kind === "llm.chunk")
|
|
209
|
+
return `model:${String(m.provider ?? "unknown")}`;
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
/** §3: where each subject's data came from and went in one session. */
|
|
213
|
+
export function lineage(lines) {
|
|
214
|
+
const tools = new Map();
|
|
215
|
+
const subjects = new Map();
|
|
216
|
+
for (const e of parse(lines)) {
|
|
217
|
+
const send = sendOf(e, tools);
|
|
218
|
+
const read = send ? undefined : readOf(e, tools);
|
|
219
|
+
for (const { id, fp } of fpsOf(e)) {
|
|
220
|
+
let s = subjects.get(fp);
|
|
221
|
+
if (!s) {
|
|
222
|
+
s = { fp, id, first: { seq: e.seq, kind: e.kind, from: read ?? "input" }, sent: [] };
|
|
223
|
+
subjects.set(fp, s);
|
|
224
|
+
}
|
|
225
|
+
if (send && !s.sent.some((x) => x.to === send.to))
|
|
226
|
+
s.sent.push({ seq: e.seq, ...send });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const list = [...subjects.values()].sort((a, b) => a.first.seq - b.first.seq);
|
|
230
|
+
const dest = new Map();
|
|
231
|
+
for (const s of list)
|
|
232
|
+
for (const x of s.sent) {
|
|
233
|
+
let d = dest.get(x.to);
|
|
234
|
+
if (!d) {
|
|
235
|
+
d = { to: x.to, ...(x.region ? { region: x.region } : {}), classes: {}, subjects: 0 };
|
|
236
|
+
dest.set(x.to, d);
|
|
237
|
+
}
|
|
238
|
+
d.classes[s.id] = (d.classes[s.id] ?? 0) + 1;
|
|
239
|
+
d.subjects++;
|
|
240
|
+
}
|
|
241
|
+
const destinations = [...dest.values()].sort((a, b) => (a.to < b.to ? -1 : 1));
|
|
242
|
+
for (const d of destinations)
|
|
243
|
+
d.classes = Object.fromEntries(Object.entries(d.classes).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
244
|
+
return { subjects: list, destinations };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* §3a: many sessions' lineage as one map: where data is read from (nodes on the left) and sent to,
|
|
248
|
+
* with how many subjects and sessions each flow carried. With `dataRegion`, a flow to a model in
|
|
249
|
+
* another region is flagged `outside_region`, one to a model of no known region `region_unknown`.
|
|
250
|
+
*/
|
|
251
|
+
export function dataTopology(sessions, dataRegion = null) {
|
|
252
|
+
const edges = new Map();
|
|
253
|
+
const nodes = new Map();
|
|
254
|
+
const node = (id, sid, region) => {
|
|
255
|
+
const n = nodes.get(id) ?? { ses: new Set() };
|
|
256
|
+
if (region !== undefined)
|
|
257
|
+
n.region = region;
|
|
258
|
+
n.ses.add(sid);
|
|
259
|
+
nodes.set(id, n);
|
|
260
|
+
};
|
|
261
|
+
for (const { session_id: sid, lines } of sessions)
|
|
262
|
+
for (const s of lineage(lines).subjects) {
|
|
263
|
+
node(s.first.from, sid);
|
|
264
|
+
for (const x of s.sent) {
|
|
265
|
+
node(x.to, sid, x.region);
|
|
266
|
+
const key = `${s.first.from}\n${x.to}`;
|
|
267
|
+
const e = edges.get(key) ?? {
|
|
268
|
+
from: s.first.from,
|
|
269
|
+
to: x.to,
|
|
270
|
+
classes: new Map(),
|
|
271
|
+
fps: new Set(),
|
|
272
|
+
ses: new Set(),
|
|
273
|
+
};
|
|
274
|
+
if (x.region !== undefined)
|
|
275
|
+
e.region = x.region;
|
|
276
|
+
const c = e.classes.get(s.id) ?? new Set();
|
|
277
|
+
c.add(`${sid}\n${s.fp}`);
|
|
278
|
+
e.classes.set(s.id, c);
|
|
279
|
+
e.fps.add(s.fp);
|
|
280
|
+
e.ses.add(sid);
|
|
281
|
+
edges.set(key, e);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const byId = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
285
|
+
return {
|
|
286
|
+
nodes: [...nodes.entries()]
|
|
287
|
+
.sort(([a], [b]) => byId(a, b))
|
|
288
|
+
.map(([id, n]) => ({
|
|
289
|
+
id,
|
|
290
|
+
kind: id.includes(":") ? id.split(":")[0] : id,
|
|
291
|
+
...(n.region !== undefined ? { region: n.region } : {}),
|
|
292
|
+
sessions: n.ses.size,
|
|
293
|
+
})),
|
|
294
|
+
edges: [...edges.values()]
|
|
295
|
+
.sort((a, b) => byId(a.from, b.from) || byId(a.to, b.to))
|
|
296
|
+
.map((e) => {
|
|
297
|
+
const flags = [];
|
|
298
|
+
if (dataRegion && e.to.startsWith("model:"))
|
|
299
|
+
if (e.region === undefined)
|
|
300
|
+
flags.push("region_unknown");
|
|
301
|
+
else if (e.region !== dataRegion)
|
|
302
|
+
flags.push("outside_region");
|
|
303
|
+
return {
|
|
304
|
+
from: e.from,
|
|
305
|
+
to: e.to,
|
|
306
|
+
...(e.region !== undefined ? { region: e.region } : {}),
|
|
307
|
+
classes: Object.fromEntries([...e.classes.entries()].sort(([a], [b]) => byId(a, b)).map(([k, v]) => [k, v.size])),
|
|
308
|
+
subjects: e.fps.size,
|
|
309
|
+
sessions: e.ses.size,
|
|
310
|
+
flags,
|
|
311
|
+
};
|
|
312
|
+
}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const allows = (pattern, send) => {
|
|
316
|
+
if (pattern.startsWith("region:"))
|
|
317
|
+
return send.region === pattern.slice(7);
|
|
318
|
+
return pattern.endsWith("*") ? send.to.startsWith(pattern.slice(0, -1)) : send.to === pattern;
|
|
319
|
+
};
|
|
320
|
+
/** §4, §6, §7, §9: findings over a session's events. */
|
|
321
|
+
export function dataFindings(lines, rules, dataRegion) {
|
|
322
|
+
const events = parse(lines);
|
|
323
|
+
const personal = new Set(rules.identifiers.filter((i) => i.personal).map((i) => i.id));
|
|
324
|
+
const out = [];
|
|
325
|
+
const once = new Set();
|
|
326
|
+
const add = (f, key) => {
|
|
327
|
+
if (once.has(key))
|
|
328
|
+
return;
|
|
329
|
+
once.add(key);
|
|
330
|
+
out.push(f);
|
|
331
|
+
};
|
|
332
|
+
const tools = new Map();
|
|
333
|
+
const withdrawn = new Set();
|
|
334
|
+
for (const e of events) {
|
|
335
|
+
const m = e.meta ?? {};
|
|
336
|
+
if (e.kind === "sdk.event" && m.type === "consent" && m.name === "withdrawn")
|
|
337
|
+
for (const { fp } of fpsOf(e))
|
|
338
|
+
withdrawn.add(fp);
|
|
339
|
+
if (e.kind === "sdk.event" && m.type === "egress_check" && m.name === "open")
|
|
340
|
+
add({ code: "EGRESS_OPEN", severity: "warning", ref: { seq: e.seq } }, `egress`);
|
|
341
|
+
const w = m.witness;
|
|
342
|
+
if (w && w.ok === false)
|
|
343
|
+
add({
|
|
344
|
+
code: "TOOL_WITNESS_INVALID",
|
|
345
|
+
severity: "warning",
|
|
346
|
+
ref: { seq: e.seq, did: String(w.did ?? "") },
|
|
347
|
+
}, `witness ${e.seq}`);
|
|
348
|
+
const send = sendOf(e, tools);
|
|
349
|
+
if (!send)
|
|
350
|
+
continue;
|
|
351
|
+
for (const { id, fp } of fpsOf(e)) {
|
|
352
|
+
const ref = { seq: e.seq, fp, id, to: send.to };
|
|
353
|
+
if (id === "canary")
|
|
354
|
+
add({ code: "DATA_CANARY_TRIPPED", severity: "warning", ref }, `canary ${fp} ${send.to}`);
|
|
355
|
+
if (withdrawn.has(fp))
|
|
356
|
+
add({ code: "CONSENT_WITHDRAWN_USE", severity: "warning", ref }, `consent ${fp} ${send.to}`);
|
|
357
|
+
if (rules.residency && dataRegion && personal.has(id) && send.to.startsWith("model:")) {
|
|
358
|
+
if (send.region === undefined)
|
|
359
|
+
add({ code: "DATA_REGION_UNKNOWN", severity: "advisory", ref }, `unknown ${fp} ${send.to}`);
|
|
360
|
+
else if (send.region !== dataRegion)
|
|
361
|
+
add({
|
|
362
|
+
code: "DATA_LEFT_REGION",
|
|
363
|
+
severity: "warning",
|
|
364
|
+
ref: { ...ref, region: send.region },
|
|
365
|
+
}, `left ${fp} ${send.to}`);
|
|
366
|
+
}
|
|
367
|
+
for (const r of rules.restricted)
|
|
368
|
+
if (r.identifiers.includes(id) && !r.allow.some((p) => allows(p, send)))
|
|
369
|
+
add({ code: "DATA_TO_UNAPPROVED_DESTINATION", severity: "warning", ref }, `restricted ${fp} ${send.to}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------- §5 evidence
|
|
375
|
+
/** Every object inside a value, including JSON written inside strings (as MCP text content). */
|
|
376
|
+
function* objects(value, depth = 0) {
|
|
377
|
+
if (depth > 20)
|
|
378
|
+
return;
|
|
379
|
+
if (typeof value === "string") {
|
|
380
|
+
const t = value.trim();
|
|
381
|
+
if ((t.startsWith("{") || t.startsWith("[")) && t.length <= 1_000_000)
|
|
382
|
+
try {
|
|
383
|
+
yield* objects(JSON.parse(t), depth + 1);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* not JSON */
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (Array.isArray(value)) {
|
|
391
|
+
for (const v of value)
|
|
392
|
+
yield* objects(v, depth + 1);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (value && typeof value === "object") {
|
|
396
|
+
yield value;
|
|
397
|
+
for (const v of Object.values(value))
|
|
398
|
+
yield* objects(v, depth + 1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export const hasEvidence = (value, field) => {
|
|
402
|
+
for (const o of objects(value)) {
|
|
403
|
+
const v = o[field];
|
|
404
|
+
if ((typeof v === "string" && v.trim() !== "") || (typeof v === "number" && Number.isFinite(v)))
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
};
|
|
409
|
+
const ruleFor = (rules, names) => rules.find((r) => names.includes(r.tool));
|
|
410
|
+
/** §5: successful results of state-changing tools with no evidence of their own. */
|
|
411
|
+
export function unevidenced(lines, body, rules) {
|
|
412
|
+
const out = [];
|
|
413
|
+
const calls = new Map();
|
|
414
|
+
const json = (e) => {
|
|
415
|
+
const b = e.body_hash ? body(e.body_hash) : undefined;
|
|
416
|
+
if (!b)
|
|
417
|
+
return undefined;
|
|
418
|
+
try {
|
|
419
|
+
return JSON.parse(Buffer.from(b).toString("utf8"));
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
for (const e of parse(lines)) {
|
|
426
|
+
const m = e.meta ?? {};
|
|
427
|
+
if (e.kind === "tool.call" && typeof m.tool === "string")
|
|
428
|
+
calls.set(e.seq, [
|
|
429
|
+
m.tool,
|
|
430
|
+
...(typeof m.server === "string" ? [`${m.server}__${m.tool}`] : []),
|
|
431
|
+
]);
|
|
432
|
+
if (e.kind === "tool.result" && typeof m.call_seq === "number") {
|
|
433
|
+
const names = calls.get(m.call_seq);
|
|
434
|
+
const rule = names ? ruleFor(rules, names) : undefined;
|
|
435
|
+
if (!rule)
|
|
436
|
+
continue;
|
|
437
|
+
const r = json(e);
|
|
438
|
+
if (r === undefined || r.error !== undefined || r.result?.isError === true)
|
|
439
|
+
continue;
|
|
440
|
+
if (!hasEvidence(r.result ?? r, rule.field))
|
|
441
|
+
out.push({
|
|
442
|
+
code: "UNEVIDENCED_SIDE_EFFECT",
|
|
443
|
+
severity: "caution",
|
|
444
|
+
ref: { seq: e.seq, tool: rule.tool, field: rule.field },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (e.kind === "sdk.event" && m.type === "tool.result" && typeof m.name === "string") {
|
|
448
|
+
const rule = ruleFor(rules, [m.name]);
|
|
449
|
+
if (!rule)
|
|
450
|
+
continue;
|
|
451
|
+
const d = json(e);
|
|
452
|
+
if (d === undefined || d.ok === false)
|
|
453
|
+
continue;
|
|
454
|
+
if (!hasEvidence(d, rule.field))
|
|
455
|
+
out.push({
|
|
456
|
+
code: "UNEVIDENCED_SIDE_EFFECT",
|
|
457
|
+
severity: "caution",
|
|
458
|
+
ref: { seq: e.seq, tool: rule.tool, field: rule.field },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
// ---------------------------------------------------------------- §9 tool witness
|
|
465
|
+
const PKCS8 = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
466
|
+
const SPKI = Buffer.from("302a300506032b6570032100", "hex");
|
|
467
|
+
/** §9: a tool signs what it returns, so the result is the tool's word, not the agent's. */
|
|
468
|
+
export function toolWitness(keypair) {
|
|
469
|
+
const key = createPrivateKey({
|
|
470
|
+
key: Buffer.concat([PKCS8, Buffer.from(keypair.privateKey).subarray(0, 32)]),
|
|
471
|
+
format: "der",
|
|
472
|
+
type: "pkcs8",
|
|
473
|
+
});
|
|
474
|
+
const raw = createPublicKey(key).export({ format: "der", type: "spki" }).subarray(-32);
|
|
475
|
+
const did = keypair.did ?? didFromPublicKey(new Uint8Array(raw));
|
|
476
|
+
return {
|
|
477
|
+
did,
|
|
478
|
+
sign(result) {
|
|
479
|
+
const sig = sign(null, Buffer.from(canonicalBytes(result)), key);
|
|
480
|
+
return { result, witness: { did, sig: sig.toString("base64url") } };
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/** §9: does `witness` sign `result`? */
|
|
485
|
+
export function verifyToolWitness(result, witness) {
|
|
486
|
+
try {
|
|
487
|
+
if (typeof witness.did !== "string" || typeof witness.sig !== "string")
|
|
488
|
+
return false;
|
|
489
|
+
const raw = publicKeyFromDid(witness.did);
|
|
490
|
+
if (!raw)
|
|
491
|
+
return false;
|
|
492
|
+
const pub = createPublicKey({
|
|
493
|
+
key: Buffer.concat([SPKI, Buffer.from(raw)]),
|
|
494
|
+
format: "der",
|
|
495
|
+
type: "spki",
|
|
496
|
+
});
|
|
497
|
+
return verify(null, Buffer.from(canonicalBytes(result)), pub, Buffer.from(witness.sig, "base64url"));
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** §9: the witness an MCP result or an SDK tool.result body carries, checked; undefined if none. */
|
|
504
|
+
export function witnessOf(bodyJson) {
|
|
505
|
+
if (!bodyJson || typeof bodyJson !== "object")
|
|
506
|
+
return undefined;
|
|
507
|
+
const b = bodyJson;
|
|
508
|
+
// An SDK tool.result event: {result, witness}
|
|
509
|
+
if (b.witness && typeof b.witness === "object" && "result" in b) {
|
|
510
|
+
const w = b.witness;
|
|
511
|
+
return { did: String(w.did ?? ""), ok: verifyToolWitness(b.result, w) };
|
|
512
|
+
}
|
|
513
|
+
// An MCP JSON-RPC answer: result._meta["blackbox/witness"], signed without that key
|
|
514
|
+
const r = b.result;
|
|
515
|
+
const meta = r?._meta;
|
|
516
|
+
const w = meta?.["blackbox/witness"];
|
|
517
|
+
if (!r || !w)
|
|
518
|
+
return undefined;
|
|
519
|
+
const { "blackbox/witness": _, ...rest } = meta;
|
|
520
|
+
const unsigned = { ...r, _meta: rest };
|
|
521
|
+
if (Object.keys(rest).length === 0)
|
|
522
|
+
delete unsigned._meta;
|
|
523
|
+
return { did: String(w.did ?? ""), ok: verifyToolWitness(unsigned, w) };
|
|
524
|
+
}
|
|
525
|
+
const COLUMN = (v) => typeof v === "string" && v.length >= 1 && v.length <= 200;
|
|
526
|
+
/** §10: why a connector is invalid, or undefined. */
|
|
527
|
+
export function checkAuditConnector(value) {
|
|
528
|
+
const c = value;
|
|
529
|
+
if (typeof c !== "object" || c === null || Array.isArray(c))
|
|
530
|
+
return "a connector must be an object";
|
|
531
|
+
if (c.v !== 1)
|
|
532
|
+
return "v must be 1";
|
|
533
|
+
if (typeof c.id !== "string" || !/^[a-z0-9_-]{1,64}$/.test(c.id))
|
|
534
|
+
return "id must be 1-64 of a-z 0-9 _ -";
|
|
535
|
+
if (c.format !== "csv" && c.format !== "jsonl")
|
|
536
|
+
return "format must be csv or jsonl";
|
|
537
|
+
const f = c.fields;
|
|
538
|
+
if (typeof f !== "object" || f === null || !COLUMN(f.at) || !COLUMN(f.action))
|
|
539
|
+
return "fields needs at and action";
|
|
540
|
+
for (const k of Object.keys(f))
|
|
541
|
+
if (!["at", "action", "object", "evidence", "amount", "actor"].includes(k) || !COLUMN(f[k]))
|
|
542
|
+
return `fields.${k} must be a column name (at, action, object, evidence, amount, actor)`;
|
|
543
|
+
const a = c.actions;
|
|
544
|
+
if (typeof a !== "object" || a === null || Array.isArray(a) || Object.keys(a).length === 0)
|
|
545
|
+
return "actions must map at least one tool to an action";
|
|
546
|
+
if (!Object.values(a).every(COLUMN))
|
|
547
|
+
return "each action must be 1-200 characters";
|
|
548
|
+
const g = c.arguments;
|
|
549
|
+
if (g !== undefined &&
|
|
550
|
+
(typeof g !== "object" ||
|
|
551
|
+
g === null ||
|
|
552
|
+
!Object.entries(g).every(([k, v]) => (k === "object" || k === "amount") && COLUMN(v))))
|
|
553
|
+
return "arguments may name object and amount";
|
|
554
|
+
const w = c.window_seconds;
|
|
555
|
+
if (w !== undefined && (!Number.isInteger(w) || w < 1 || w > 86_400))
|
|
556
|
+
return "window_seconds must be 1-86400";
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
/** RFC 4180: a header row, then rows; quoted fields may hold commas, quotes ("") and newlines. */
|
|
560
|
+
export function parseCsv(text) {
|
|
561
|
+
const rows = [];
|
|
562
|
+
let row = [];
|
|
563
|
+
let field = "";
|
|
564
|
+
let quoted = false;
|
|
565
|
+
for (let i = 0; i < text.length; i++) {
|
|
566
|
+
const c = text[i];
|
|
567
|
+
if (quoted) {
|
|
568
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
569
|
+
field += '"';
|
|
570
|
+
i++;
|
|
571
|
+
}
|
|
572
|
+
else if (c === '"')
|
|
573
|
+
quoted = false;
|
|
574
|
+
else
|
|
575
|
+
field += c;
|
|
576
|
+
}
|
|
577
|
+
else if (c === '"' && field === "")
|
|
578
|
+
quoted = true;
|
|
579
|
+
else if (c === ",") {
|
|
580
|
+
row.push(field);
|
|
581
|
+
field = "";
|
|
582
|
+
}
|
|
583
|
+
else if (c === "\n" || c === "\r") {
|
|
584
|
+
if (c === "\r" && text[i + 1] === "\n")
|
|
585
|
+
i++;
|
|
586
|
+
row.push(field);
|
|
587
|
+
field = "";
|
|
588
|
+
if (row.some((f) => f !== ""))
|
|
589
|
+
rows.push(row);
|
|
590
|
+
row = [];
|
|
591
|
+
}
|
|
592
|
+
else
|
|
593
|
+
field += c;
|
|
594
|
+
}
|
|
595
|
+
row.push(field);
|
|
596
|
+
if (row.some((f) => f !== ""))
|
|
597
|
+
rows.push(row);
|
|
598
|
+
return rows;
|
|
599
|
+
}
|
|
600
|
+
const timeOf = (v) => {
|
|
601
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
602
|
+
return Math.round(v * 1000);
|
|
603
|
+
if (typeof v !== "string" || v.trim() === "")
|
|
604
|
+
return undefined;
|
|
605
|
+
const t0 = v.trim();
|
|
606
|
+
if (/^[0-9]{9,11}(\.[0-9]+)?$/.test(t0))
|
|
607
|
+
return Math.round(Number(t0) * 1000);
|
|
608
|
+
// ISO-8601 with a zone only: without one, JavaScript reads local time and Python doesn't.
|
|
609
|
+
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\.[0-9]{1,6})?)?(Z|[+-][0-9]{2}:[0-9]{2})$/.test(t0))
|
|
610
|
+
return undefined;
|
|
611
|
+
const t = Date.parse(t0);
|
|
612
|
+
return Number.isNaN(t) ? undefined : t;
|
|
613
|
+
};
|
|
614
|
+
/** §10: the rows of an audit-log export, in file order; rows without a time or action are skipped. */
|
|
615
|
+
export function parseAuditLog(text, connector) {
|
|
616
|
+
let records;
|
|
617
|
+
if (connector.format === "csv") {
|
|
618
|
+
const [head, ...rest] = parseCsv(text);
|
|
619
|
+
records = (rest ?? []).map((r) => Object.fromEntries((head ?? []).map((h, i) => [h.trim(), r[i] ?? ""])));
|
|
620
|
+
}
|
|
621
|
+
else
|
|
622
|
+
records = text
|
|
623
|
+
.split(/\r?\n/)
|
|
624
|
+
.filter((l) => l.trim() !== "")
|
|
625
|
+
.flatMap((l) => {
|
|
626
|
+
try {
|
|
627
|
+
const o = JSON.parse(l);
|
|
628
|
+
return o && typeof o === "object" && !Array.isArray(o)
|
|
629
|
+
? [o]
|
|
630
|
+
: [];
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return [];
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
const f = connector.fields;
|
|
637
|
+
const str = (v) => (v === undefined || v === null || v === "" ? undefined : String(v));
|
|
638
|
+
const out = [];
|
|
639
|
+
records.forEach((r, i) => {
|
|
640
|
+
const at = timeOf(r[f.at]);
|
|
641
|
+
const action = str(r[f.action]);
|
|
642
|
+
if (at === undefined || action === undefined)
|
|
643
|
+
return;
|
|
644
|
+
const row = { row: i + 1, at, action };
|
|
645
|
+
for (const k of ["object", "evidence", "amount", "actor"]) {
|
|
646
|
+
const col = f[k];
|
|
647
|
+
const v = col ? str(r[col]) : undefined;
|
|
648
|
+
if (v !== undefined)
|
|
649
|
+
row[k] = v;
|
|
650
|
+
}
|
|
651
|
+
out.push(row);
|
|
652
|
+
});
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
/** §10: the session's successful mapped tool calls against the system's own log. */
|
|
656
|
+
export function reconcileSystem(lines, body, rows, connector, evidence) {
|
|
657
|
+
const events = parse(lines);
|
|
658
|
+
const json = (e) => {
|
|
659
|
+
const b = e.body_hash ? body(e.body_hash) : undefined;
|
|
660
|
+
if (!b)
|
|
661
|
+
return undefined;
|
|
662
|
+
try {
|
|
663
|
+
return JSON.parse(Buffer.from(b).toString("utf8"));
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
const window = (connector.window_seconds ?? 300) * 1000;
|
|
670
|
+
const callTools = new Map();
|
|
671
|
+
const calls = [];
|
|
672
|
+
const first = events.find((e) => e.kind === "session.open");
|
|
673
|
+
const ts = (e) => Date.parse(String(e.ts ?? ""));
|
|
674
|
+
const fieldValue = (v, field) => {
|
|
675
|
+
if (!field)
|
|
676
|
+
return undefined;
|
|
677
|
+
for (const o of objects(v)) {
|
|
678
|
+
const x = o[field];
|
|
679
|
+
if ((typeof x === "string" && x !== "") || typeof x === "number")
|
|
680
|
+
return String(x);
|
|
681
|
+
}
|
|
682
|
+
return undefined;
|
|
683
|
+
};
|
|
684
|
+
for (const e of events) {
|
|
685
|
+
const m = e.meta ?? {};
|
|
686
|
+
const sdkCall = e.kind === "sdk.event" && m.type === "tool.call" && typeof m.name === "string";
|
|
687
|
+
if ((e.kind === "tool.call" && typeof m.tool === "string") || sdkCall) {
|
|
688
|
+
const name = String(sdkCall ? m.name : m.tool);
|
|
689
|
+
const names = [name, ...(typeof m.server === "string" ? [`${m.server}__${name}`] : [])];
|
|
690
|
+
const req = json(e);
|
|
691
|
+
callTools.set(e.seq, {
|
|
692
|
+
name,
|
|
693
|
+
names,
|
|
694
|
+
at: ts(e),
|
|
695
|
+
args: sdkCall ? req : req?.params?.arguments,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const sdkResult = e.kind === "sdk.event" && m.type === "tool.result" && typeof m.name === "string";
|
|
699
|
+
if ((e.kind === "tool.result" && typeof m.call_seq === "number") || sdkResult) {
|
|
700
|
+
const call = sdkResult
|
|
701
|
+
? [...callTools.entries()].reverse().find(([, c]) => c.name === m.name)
|
|
702
|
+
: [m.call_seq, callTools.get(m.call_seq)];
|
|
703
|
+
const c = call?.[1];
|
|
704
|
+
if (!c)
|
|
705
|
+
continue;
|
|
706
|
+
const tool = c.names.find((n) => connector.actions[n] !== undefined);
|
|
707
|
+
if (!tool)
|
|
708
|
+
continue;
|
|
709
|
+
const r = json(e);
|
|
710
|
+
if (r === undefined || r.error !== undefined || r.ok === false || r.result?.isError === true)
|
|
711
|
+
continue;
|
|
712
|
+
const rule = evidence.find((x) => c.names.includes(x.tool));
|
|
713
|
+
const object = fieldValue(c.args, connector.arguments?.object);
|
|
714
|
+
const amount = fieldValue(c.args, connector.arguments?.amount);
|
|
715
|
+
const proof = rule ? fieldValue(r, rule.field) : undefined;
|
|
716
|
+
calls.push({
|
|
717
|
+
seq: e.seq,
|
|
718
|
+
tool,
|
|
719
|
+
at: c.at,
|
|
720
|
+
...(object !== undefined ? { object } : {}),
|
|
721
|
+
...(proof !== undefined ? { evidence: proof } : {}),
|
|
722
|
+
...(amount !== undefined ? { amount } : {}),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const used = new Set();
|
|
727
|
+
const matched = [];
|
|
728
|
+
const findings = [];
|
|
729
|
+
for (const c of calls) {
|
|
730
|
+
const action = connector.actions[c.tool];
|
|
731
|
+
let hit = c.evidence
|
|
732
|
+
? rows.find((r) => !used.has(r.row) && r.evidence !== undefined && r.evidence === c.evidence)
|
|
733
|
+
: undefined;
|
|
734
|
+
let by = "evidence";
|
|
735
|
+
if (!hit) {
|
|
736
|
+
by = "action";
|
|
737
|
+
hit = rows.find((r) => !used.has(r.row) &&
|
|
738
|
+
r.action === action &&
|
|
739
|
+
(c.object === undefined || r.object === undefined || r.object === c.object) &&
|
|
740
|
+
Math.abs(r.at - c.at) <= window);
|
|
741
|
+
}
|
|
742
|
+
if (!hit) {
|
|
743
|
+
findings.push({ code: "AGENT_ACTION_NOT_IN_SYSTEM", ref: { seq: c.seq } });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
used.add(hit.row);
|
|
747
|
+
matched.push({ seq: c.seq, row: hit.row, by });
|
|
748
|
+
if (by === "evidence" &&
|
|
749
|
+
c.amount !== undefined &&
|
|
750
|
+
hit.amount !== undefined &&
|
|
751
|
+
c.amount !== hit.amount)
|
|
752
|
+
findings.push({ code: "SYSTEM_RECORD_MISMATCH", ref: { seq: c.seq, row: hit.row } });
|
|
753
|
+
}
|
|
754
|
+
const start = first ? ts(first) : Number.NEGATIVE_INFINITY;
|
|
755
|
+
const lastEv = events.at(-1);
|
|
756
|
+
const end = lastEv ? ts(lastEv) : Number.POSITIVE_INFINITY;
|
|
757
|
+
const mapped = new Set(Object.values(connector.actions));
|
|
758
|
+
for (const r of rows)
|
|
759
|
+
if (!used.has(r.row) && mapped.has(r.action) && r.at >= start - window && r.at <= end + window)
|
|
760
|
+
findings.push({ code: "SYSTEM_CHANGE_NOT_IN_RECORD", ref: { row: r.row } });
|
|
761
|
+
return { connector: connector.id, calls: calls.length, rows: rows.length, matched, findings };
|
|
762
|
+
}
|