@zanii/blackbox 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/a2a/index.d.ts +77 -0
- package/dist/a2a/index.js +305 -0
- package/dist/agents/index.d.ts +8 -0
- package/dist/analysis/accuracy.d.ts +24 -0
- package/dist/analysis/accuracy.js +45 -0
- package/dist/analysis/credential.d.ts +101 -0
- package/dist/analysis/credential.js +142 -0
- package/dist/analysis/faults.js +115 -0
- package/dist/analysis/grounding.d.ts +122 -0
- package/dist/analysis/grounding.js +445 -0
- package/dist/analysis/hallucination.d.ts +32 -0
- package/dist/analysis/hallucination.js +357 -0
- package/dist/analysis/index.d.ts +23 -0
- package/dist/analysis/index.js +93 -0
- package/dist/analysis/memory.d.ts +8 -0
- package/dist/analysis/memory.js +35 -8
- package/dist/analysis/reference.d.ts +49 -0
- package/dist/analysis/reference.js +164 -0
- package/dist/analysis/taxonomy.d.ts +18 -0
- package/dist/analysis/taxonomy.js +66 -0
- package/dist/approvals/index.d.ts +23 -0
- package/dist/approvals/index.js +48 -0
- package/dist/archive/index.d.ts +39 -0
- package/dist/archive/index.js +96 -0
- package/dist/archive/parquet.d.ts +2 -0
- package/dist/archive/parquet.js +185 -0
- package/dist/badge/index.d.ts +16 -0
- package/dist/badge/index.js +48 -0
- package/dist/bom/index.d.ts +14 -0
- package/dist/bom/index.js +152 -0
- package/dist/cli.js +114 -10
- package/dist/compliance/art12.d.ts +35 -0
- package/dist/compliance/art12.js +190 -0
- package/dist/compliance/index.d.ts +36 -2
- package/dist/compliance/index.js +78 -11
- package/dist/compliance/zanii.d.ts +29 -0
- package/dist/compliance/zanii.js +84 -0
- package/dist/constitution/index.d.ts +57 -0
- package/dist/constitution/index.js +131 -0
- package/dist/cv/index.d.ts +39 -0
- package/dist/cv/index.js +108 -0
- package/dist/disclosure/index.d.ts +31 -0
- package/dist/disclosure/index.js +113 -0
- package/dist/encryption/index.d.ts +9 -0
- package/dist/encryption/index.js +31 -0
- package/dist/evidence/index.d.ts +60 -0
- package/dist/evidence/index.js +151 -0
- package/dist/federation/index.d.ts +35 -0
- package/dist/federation/index.js +102 -0
- package/dist/finance/index.d.ts +126 -0
- package/dist/finance/index.js +320 -0
- package/dist/fleet/index.js +9 -0
- package/dist/gov/index.d.ts +108 -0
- package/dist/gov/index.js +225 -0
- package/dist/health/index.d.ts +120 -0
- package/dist/health/index.js +233 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.js +34 -5
- package/dist/memory/index.d.ts +36 -0
- package/dist/memory/index.js +85 -0
- package/dist/occurrence/index.d.ts +11 -0
- package/dist/occurrence/index.js +18 -0
- 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 +258 -1
- package/dist/packs/index.js +44 -4
- package/dist/policy/delta.js +7 -1
- package/dist/policy/index.d.ts +40 -6
- package/dist/policy/index.js +186 -8
- package/dist/policy/zanii.d.ts +31 -0
- package/dist/policy/zanii.js +87 -0
- package/dist/pq/index.d.ts +23 -0
- package/dist/pq/index.js +104 -0
- package/dist/search/index.d.ts +23 -0
- package/dist/search/index.js +69 -0
- package/dist/session/index.d.ts +107 -1
- package/dist/session/index.js +189 -11
- package/dist/sla/index.d.ts +61 -0
- package/dist/sla/index.js +197 -0
- package/dist/succession/index.d.ts +50 -0
- package/dist/succession/index.js +123 -0
- package/dist/timestamp/index.d.ts +24 -0
- package/dist/timestamp/index.js +274 -0
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.js +46 -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/dist/walls/index.d.ts +31 -0
- package/dist/walls/index.js +119 -0
- package/package.json +1 -1
package/dist/policy/index.js
CHANGED
|
@@ -1,7 +1,57 @@
|
|
|
1
1
|
// Tool policy (spec/policy.md): rules checked in order, the first match decides. Mirrors policy.py.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { hasEvidence } from "../data/index.js";
|
|
2
4
|
import { callsOf } from "../reconcile/record.js";
|
|
3
5
|
import { canonical } from "../reconcile/shared.js";
|
|
4
6
|
import { checkCompensations } from "../undo/index.js";
|
|
7
|
+
/** spec/api.md: a session's environment, e.g. `production`, `staging`, `dev`. */
|
|
8
|
+
export const ENVIRONMENT = /^[a-z][a-z0-9-]{0,31}$/;
|
|
9
|
+
const HINTS = ["read_only", "destructive", "idempotent", "open_world"];
|
|
10
|
+
/**
|
|
11
|
+
* spec/policy.md §1: a tool's annotations (MCP `readOnlyHint`, `destructiveHint`, `idempotentHint`,
|
|
12
|
+
* `openWorldHint`) as hints. Missing ones take MCP's defaults: not read-only, destructive, not
|
|
13
|
+
* idempotent, open-world; destructive and idempotent only mean something for a tool that writes.
|
|
14
|
+
* An unknown tool gets the defaults too. Hints are the server's word, not proof.
|
|
15
|
+
*/
|
|
16
|
+
export function mcpToolHints(annotations) {
|
|
17
|
+
const a = (typeof annotations === "object" && annotations !== null ? annotations : {});
|
|
18
|
+
const readOnly = a.readOnlyHint === true;
|
|
19
|
+
return {
|
|
20
|
+
read_only: readOnly,
|
|
21
|
+
destructive: !readOnly && a.destructiveHint !== false,
|
|
22
|
+
idempotent: !readOnly && a.idempotentHint === true,
|
|
23
|
+
open_world: a.openWorldHint !== false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** The fields of an MCP tool definition that say what the tool is and does (spec/policy.md §1a). */
|
|
27
|
+
const DEFINITION_FIELDS = [
|
|
28
|
+
"name",
|
|
29
|
+
"title",
|
|
30
|
+
"description",
|
|
31
|
+
"inputSchema",
|
|
32
|
+
"outputSchema",
|
|
33
|
+
"annotations",
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* spec/policy.md §1a: a tool definition's identity, `sha256:<hex>` of the canonical JSON of its
|
|
37
|
+
* name, title, description, schemas and annotations (the fields present). A changed description
|
|
38
|
+
* or schema changes it: what the agent was shown is no longer what was approved. `null` for
|
|
39
|
+
* something that isn't a named tool.
|
|
40
|
+
*/
|
|
41
|
+
export function toolDefinitionHash(tool) {
|
|
42
|
+
if (typeof tool !== "object" || tool === null || Array.isArray(tool))
|
|
43
|
+
return null;
|
|
44
|
+
const t = tool;
|
|
45
|
+
if (typeof t.name !== "string" || t.name.length === 0 || t.name.length > 256)
|
|
46
|
+
return null;
|
|
47
|
+
const picked = {};
|
|
48
|
+
for (const k of DEFINITION_FIELDS)
|
|
49
|
+
if (t[k] !== undefined)
|
|
50
|
+
picked[k] = t[k];
|
|
51
|
+
return `sha256:${createHash("sha256").update(canonical(picked)).digest("hex")}`;
|
|
52
|
+
}
|
|
53
|
+
const hintsMatch = (want, have) => want === undefined ||
|
|
54
|
+
(have !== undefined && Object.entries(want).every(([k, v]) => have[k] === v));
|
|
5
55
|
const MAX_ARGS = 64 * 1024;
|
|
6
56
|
/** Escapes a string for use inside a regular expression. */
|
|
7
57
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -17,8 +67,13 @@ export function loadPolicy(bytes) {
|
|
|
17
67
|
throw new Error(`policy: rule id "${r.id}" must match ^[a-z0-9-]{1,64}$`);
|
|
18
68
|
if (typeof r.tool !== "string" || r.tool === "")
|
|
19
69
|
throw new Error(`policy ${r.id}: tool is required`);
|
|
20
|
-
if (
|
|
21
|
-
throw new Error(`policy ${r.id}: action must be deny, allow or
|
|
70
|
+
if (!["deny", "allow", "require_approval", "require_proof"].includes(r.action))
|
|
71
|
+
throw new Error(`policy ${r.id}: action must be deny, allow, require_approval or require_proof`);
|
|
72
|
+
if (r.field !== undefined &&
|
|
73
|
+
(r.action !== "require_proof" ||
|
|
74
|
+
typeof r.field !== "string" ||
|
|
75
|
+
!/^[A-Za-z0-9_.-]{1,64}$/.test(r.field)))
|
|
76
|
+
throw new Error(`policy ${r.id}: field is a 1-64 character key, with require_proof only`);
|
|
22
77
|
if (r.args_match !== undefined) {
|
|
23
78
|
if (typeof r.args_match !== "string" || r.args_match.length > 200)
|
|
24
79
|
throw new Error(`policy ${r.id}: args_match must be a regular expression of ≤ 200 characters`);
|
|
@@ -26,6 +81,21 @@ export function loadPolicy(bytes) {
|
|
|
26
81
|
}
|
|
27
82
|
if (r.audit !== undefined && typeof r.audit !== "boolean")
|
|
28
83
|
throw new Error(`policy ${r.id}: audit must be true or false`);
|
|
84
|
+
if (r.hints !== undefined) {
|
|
85
|
+
const h = r.hints;
|
|
86
|
+
if (typeof h !== "object" ||
|
|
87
|
+
h === null ||
|
|
88
|
+
Array.isArray(h) ||
|
|
89
|
+
Object.keys(h).length === 0 ||
|
|
90
|
+
!Object.entries(h).every(([k, v]) => HINTS.includes(k) && typeof v === "boolean"))
|
|
91
|
+
throw new Error(`policy ${r.id}: hints must set some of read_only, destructive, idempotent, open_world to true or false`);
|
|
92
|
+
}
|
|
93
|
+
if (r.environment !== undefined &&
|
|
94
|
+
!(Array.isArray(r.environment) &&
|
|
95
|
+
r.environment.length > 0 &&
|
|
96
|
+
r.environment.length <= 16 &&
|
|
97
|
+
r.environment.every((e) => typeof e === "string" && ENVIRONMENT.test(e))))
|
|
98
|
+
throw new Error(`policy ${r.id}: environment must be a list of 1-16 environment names (^[a-z][a-z0-9-]{0,31}$)`);
|
|
29
99
|
}
|
|
30
100
|
const ids = doc.rules.map((r) => r.id);
|
|
31
101
|
const twice = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
@@ -45,7 +115,13 @@ function checkShadowed(rules) {
|
|
|
45
115
|
.slice(0, j)
|
|
46
116
|
.find((e) => glob(e.tool).test(later.tool) &&
|
|
47
117
|
(e.args_match === undefined ||
|
|
48
|
-
(e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case))
|
|
118
|
+
(e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case)) &&
|
|
119
|
+
(e.hints === undefined ||
|
|
120
|
+
(later.hints !== undefined &&
|
|
121
|
+
Object.entries(e.hints).every(([k, v]) => later.hints[k] === v))) &&
|
|
122
|
+
(e.environment === undefined ||
|
|
123
|
+
(later.environment !== undefined &&
|
|
124
|
+
later.environment.every((x) => e.environment.includes(x)))));
|
|
49
125
|
if (first && first.action !== later.action)
|
|
50
126
|
throw new Error(`policy ${later.id}: never applies: ${first.id} matches the same calls first (${first.action})`);
|
|
51
127
|
});
|
|
@@ -59,12 +135,30 @@ export function compilePolicy(policy) {
|
|
|
59
135
|
: new RegExp(rule.args_match, rule.ignore_case ? "i" : ""),
|
|
60
136
|
}));
|
|
61
137
|
}
|
|
62
|
-
/**
|
|
63
|
-
export function
|
|
138
|
+
/** spec/api.md: the session's environment, from its `session.open` (the first line). */
|
|
139
|
+
export function environmentOf(lines) {
|
|
140
|
+
const first = lines[0];
|
|
141
|
+
if (first === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
const e = JSON.parse(first);
|
|
144
|
+
const env = e.kind === "session.open" ? e.meta?.environment : undefined;
|
|
145
|
+
return typeof env === "string" ? env : undefined;
|
|
146
|
+
}
|
|
147
|
+
/** A rule with `environment` holds only in a session whose environment it lists. */
|
|
148
|
+
const environmentMatch = (rule, environment) => rule.environment === undefined ||
|
|
149
|
+
(environment !== undefined && rule.environment.includes(environment));
|
|
150
|
+
/** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool
|
|
151
|
+
* has two). `hints`: an MCP tool's annotations; a rule with `hints` never matches a call without.
|
|
152
|
+
* `environment`: the session's; a rule with `environment` never matches a session without one. */
|
|
153
|
+
export function decide(compiled, names, args, hints, environment) {
|
|
64
154
|
let text = null;
|
|
65
155
|
for (const c of compiled) {
|
|
66
156
|
if (c.rule.audit === true || !names.some((n) => c.tool.test(n)))
|
|
67
157
|
continue;
|
|
158
|
+
if (!hintsMatch(c.rule.hints, hints))
|
|
159
|
+
continue;
|
|
160
|
+
if (!environmentMatch(c.rule, environment))
|
|
161
|
+
continue;
|
|
68
162
|
if (c.args) {
|
|
69
163
|
text ??= canonical(args ?? null).slice(0, MAX_ARGS);
|
|
70
164
|
if (!c.args.test(text))
|
|
@@ -79,10 +173,12 @@ export function decide(compiled, names, args) {
|
|
|
79
173
|
return null;
|
|
80
174
|
}
|
|
81
175
|
/** N3 (idea C5): every audit rule this call matches, and what it would do if it were enforced. */
|
|
82
|
-
export function audited(compiled, names, args) {
|
|
176
|
+
export function audited(compiled, names, args, hints, environment) {
|
|
83
177
|
const text = canonical(args ?? null).slice(0, MAX_ARGS);
|
|
84
178
|
return compiled
|
|
85
179
|
.filter((c) => c.rule.audit === true && names.some((n) => c.tool.test(n)))
|
|
180
|
+
.filter((c) => hintsMatch(c.rule.hints, hints))
|
|
181
|
+
.filter((c) => environmentMatch(c.rule, environment))
|
|
86
182
|
.filter((c) => !c.args || c.args.test(text))
|
|
87
183
|
.map((c) => ({ rule: c.rule.id, would: c.rule.action }));
|
|
88
184
|
}
|
|
@@ -96,6 +192,86 @@ function auditFinding(rule, would, at, tool) {
|
|
|
96
192
|
detail: `Policy ${rule} (audit only) would ${would === "deny" ? "have refused" : "have held"} ${String(tool ?? "a tool call")}.`,
|
|
97
193
|
};
|
|
98
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* spec/policy.md §9: each successful result of a call a `require_proof` rule decided must carry a
|
|
197
|
+
* valid tool witness (spec/data.md §9) or the rule's `field`, the touched system's own id.
|
|
198
|
+
*/
|
|
199
|
+
function unproven(lines, bodies, compiled) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const environment = environmentOf(lines);
|
|
202
|
+
const pending = new Map();
|
|
203
|
+
const sdkPending = new Map();
|
|
204
|
+
const json = (hash) => {
|
|
205
|
+
const b = bodies(hash);
|
|
206
|
+
if (!b)
|
|
207
|
+
return undefined;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(new TextDecoder().decode(b));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const ruleOf = (names, args, hints) => {
|
|
216
|
+
const d = decide(compiled, names, args, hints, environment);
|
|
217
|
+
return d?.action === "require_proof"
|
|
218
|
+
? compiled.find((c) => c.rule.id === d.rule)?.rule
|
|
219
|
+
: undefined;
|
|
220
|
+
};
|
|
221
|
+
const proven = (rule, meta, result) => meta.witness?.ok === true ||
|
|
222
|
+
(rule.field !== undefined && hasEvidence(result, rule.field));
|
|
223
|
+
const finding = (seq, rule, tool) => ({
|
|
224
|
+
code: "UNPROVEN_SIDE_EFFECT",
|
|
225
|
+
source: "policy",
|
|
226
|
+
severity: "warning",
|
|
227
|
+
ref: { seq, rule: rule.id, tool },
|
|
228
|
+
detail: `${tool} ran under policy ${rule.id}, and its result carries no witness${rule.field ? ` or ${rule.field}` : ""}.`,
|
|
229
|
+
});
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
const e = JSON.parse(line);
|
|
232
|
+
const m = e.meta;
|
|
233
|
+
if (e.kind === "tool.call" && typeof m.tool === "string" && m.via !== "broker") {
|
|
234
|
+
if (m.policy?.action === "deny")
|
|
235
|
+
continue;
|
|
236
|
+
const body = json(e.body_hash);
|
|
237
|
+
const names = [
|
|
238
|
+
m.tool,
|
|
239
|
+
...(typeof m.server === "string" ? [`mcp__${m.server}__${m.tool}`] : []),
|
|
240
|
+
];
|
|
241
|
+
const rule = ruleOf(names, body?.params?.arguments ?? {}, m.hints);
|
|
242
|
+
if (rule)
|
|
243
|
+
pending.set(e.seq, { rule, tool: m.tool });
|
|
244
|
+
}
|
|
245
|
+
else if (e.kind === "tool.result" && typeof m.call_seq === "number") {
|
|
246
|
+
const p = pending.get(m.call_seq);
|
|
247
|
+
if (!p)
|
|
248
|
+
continue;
|
|
249
|
+
pending.delete(m.call_seq);
|
|
250
|
+
const r = json(e.body_hash);
|
|
251
|
+
if (r === undefined || r.error !== undefined || r.result?.isError === true)
|
|
252
|
+
continue;
|
|
253
|
+
if (!proven(p.rule, m, r.result ?? r))
|
|
254
|
+
out.push(finding(e.seq, p.rule, p.tool));
|
|
255
|
+
}
|
|
256
|
+
else if (e.kind === "sdk.event" && m.type === "tool.call" && typeof m.name === "string") {
|
|
257
|
+
const d = json(e.body_hash);
|
|
258
|
+
const rule = ruleOf([m.name], d?.args ?? {});
|
|
259
|
+
if (rule)
|
|
260
|
+
sdkPending.set(m.name, [...(sdkPending.get(m.name) ?? []), { rule, tool: m.name }]);
|
|
261
|
+
}
|
|
262
|
+
else if (e.kind === "sdk.event" && m.type === "tool.result" && typeof m.name === "string") {
|
|
263
|
+
const p = sdkPending.get(m.name)?.shift();
|
|
264
|
+
if (!p)
|
|
265
|
+
continue;
|
|
266
|
+
const d = json(e.body_hash);
|
|
267
|
+
if (d === undefined || d.ok === false)
|
|
268
|
+
continue;
|
|
269
|
+
if (!proven(p.rule, m, d))
|
|
270
|
+
out.push(finding(e.seq, p.rule, p.tool));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
99
275
|
/**
|
|
100
276
|
* spec/policy.md §2 as findings: POLICY_DENIED for MCP calls the gateway refused (tool.call meta.policy),
|
|
101
277
|
* POLICY_VIOLATION for tools a model response asked for that a deny rule matches.
|
|
@@ -118,12 +294,14 @@ export function policyFindings(lines, bodies, compiled) {
|
|
|
118
294
|
if (typeof a.rule === "string" && a.would !== "allow")
|
|
119
295
|
out.push(auditFinding(a.rule, String(a.would), { seq: e.seq }, e.meta.tool));
|
|
120
296
|
}
|
|
297
|
+
out.push(...unproven(lines, bodies, compiled));
|
|
121
298
|
const calls = [...callsOf(lines, bodies)].sort((a, b) => a.endSeq - b.endSeq);
|
|
299
|
+
const environment = environmentOf(lines);
|
|
122
300
|
const violation = (tool, id, args) => {
|
|
123
|
-
for (const a of audited(compiled, [tool], args))
|
|
301
|
+
for (const a of audited(compiled, [tool], args, undefined, environment))
|
|
124
302
|
if (a.would !== "allow")
|
|
125
303
|
out.push(auditFinding(a.rule, a.would, { tool_use_id: id }, tool));
|
|
126
|
-
const d = decide(compiled, [tool], args);
|
|
304
|
+
const d = decide(compiled, [tool], args, undefined, environment);
|
|
127
305
|
if (d?.action === "deny")
|
|
128
306
|
out.push({
|
|
129
307
|
code: "POLICY_VIOLATION",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type OurRule = {
|
|
2
|
+
id: string;
|
|
3
|
+
tool: string;
|
|
4
|
+
action: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
type Skipped = {
|
|
8
|
+
id: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
/** Zanii → Blackbox: `{policy: {version: 1, rules}, skipped}`. */
|
|
12
|
+
export declare function fromZaniiPolicy(z: unknown): {
|
|
13
|
+
policy: {
|
|
14
|
+
version: 1;
|
|
15
|
+
rules: OurRule[];
|
|
16
|
+
};
|
|
17
|
+
skipped: Skipped[];
|
|
18
|
+
};
|
|
19
|
+
/** Blackbox → Zanii: `{policy: {default: "allow", rules}, skipped}`. */
|
|
20
|
+
export declare function toZaniiPolicy(ours: unknown): {
|
|
21
|
+
policy: {
|
|
22
|
+
default: "allow";
|
|
23
|
+
rules: Array<{
|
|
24
|
+
id: string;
|
|
25
|
+
effect: string;
|
|
26
|
+
targets: string[];
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
skipped: Skipped[];
|
|
30
|
+
};
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Zanii's policy format, in and out (spec/policy.md §8). A rule that can't be said the same way on
|
|
2
|
+
// the other side is skipped and named, never loosened: dropping a condition would widen what a rule
|
|
3
|
+
// denies or allows. Pure; mirrors sdks/python/src/zanii_blackbox/policy_zanii.py; pinned by
|
|
4
|
+
// spec/vectors/policy-zanii.json.
|
|
5
|
+
const EFFECTS = new Set(["deny", "allow", "require_approval"]);
|
|
6
|
+
const idOf = (raw, n) => {
|
|
7
|
+
const s = String(raw ?? "")
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "")
|
|
11
|
+
.slice(0, 60);
|
|
12
|
+
return s || `rule-${n + 1}`;
|
|
13
|
+
};
|
|
14
|
+
/** A Zanii target as a Blackbox tool pattern, or null (`mcp.<server>.<tool>`, `mcp.<server>.*`, `*`). */
|
|
15
|
+
function toolOf(target) {
|
|
16
|
+
if (target === "*" || target === "mcp.*")
|
|
17
|
+
return target === "*" ? "*" : "mcp__*";
|
|
18
|
+
const m = /^mcp\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_.*-]+)$/.exec(target);
|
|
19
|
+
return m ? `mcp__${m[1]}__${m[2]}` : null;
|
|
20
|
+
}
|
|
21
|
+
/** A Blackbox tool pattern as a Zanii target, or null (a bare tool name matches any server). */
|
|
22
|
+
function targetOf(tool) {
|
|
23
|
+
if (tool === "*")
|
|
24
|
+
return "*";
|
|
25
|
+
if (tool === "mcp__*")
|
|
26
|
+
return "mcp.*";
|
|
27
|
+
const m = /^mcp__([A-Za-z0-9_-]+?)__(.+)$/.exec(tool);
|
|
28
|
+
return m ? `mcp.${m[1]}.${m[2]}` : null;
|
|
29
|
+
}
|
|
30
|
+
/** Zanii → Blackbox: `{policy: {version: 1, rules}, skipped}`. */
|
|
31
|
+
export function fromZaniiPolicy(z) {
|
|
32
|
+
const p = (z ?? {});
|
|
33
|
+
const rules = [];
|
|
34
|
+
const skipped = [];
|
|
35
|
+
const list = Array.isArray(p.rules) ? p.rules : [];
|
|
36
|
+
for (const [n, r] of list.entries()) {
|
|
37
|
+
const id = idOf(r.id, n);
|
|
38
|
+
const skip = (reason) => skipped.push({ id, reason });
|
|
39
|
+
if (!EFFECTS.has(String(r.effect)))
|
|
40
|
+
skip(`effect ${String(r.effect)}`);
|
|
41
|
+
else if (r.where !== undefined)
|
|
42
|
+
skip("conditions on the payload (where) have no exact Blackbox form");
|
|
43
|
+
else if (r.rateLimit !== undefined)
|
|
44
|
+
skip("rate limits are not tool policy (use the session limits)");
|
|
45
|
+
else {
|
|
46
|
+
const targets = Array.isArray(r.targets) ? r.targets.map(String) : ["*"];
|
|
47
|
+
const tools = targets.map(toolOf);
|
|
48
|
+
const bad = targets.filter((_, i) => tools[i] === null);
|
|
49
|
+
if (bad.length)
|
|
50
|
+
skip(`not an MCP tool target: ${bad.join(", ")}`);
|
|
51
|
+
else
|
|
52
|
+
for (const [i, tool] of tools.entries())
|
|
53
|
+
rules.push({
|
|
54
|
+
id: tools.length > 1 ? `${id}-${i + 1}`.slice(0, 64) : id,
|
|
55
|
+
tool: tool,
|
|
56
|
+
action: String(r.effect),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (p.default === "deny")
|
|
61
|
+
rules.push({ id: "zanii-default", tool: "*", action: "deny" });
|
|
62
|
+
return { policy: { version: 1, rules }, skipped };
|
|
63
|
+
}
|
|
64
|
+
/** Blackbox → Zanii: `{policy: {default: "allow", rules}, skipped}`. */
|
|
65
|
+
export function toZaniiPolicy(ours) {
|
|
66
|
+
const p = (ours ?? {});
|
|
67
|
+
const rules = [];
|
|
68
|
+
const skipped = [];
|
|
69
|
+
for (const r of (Array.isArray(p.rules) ? p.rules : [])) {
|
|
70
|
+
const id = String(r.id);
|
|
71
|
+
const extra = ["args_match", "hints", "environment"].filter((k) => r[k] !== undefined);
|
|
72
|
+
if (r.audit === true)
|
|
73
|
+
skipped.push({ id, reason: "an audit-only rule decides nothing" });
|
|
74
|
+
else if (r.action === "require_proof")
|
|
75
|
+
skipped.push({ id, reason: "Zanii has no require_proof effect" });
|
|
76
|
+
else if (extra.length)
|
|
77
|
+
skipped.push({ id, reason: `${extra.join(", ")} have no Zanii form` });
|
|
78
|
+
else {
|
|
79
|
+
const target = targetOf(String(r.tool));
|
|
80
|
+
if (target === null)
|
|
81
|
+
skipped.push({ id, reason: `a bare tool name matches any server: ${String(r.tool)}` });
|
|
82
|
+
else
|
|
83
|
+
rules.push({ id, effect: String(r.action), targets: [target] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { policy: { default: "allow", rules }, skipped };
|
|
87
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type Obj = Record<string, unknown>;
|
|
2
|
+
export declare const PQ_ALG = "ML-DSA-65";
|
|
3
|
+
/** The ML-DSA-65 seed for a gateway identity: HMAC-SHA256(its Ed25519 key, a fixed label). */
|
|
4
|
+
export declare const pqSeedOf: (identityPrivateKey: Uint8Array) => Uint8Array;
|
|
5
|
+
/** The raw ML-DSA-65 public key (1952 bytes) for a 32-byte seed. */
|
|
6
|
+
export declare const pqPublicKey: (seed: Uint8Array) => Uint8Array;
|
|
7
|
+
/** Whether this runtime has ML-DSA-65 (Node ≥ 24.6 with OpenSSL ≥ 3.5). */
|
|
8
|
+
export declare function pqAvailable(): boolean;
|
|
9
|
+
/** Zanii's pq.binding: both keys sign the same body, so holding one key can't forge it. */
|
|
10
|
+
export declare function bindPqKey(o: {
|
|
11
|
+
did: string;
|
|
12
|
+
ts: string;
|
|
13
|
+
edPrivateKey: Uint8Array;
|
|
14
|
+
pqSeed: Uint8Array;
|
|
15
|
+
}): Obj;
|
|
16
|
+
/** Zanii's check: the structure, and BOTH signatures over the same body. */
|
|
17
|
+
export declare function verifyPqBinding(binding: unknown): {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
reasons: string[];
|
|
20
|
+
};
|
|
21
|
+
/** Zanii's transition payload, recorded UNSALTED: the binding as compact JSON. */
|
|
22
|
+
export declare function pqTransitionPayload(binding: Obj): string;
|
|
23
|
+
export {};
|
package/dist/pq/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Post-quantum binding of the gateway identity (spec/anchoring.md §13): Zanii's pq.binding, an
|
|
2
|
+
// ML-DSA-65 key (FIPS 204) bound to a did:key and signed by both keys, and the unsalted transition
|
|
3
|
+
// payload anchored to prove the binding existed before any break of Ed25519 (@zanii/pq). Needs
|
|
4
|
+
// Node ≥ 24.6 for ML-DSA. Pure; mirrors sdks/python/src/zanii_blackbox/pq.py; pinned by
|
|
5
|
+
// spec/vectors/pq-binding.json.
|
|
6
|
+
import { createHmac, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|
7
|
+
import { canonicalBytes, publicKeyFromDid } from "@zanii/core";
|
|
8
|
+
import { ed25519Sign, ed25519Verify } from "../transparency/index.js";
|
|
9
|
+
export const PQ_ALG = "ML-DSA-65";
|
|
10
|
+
const SEED_INFO = "zanii-blackbox identity ml-dsa-65 v1";
|
|
11
|
+
const PKCS8 = Buffer.from("3034020100300b060960864801650304031204228020", "hex");
|
|
12
|
+
const SPKI = Buffer.from("308207b2300b0609608648016503040312038207a100", "hex");
|
|
13
|
+
/** The ML-DSA-65 seed for a gateway identity: HMAC-SHA256(its Ed25519 key, a fixed label). */
|
|
14
|
+
export const pqSeedOf = (identityPrivateKey) => new Uint8Array(createHmac("sha256", identityPrivateKey).update(SEED_INFO).digest());
|
|
15
|
+
const privateKey = (seed) => {
|
|
16
|
+
if (seed.length !== 32)
|
|
17
|
+
throw new Error("an ML-DSA-65 seed is 32 bytes");
|
|
18
|
+
return createPrivateKey({ key: Buffer.concat([PKCS8, seed]), format: "der", type: "pkcs8" });
|
|
19
|
+
};
|
|
20
|
+
/** The raw ML-DSA-65 public key (1952 bytes) for a 32-byte seed. */
|
|
21
|
+
export const pqPublicKey = (seed) => new Uint8Array(createPublicKey(privateKey(seed)).export({ format: "der", type: "spki" }).subarray(SPKI.length));
|
|
22
|
+
/** Whether this runtime has ML-DSA-65 (Node ≥ 24.6 with OpenSSL ≥ 3.5). */
|
|
23
|
+
export function pqAvailable() {
|
|
24
|
+
try {
|
|
25
|
+
pqPublicKey(new Uint8Array(32));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const validTs = (ts) => typeof ts === "string" && !Number.isNaN(Date.parse(ts));
|
|
33
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
34
|
+
/** Zanii's pq.binding: both keys sign the same body, so holding one key can't forge it. */
|
|
35
|
+
export function bindPqKey(o) {
|
|
36
|
+
if (!o.did)
|
|
37
|
+
throw new Error("did is required");
|
|
38
|
+
if (!validTs(o.ts))
|
|
39
|
+
throw new Error("ts must be an ISO timestamp");
|
|
40
|
+
const unsigned = {
|
|
41
|
+
v: 1,
|
|
42
|
+
type: "pq.binding",
|
|
43
|
+
did: o.did,
|
|
44
|
+
alg: PQ_ALG,
|
|
45
|
+
pq_pub: hex(pqPublicKey(o.pqSeed)),
|
|
46
|
+
ts: o.ts,
|
|
47
|
+
};
|
|
48
|
+
const raw = canonicalBytes(unsigned);
|
|
49
|
+
return {
|
|
50
|
+
...unsigned,
|
|
51
|
+
sig_ed: `ed25519:${hex(ed25519Sign(o.edPrivateKey, raw))}`,
|
|
52
|
+
sig_pq: `mldsa65:${hex(sign(null, raw, privateKey(o.pqSeed)))}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const pqVerify = (publicKey, msg, sig) => {
|
|
56
|
+
try {
|
|
57
|
+
const key = createPublicKey({
|
|
58
|
+
key: Buffer.concat([SPKI, publicKey]),
|
|
59
|
+
format: "der",
|
|
60
|
+
type: "spki",
|
|
61
|
+
});
|
|
62
|
+
return verify(null, msg, key, sig);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/** Zanii's check: the structure, and BOTH signatures over the same body. */
|
|
69
|
+
export function verifyPqBinding(binding) {
|
|
70
|
+
const b = (binding ?? {});
|
|
71
|
+
const reasons = [];
|
|
72
|
+
if (b.v !== 1 || b.type !== "pq.binding")
|
|
73
|
+
reasons.push("not a pq.binding (v1)");
|
|
74
|
+
if (b.alg !== PQ_ALG)
|
|
75
|
+
reasons.push("unsupported alg (want ML-DSA-65)");
|
|
76
|
+
if (!b.did)
|
|
77
|
+
reasons.push("missing did");
|
|
78
|
+
const pqPub = typeof b.pq_pub === "string" && /^([0-9a-f]{2})+$/.test(b.pq_pub)
|
|
79
|
+
? new Uint8Array(Buffer.from(b.pq_pub, "hex"))
|
|
80
|
+
: null;
|
|
81
|
+
if (!pqPub)
|
|
82
|
+
reasons.push("missing/invalid pq_pub");
|
|
83
|
+
if (!validTs(b.ts))
|
|
84
|
+
reasons.push("missing/invalid ts");
|
|
85
|
+
if (reasons.length > 0 || !pqPub)
|
|
86
|
+
return { ok: false, reasons };
|
|
87
|
+
const { sig_ed: sigEd, sig_pq: sigPq, ...unsigned } = b;
|
|
88
|
+
const raw = canonicalBytes(unsigned);
|
|
89
|
+
const pub = publicKeyFromDid(String(b.did));
|
|
90
|
+
const ed = typeof sigEd === "string" ? /^ed25519:([0-9a-f]{128})$/.exec(sigEd) : null;
|
|
91
|
+
if (!pub || !ed || !ed25519Verify(pub, raw, Buffer.from(ed[1], "hex")))
|
|
92
|
+
reasons.push("Ed25519 signature does not verify against the did");
|
|
93
|
+
const pq = typeof sigPq === "string" ? /^mldsa65:((?:[0-9a-f]{2})+)$/.exec(sigPq) : null;
|
|
94
|
+
if (!pq || !pqVerify(pqPub, raw, Buffer.from(pq[1], "hex")))
|
|
95
|
+
reasons.push("ML-DSA signature does not verify against pq_pub (possession not proven)");
|
|
96
|
+
return { ok: reasons.length === 0, reasons };
|
|
97
|
+
}
|
|
98
|
+
/** Zanii's transition payload, recorded UNSALTED: the binding as compact JSON. */
|
|
99
|
+
export function pqTransitionPayload(binding) {
|
|
100
|
+
const check = verifyPqBinding(binding);
|
|
101
|
+
if (!check.ok)
|
|
102
|
+
throw new Error(`refusing to anchor an invalid binding: ${check.reasons.join("; ")}`);
|
|
103
|
+
return JSON.stringify(binding);
|
|
104
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface SearchQuery {
|
|
2
|
+
/** An event kind, e.g. `tool.call`, `finding`, `control`. */
|
|
3
|
+
kind?: string;
|
|
4
|
+
/** A tool's name, as called: `delete_record`, `mcp__db__delete_record`, an SDK tool. */
|
|
5
|
+
tool?: string;
|
|
6
|
+
/** A finding code, e.g. `TOOL_CHANGED`. */
|
|
7
|
+
code?: string;
|
|
8
|
+
/** A person who decided an approval or a grant: their `sub`, `email` (any case) or `name`. */
|
|
9
|
+
person?: string;
|
|
10
|
+
/** The session's environment (`session.open` meta.environment). */
|
|
11
|
+
environment?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SearchMatch {
|
|
14
|
+
seq: number;
|
|
15
|
+
kind: string;
|
|
16
|
+
ts: string;
|
|
17
|
+
/** What matched: the tool, the code, the person, or the kind. */
|
|
18
|
+
match: string;
|
|
19
|
+
}
|
|
20
|
+
/** The query's fields, checked: each a 1-256 character string; `environment` alone is allowed. */
|
|
21
|
+
export declare function checkSearch(q: Record<string, unknown>): string | undefined;
|
|
22
|
+
/** Every event of the record that matches all of the query's fields, in seq order. */
|
|
23
|
+
export declare function searchEvents(lines: readonly string[], q: SearchQuery): SearchMatch[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Structured search over a session's record (spec/search.md): exact matches on what the events say
|
|
2
|
+
// (a tool, a fault code, a person, an event kind, the session's environment), never text inside
|
|
3
|
+
// bodies. Precise by construction, and it reads nothing that was redacted. Pure; mirrors
|
|
4
|
+
// sdks/python/src/zanii_blackbox/search.py; pinned by spec/vectors/search.json.
|
|
5
|
+
const DECISIONS = new Set(["approval", "approval_vote", "permission"]);
|
|
6
|
+
/** The query's fields, checked: each a 1-256 character string; `environment` alone is allowed. */
|
|
7
|
+
export function checkSearch(q) {
|
|
8
|
+
const fields = ["kind", "tool", "code", "person", "environment"];
|
|
9
|
+
for (const k of Object.keys(q))
|
|
10
|
+
if (!fields.includes(k))
|
|
11
|
+
return `unknown search field ${JSON.stringify(k)}`;
|
|
12
|
+
for (const k of fields) {
|
|
13
|
+
const v = q[k];
|
|
14
|
+
if (v !== undefined && (typeof v !== "string" || v.length < 1 || v.length > 256))
|
|
15
|
+
return `${k} must be 1-256 characters`;
|
|
16
|
+
}
|
|
17
|
+
return fields.some((k) => q[k] !== undefined)
|
|
18
|
+
? undefined
|
|
19
|
+
: "give kind, tool, code, person or environment";
|
|
20
|
+
}
|
|
21
|
+
function toolOf(e) {
|
|
22
|
+
if (e.kind === "tool.call" && typeof e.meta.tool === "string")
|
|
23
|
+
return typeof e.meta.server === "string"
|
|
24
|
+
? [e.meta.tool, `mcp__${e.meta.server}__${e.meta.tool}`]
|
|
25
|
+
: [e.meta.tool];
|
|
26
|
+
if (e.kind === "sdk.event" && e.meta.type === "tool.call" && typeof e.meta.name === "string")
|
|
27
|
+
return [e.meta.name];
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
function personMatch(e, who) {
|
|
31
|
+
if (e.kind !== "control" || !DECISIONS.has(String(e.meta.action)))
|
|
32
|
+
return false;
|
|
33
|
+
const p = e.meta.person;
|
|
34
|
+
if (!p)
|
|
35
|
+
return false;
|
|
36
|
+
return (p.sub === who ||
|
|
37
|
+
p.name === who ||
|
|
38
|
+
(typeof p.email === "string" && p.email.toLowerCase() === who.toLowerCase()));
|
|
39
|
+
}
|
|
40
|
+
/** Every event of the record that matches all of the query's fields, in seq order. */
|
|
41
|
+
export function searchEvents(lines, q) {
|
|
42
|
+
const events = lines.map((l) => JSON.parse(l));
|
|
43
|
+
const open = events.find((e) => e.kind === "session.open");
|
|
44
|
+
if (q.environment !== undefined && open?.meta.environment !== q.environment)
|
|
45
|
+
return [];
|
|
46
|
+
const eventWise = q.kind !== undefined || q.tool !== undefined || q.code !== undefined || q.person !== undefined;
|
|
47
|
+
if (!eventWise)
|
|
48
|
+
return open
|
|
49
|
+
? [{ seq: open.seq, kind: open.kind, ts: open.ts, match: q.environment }]
|
|
50
|
+
: [];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const e of events) {
|
|
53
|
+
if (q.kind !== undefined && e.kind !== q.kind)
|
|
54
|
+
continue;
|
|
55
|
+
if (q.tool !== undefined && !toolOf(e).includes(q.tool))
|
|
56
|
+
continue;
|
|
57
|
+
if (q.code !== undefined && !(e.kind === "finding" && e.meta.code === q.code))
|
|
58
|
+
continue;
|
|
59
|
+
if (q.person !== undefined && !personMatch(e, q.person))
|
|
60
|
+
continue;
|
|
61
|
+
out.push({
|
|
62
|
+
seq: e.seq,
|
|
63
|
+
kind: e.kind,
|
|
64
|
+
ts: e.ts,
|
|
65
|
+
match: q.tool ?? q.code ?? q.person ?? q.kind,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|