@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.
Files changed (94) hide show
  1. package/README.md +26 -1
  2. package/dist/a2a/index.d.ts +77 -0
  3. package/dist/a2a/index.js +305 -0
  4. package/dist/agents/index.d.ts +8 -0
  5. package/dist/analysis/accuracy.d.ts +24 -0
  6. package/dist/analysis/accuracy.js +45 -0
  7. package/dist/analysis/credential.d.ts +101 -0
  8. package/dist/analysis/credential.js +142 -0
  9. package/dist/analysis/faults.js +115 -0
  10. package/dist/analysis/grounding.d.ts +122 -0
  11. package/dist/analysis/grounding.js +445 -0
  12. package/dist/analysis/hallucination.d.ts +32 -0
  13. package/dist/analysis/hallucination.js +357 -0
  14. package/dist/analysis/index.d.ts +23 -0
  15. package/dist/analysis/index.js +93 -0
  16. package/dist/analysis/memory.d.ts +8 -0
  17. package/dist/analysis/memory.js +35 -8
  18. package/dist/analysis/reference.d.ts +49 -0
  19. package/dist/analysis/reference.js +164 -0
  20. package/dist/analysis/taxonomy.d.ts +18 -0
  21. package/dist/analysis/taxonomy.js +66 -0
  22. package/dist/approvals/index.d.ts +23 -0
  23. package/dist/approvals/index.js +48 -0
  24. package/dist/archive/index.d.ts +39 -0
  25. package/dist/archive/index.js +96 -0
  26. package/dist/archive/parquet.d.ts +2 -0
  27. package/dist/archive/parquet.js +185 -0
  28. package/dist/badge/index.d.ts +16 -0
  29. package/dist/badge/index.js +48 -0
  30. package/dist/bom/index.d.ts +14 -0
  31. package/dist/bom/index.js +152 -0
  32. package/dist/cli.js +114 -10
  33. package/dist/compliance/art12.d.ts +35 -0
  34. package/dist/compliance/art12.js +190 -0
  35. package/dist/compliance/index.d.ts +36 -2
  36. package/dist/compliance/index.js +78 -11
  37. package/dist/compliance/zanii.d.ts +29 -0
  38. package/dist/compliance/zanii.js +84 -0
  39. package/dist/constitution/index.d.ts +57 -0
  40. package/dist/constitution/index.js +131 -0
  41. package/dist/cv/index.d.ts +39 -0
  42. package/dist/cv/index.js +108 -0
  43. package/dist/disclosure/index.d.ts +31 -0
  44. package/dist/disclosure/index.js +113 -0
  45. package/dist/encryption/index.d.ts +9 -0
  46. package/dist/encryption/index.js +31 -0
  47. package/dist/evidence/index.d.ts +60 -0
  48. package/dist/evidence/index.js +151 -0
  49. package/dist/federation/index.d.ts +35 -0
  50. package/dist/federation/index.js +102 -0
  51. package/dist/finance/index.d.ts +126 -0
  52. package/dist/finance/index.js +320 -0
  53. package/dist/fleet/index.js +9 -0
  54. package/dist/gov/index.d.ts +108 -0
  55. package/dist/gov/index.js +225 -0
  56. package/dist/health/index.d.ts +120 -0
  57. package/dist/health/index.js +233 -0
  58. package/dist/index.d.ts +34 -5
  59. package/dist/index.js +34 -5
  60. package/dist/memory/index.d.ts +36 -0
  61. package/dist/memory/index.js +85 -0
  62. package/dist/occurrence/index.d.ts +11 -0
  63. package/dist/occurrence/index.js +18 -0
  64. package/dist/ocsf/index.d.ts +1 -1
  65. package/dist/ocsf/index.js +36 -3
  66. package/dist/otlp/index.d.ts +8 -1
  67. package/dist/otlp/index.js +258 -1
  68. package/dist/packs/index.js +44 -4
  69. package/dist/policy/delta.js +7 -1
  70. package/dist/policy/index.d.ts +40 -6
  71. package/dist/policy/index.js +186 -8
  72. package/dist/policy/zanii.d.ts +31 -0
  73. package/dist/policy/zanii.js +87 -0
  74. package/dist/pq/index.d.ts +23 -0
  75. package/dist/pq/index.js +104 -0
  76. package/dist/search/index.d.ts +23 -0
  77. package/dist/search/index.js +69 -0
  78. package/dist/session/index.d.ts +107 -1
  79. package/dist/session/index.js +189 -11
  80. package/dist/sla/index.d.ts +61 -0
  81. package/dist/sla/index.js +197 -0
  82. package/dist/succession/index.d.ts +50 -0
  83. package/dist/succession/index.js +123 -0
  84. package/dist/timestamp/index.d.ts +24 -0
  85. package/dist/timestamp/index.js +274 -0
  86. package/dist/tokens/index.d.ts +6 -0
  87. package/dist/tokens/index.js +46 -0
  88. package/dist/transparency/index.d.ts +188 -0
  89. package/dist/transparency/index.js +712 -0
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/dist/walls/index.d.ts +31 -0
  93. package/dist/walls/index.js +119 -0
  94. package/package.json +1 -1
@@ -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 (r.action !== "deny" && r.action !== "allow" && r.action !== "require_approval")
21
- throw new Error(`policy ${r.id}: action must be deny, allow or require_approval`);
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
- /** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool has two). */
63
- export function decide(compiled, names, args) {
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 {};
@@ -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
+ }