@zanii/blackbox 0.3.0 → 0.5.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 (82) hide show
  1. package/README.md +31 -1
  2. package/dist/a2a/index.d.ts +27 -0
  3. package/dist/a2a/index.js +104 -1
  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.js +1 -0
  21. package/dist/approvals/index.d.ts +23 -0
  22. package/dist/approvals/index.js +48 -0
  23. package/dist/archive/parquet.d.ts +2 -0
  24. package/dist/archive/parquet.js +185 -0
  25. package/dist/badge/index.d.ts +16 -0
  26. package/dist/badge/index.js +48 -0
  27. package/dist/bom/index.js +20 -0
  28. package/dist/cli.js +114 -10
  29. package/dist/compliance/art12.js +36 -9
  30. package/dist/compliance/index.d.ts +36 -2
  31. package/dist/compliance/index.js +78 -11
  32. package/dist/compliance/zanii.d.ts +29 -0
  33. package/dist/compliance/zanii.js +84 -0
  34. package/dist/constitution/index.d.ts +57 -0
  35. package/dist/constitution/index.js +131 -0
  36. package/dist/cv/index.d.ts +39 -0
  37. package/dist/cv/index.js +108 -0
  38. package/dist/disclosure/index.d.ts +31 -0
  39. package/dist/disclosure/index.js +113 -0
  40. package/dist/encryption/index.d.ts +9 -0
  41. package/dist/encryption/index.js +31 -0
  42. package/dist/evidence/index.d.ts +60 -0
  43. package/dist/evidence/index.js +151 -0
  44. package/dist/federation/index.d.ts +35 -0
  45. package/dist/federation/index.js +102 -0
  46. package/dist/finance/index.d.ts +126 -0
  47. package/dist/finance/index.js +320 -0
  48. package/dist/fleet/index.js +9 -0
  49. package/dist/gov/index.d.ts +108 -0
  50. package/dist/gov/index.js +225 -0
  51. package/dist/health/index.d.ts +120 -0
  52. package/dist/health/index.js +233 -0
  53. package/dist/index.d.ts +29 -7
  54. package/dist/index.js +28 -6
  55. package/dist/memory/index.d.ts +36 -0
  56. package/dist/memory/index.js +85 -0
  57. package/dist/occurrence/index.d.ts +11 -0
  58. package/dist/occurrence/index.js +18 -0
  59. package/dist/otlp/index.js +28 -1
  60. package/dist/packs/index.js +44 -4
  61. package/dist/policy/delta.js +7 -1
  62. package/dist/policy/index.d.ts +23 -6
  63. package/dist/policy/index.js +151 -8
  64. package/dist/policy/zanii.d.ts +31 -0
  65. package/dist/policy/zanii.js +87 -0
  66. package/dist/pq/index.d.ts +23 -0
  67. package/dist/pq/index.js +104 -0
  68. package/dist/search/index.d.ts +23 -0
  69. package/dist/search/index.js +69 -0
  70. package/dist/session/index.d.ts +106 -1
  71. package/dist/session/index.js +163 -11
  72. package/dist/sla/index.d.ts +61 -0
  73. package/dist/sla/index.js +197 -0
  74. package/dist/succession/index.d.ts +50 -0
  75. package/dist/succession/index.js +123 -0
  76. package/dist/tokens/index.d.ts +6 -0
  77. package/dist/tokens/index.js +46 -0
  78. package/dist/version.d.ts +1 -1
  79. package/dist/version.js +1 -1
  80. package/dist/walls/index.d.ts +31 -0
  81. package/dist/walls/index.js +119 -0
  82. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ // Provable agent memory (spec/agents.md §4): Zanii's memory.write entries, a salted commitment to
2
+ // what the agent remembered, hash-chained so an edit afterwards shows (@zanii/memory). Pure;
3
+ // mirrors sdks/python/src/zanii_blackbox/memory.py; pinned by spec/vectors/memory.json.
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { jcsHash } from "@zanii/core";
6
+ /** Zanii's salted commitment to memory content. */
7
+ export function commitContent(content, salt) {
8
+ const s = salt ?? randomBytes(16).toString("hex");
9
+ const h = createHash("sha256")
10
+ .update(Buffer.from(s, "hex"))
11
+ .update(content, "utf8")
12
+ .digest("hex");
13
+ return { commitment: `sha256:${h}`, salt: s };
14
+ }
15
+ /** The entry was about `content`, given the salt kept at write time. */
16
+ export const verifyMemoryContent = (content, salt, commitment) => commitContent(content, salt).commitment === commitment;
17
+ const validTs = (ts) => typeof ts === "string" && !Number.isNaN(Date.parse(ts));
18
+ const entryHash = (commitment, kind, ts, prev, seq) => jcsHash({ v: 1, content_commitment: commitment, kind, ts, prev, seq });
19
+ /** Zanii's memory.write payload, after `prev` (the previous entry; null starts a chain). Keep the salt. */
20
+ export function memoryEntry(input) {
21
+ if (!validTs(input.ts))
22
+ throw new Error("ts must be an ISO timestamp");
23
+ const p = input.prev ?? null;
24
+ if (p && (typeof p.entry_hash !== "string" || !Number.isSafeInteger(p.seq)))
25
+ throw new Error("previous entry has no entry_hash or seq");
26
+ const kind = input.kind ?? "fact";
27
+ const c = commitContent(input.content, input.salt);
28
+ const seq = p ? p.seq + 1 : 0;
29
+ const prev = p ? p.entry_hash : null;
30
+ return {
31
+ payload: {
32
+ _zr_kind: "memory",
33
+ v: 1,
34
+ kind,
35
+ content_commitment: c.commitment,
36
+ prev,
37
+ seq,
38
+ entry_hash: entryHash(c.commitment, kind, input.ts, prev, seq),
39
+ agent: input.agent ?? null,
40
+ tags: input.tags ?? null,
41
+ ts: input.ts,
42
+ },
43
+ salt: c.salt,
44
+ };
45
+ }
46
+ /** Zanii's check of one entry: well formed, and its entry_hash matches. */
47
+ export function verifyMemoryEntry(payload) {
48
+ const p = (payload ?? {});
49
+ const reasons = [];
50
+ if (p._zr_kind !== "memory" || p.v !== 1)
51
+ reasons.push("not a memory receipt (v1)");
52
+ const cc = p.content_commitment;
53
+ if (typeof cc !== "string" || !cc.startsWith("sha256:"))
54
+ reasons.push("missing/invalid content commitment");
55
+ if (!validTs(p.ts))
56
+ reasons.push("missing/invalid ts");
57
+ if (!Number.isSafeInteger(p.seq) || p.seq < 0)
58
+ reasons.push("missing/invalid seq");
59
+ if (p.prev !== null && p.prev !== undefined && typeof p.prev !== "string")
60
+ reasons.push("prev must be a hash string or null");
61
+ if (reasons.length === 0 &&
62
+ entryHash(cc, p.kind || "fact", p.ts, p.prev ?? null, p.seq) !== p.entry_hash)
63
+ reasons.push("entry_hash does not match its contents (tampered)");
64
+ return { ok: reasons.length === 0, reasons };
65
+ }
66
+ /** Walks entries in order: each checks, links to the one before and counts up by one. The first may continue a chain from elsewhere. */
67
+ export function memoryChain(entries) {
68
+ const broken = [];
69
+ let prevHash = null;
70
+ let prevSeq = null;
71
+ entries.forEach((e, i) => {
72
+ const p = (e ?? {});
73
+ const single = verifyMemoryEntry(p);
74
+ if (!single.ok)
75
+ broken.push({ index: i, reason: single.reasons.join("; ") });
76
+ else if (i > 0 && (p.prev ?? null) !== prevHash)
77
+ broken.push({ index: i, reason: "broken link (prev is not the previous entry_hash)" });
78
+ if (single.ok && prevSeq !== null && p.seq !== prevSeq + 1)
79
+ broken.push({ index: i, reason: "seq is not previous + 1" });
80
+ // a bad entry is reported once: the next one is checked against what it claims
81
+ prevHash = p.entry_hash;
82
+ prevSeq = Number.isSafeInteger(p.seq) ? p.seq : null;
83
+ });
84
+ return { ok: broken.length === 0, length: entries.length, broken };
85
+ }
@@ -5,6 +5,8 @@ export interface OccurrenceFramework {
5
5
  title: string;
6
6
  authority: string;
7
7
  deadlines: string[];
8
+ /** spec/packs.md §1: each deadline's hours after awareness, or null. */
9
+ clock_hours?: Array<number | null>;
8
10
  sections: Array<{
9
11
  id: string;
10
12
  title: string;
@@ -68,6 +70,15 @@ export declare function occurrenceReport(id: string, framework: OccurrenceFramew
68
70
  }[];
69
71
  };
70
72
  export type OccurrenceReport = ReturnType<typeof occurrenceReport>;
73
+ /**
74
+ * spec/occurrence.md §1a: each deadline's due time, counted from when the entity became aware
75
+ * (`awareAt`, ISO-8601 with a zone). A deadline without a clock (it runs from another event, like a
76
+ * corrective measure) is `null`. Whether and when the entity became aware is the entity's call.
77
+ */
78
+ export declare function deadlinesDue(framework: OccurrenceFramework, awareAt: string, lang?: "en" | "ar"): Array<{
79
+ deadline: string;
80
+ due_at: string | null;
81
+ }>;
71
82
  export declare function renderOccurrence(r: OccurrenceReport, notes: string): string;
72
83
  /** spec/occurrence.md "Filing": what a person needs to file the report in an authority's portal. */
73
84
  export declare function filingPack(r: OccurrenceReport, notes: string, pack: {
@@ -114,6 +114,24 @@ export function occurrenceReport(id, framework, facts, lang = "en") {
114
114
  })),
115
115
  };
116
116
  }
117
+ /**
118
+ * spec/occurrence.md §1a: each deadline's due time, counted from when the entity became aware
119
+ * (`awareAt`, ISO-8601 with a zone). A deadline without a clock (it runs from another event, like a
120
+ * corrective measure) is `null`. Whether and when the entity became aware is the entity's call.
121
+ */
122
+ export function deadlinesDue(framework, awareAt, lang = "en") {
123
+ const at = Date.parse(awareAt);
124
+ if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(awareAt) || Number.isNaN(at))
125
+ throw new Error("aware_at must be an ISO-8601 time with a zone");
126
+ const texts = lang === "ar" && framework.ar ? framework.ar.deadlines : framework.deadlines;
127
+ return texts.map((deadline, i) => {
128
+ const h = framework.clock_hours?.[i];
129
+ return {
130
+ deadline,
131
+ due_at: typeof h === "number" ? new Date(at + h * 3_600_000).toISOString() : null,
132
+ };
133
+ });
134
+ }
117
135
  const LABELS = {
118
136
  en: ["Session", "Record verifies", "yes", "NO", "Report to", "Generated", "Deadlines"],
119
137
  ar: ["الجلسة", "السجل موثّق", "نعم", "لا", "جهة الإبلاغ", "تاريخ الإنشاء", "المواعيد النهائية"],
@@ -13,7 +13,34 @@ const int = (key, v) => typeof v === "number" && Number.isSafeInteger(v) ? [{ ke
13
13
  * is the first version's output, pinned by spec/vectors/otlp.json.
14
14
  */
15
15
  export function toOtlp(lines, options = {}) {
16
- return options.semconv === 1 ? toOtlpV1(lines) : toOtlpV2(lines);
16
+ return linkCallers(options.semconv === 1 ? toOtlpV1(lines) : toOtlpV2(lines), lines);
17
+ }
18
+ const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/;
19
+ /** spec/otlp.md §3: a call that carried the caller's W3C `traceparent` links to the caller's span. */
20
+ function linkCallers(out, lines) {
21
+ const links = new Map();
22
+ let session = "";
23
+ for (const l of lines) {
24
+ const e = JSON.parse(l);
25
+ session ||= e.session_id;
26
+ const tp = e.meta.headers?.traceparent;
27
+ const m = typeof tp === "string" ? TRACEPARENT.exec(tp) : null;
28
+ if (m && !/^0+$/.test(m[1]) && !/^0+$/.test(m[2]))
29
+ links.set(hex(`${session}:${e.seq}`, 16), {
30
+ traceId: m[1],
31
+ spanId: m[2],
32
+ });
33
+ }
34
+ if (links.size === 0)
35
+ return out;
36
+ for (const rs of out.resourceSpans)
37
+ for (const ss of rs.scopeSpans)
38
+ for (const sp of ss.spans) {
39
+ const link = links.get(sp.spanId);
40
+ if (link)
41
+ sp.links = [link];
42
+ }
43
+ return out;
17
44
  }
18
45
  function toOtlpV1(lines) {
19
46
  const events = lines.map((l) => JSON.parse(l));
@@ -16,6 +16,13 @@ const FACTS = new Set([
16
16
  "resumes_with_note",
17
17
  "outcomes",
18
18
  "data_region",
19
+ // spec/compliance.md §1a (H6)
20
+ "model_responses",
21
+ "hallucination_findings",
22
+ "grounding_checks",
23
+ "judge_checks",
24
+ "hallucination_controls",
25
+ "detector_accuracy",
19
26
  ]);
20
27
  const FILLS = new Set(["record", "findings", "redactions", "operator"]);
21
28
  const PACK_ID = /^[a-z0-9-]{1,64}$/;
@@ -63,8 +70,8 @@ function checkPart(part, kind) {
63
70
  if (!isObj(fw) || !text(fw.title, 500))
64
71
  return `${at}.title must be 1-500 characters`;
65
72
  if (kind === "compliance") {
66
- if (!only(fw, ["title", "sections"]))
67
- return `${at} may only have title and sections`;
73
+ if (!only(fw, ["title", "sections", "ar"]))
74
+ return `${at} may only have title, sections and ar`;
68
75
  const bad = checkSections(fw.sections, 50, at, (s, sat) => {
69
76
  if (!only(s, ["id", "title", "requirement", "facts"]))
70
77
  return `${sat} may only have id, title, requirement, facts`;
@@ -79,10 +86,15 @@ function checkPart(part, kind) {
79
86
  });
80
87
  if (bad)
81
88
  return bad;
89
+ if (fw.ar !== undefined) {
90
+ const badAr = checkComplianceArabic(fw.ar, fw, `${at}.ar`);
91
+ if (badAr)
92
+ return badAr;
93
+ }
82
94
  }
83
95
  else {
84
- if (!only(fw, ["title", "authority", "deadlines", "sections", "ar"]))
85
- return `${at} may only have title, authority, deadlines, sections`;
96
+ if (!only(fw, ["title", "authority", "deadlines", "clock_hours", "sections", "ar"]))
97
+ return `${at} may only have title, authority, deadlines, clock_hours, sections`;
86
98
  if (!text(fw.authority, 500))
87
99
  return `${at}.authority must be 1-500 characters`;
88
100
  if (!Array.isArray(fw.deadlines) ||
@@ -90,6 +102,13 @@ function checkPart(part, kind) {
90
102
  fw.deadlines.length > 10 ||
91
103
  !fw.deadlines.every((d) => text(d, 500)))
92
104
  return `${at}.deadlines must hold 1-10 texts of 1-500 characters`;
105
+ // spec/packs.md §1: each deadline's hours after awareness, or null when it runs from another event
106
+ if (fw.clock_hours !== undefined &&
107
+ !(Array.isArray(fw.clock_hours) &&
108
+ fw.clock_hours.length === fw.deadlines.length &&
109
+ fw.clock_hours.every((h) => h === null ||
110
+ (Number.isSafeInteger(h) && h >= 1 && h <= 8760))))
111
+ return `${at}.clock_hours must give each deadline 1-8760 hours, or null`;
93
112
  const bad = checkSections(fw.sections, 30, at, (s, sat) => {
94
113
  if (!only(s, ["id", "title", "fill"]))
95
114
  return `${sat} may only have id, title, fill`;
@@ -108,6 +127,27 @@ function checkPart(part, kind) {
108
127
  }
109
128
  return undefined;
110
129
  }
130
+ /** spec/packs.md §1: a compliance framework's Arabic: its title, and each section's title and
131
+ * requirement, by id. */
132
+ function checkComplianceArabic(ar, fw, at) {
133
+ if (!isObj(ar) || !only(ar, ["title", "sections"]))
134
+ return `${at} must be {title, sections}`;
135
+ if (!text(ar.title, 500))
136
+ return `${at}.title must be 1-500 characters`;
137
+ const ids = fw.sections.map((s) => s.id);
138
+ const secs = ar.sections;
139
+ if (!isObj(secs) ||
140
+ Object.keys(secs).length !== ids.length ||
141
+ !ids.every((id) => {
142
+ const s = secs[id];
143
+ return (isObj(s) &&
144
+ only(s, ["title", "requirement"]) &&
145
+ text(s.title, 500) &&
146
+ text(s.requirement, 2000));
147
+ }))
148
+ return `${at}.sections must translate each section's title and requirement, by id`;
149
+ return undefined;
150
+ }
111
151
  /** spec/packs.md §1: an occurrence framework's Arabic: the same deadlines and sections, translated. */
112
152
  function checkArabic(ar, fw, at) {
113
153
  if (!isObj(ar) || !only(ar, ["title", "authority", "deadlines", "sections"]))
@@ -3,7 +3,13 @@
3
3
  // in doubt, it adds power. Mirrors policy/delta.py.
4
4
  /** How much a rule holds back: an enforced allow opens (0), an audit rule does nothing (1), a hold
5
5
  * (2) and a deny (3) close. */
6
- const rank = (r) => r.audit === true ? 1 : r.action === "allow" ? 0 : r.action === "require_approval" ? 2 : 3;
6
+ const rank = (r) => r.audit === true || r.action === "require_proof"
7
+ ? 1
8
+ : r.action === "allow"
9
+ ? 0
10
+ : r.action === "require_approval"
11
+ ? 2
12
+ : 3;
7
13
  const matcher = (r) => JSON.stringify([r.tool, r.args_match ?? null, r.ignore_case === true]);
8
14
  export function policyDelta(current, proposed) {
9
15
  const before = new Map(current.rules.map((r) => [r.id, r]));
@@ -6,14 +6,21 @@ export interface Rule {
6
6
  tool: string;
7
7
  args_match?: string;
8
8
  ignore_case?: boolean;
9
- /** spec/approvals.md: `require_approval` holds the call for a second person. */
10
- action: "deny" | "allow" | "require_approval";
9
+ /** spec/approvals.md: `require_approval` holds the call for a second person; spec/policy.md
10
+ * §9: `require_proof` lets it run, and its result must prove itself. */
11
+ action: "deny" | "allow" | "require_approval" | "require_proof";
12
+ /** spec/policy.md §9: with `require_proof`, the system's own id in the result (`refund_id`). */
13
+ field?: string;
11
14
  reason?: string;
12
15
  /** N3 (idea C5): record-only. Never decides a call; what it would do is recorded (POLICY_AUDIT). */
13
16
  audit?: boolean;
14
17
  /** spec/policy.md §1: MCP tool annotations the call must have (from the server's `tools/list`). */
15
18
  hints?: Partial<ToolHints>;
19
+ /** spec/policy.md §1: the session environments the rule holds in (a session's `environment`). */
20
+ environment?: string[];
16
21
  }
22
+ /** spec/api.md: a session's environment, e.g. `production`, `staging`, `dev`. */
23
+ export declare const ENVIRONMENT: RegExp;
17
24
  /** MCP tool annotations as booleans, with the spec's defaults for what a server leaves out. */
18
25
  export interface ToolHints {
19
26
  read_only: boolean;
@@ -28,6 +35,13 @@ export interface ToolHints {
28
35
  * An unknown tool gets the defaults too. Hints are the server's word, not proof.
29
36
  */
30
37
  export declare function mcpToolHints(annotations?: unknown): ToolHints;
38
+ /**
39
+ * spec/policy.md §1a: a tool definition's identity, `sha256:<hex>` of the canonical JSON of its
40
+ * name, title, description, schemas and annotations (the fields present). A changed description
41
+ * or schema changes it: what the agent was shown is no longer what was approved. `null` for
42
+ * something that isn't a named tool.
43
+ */
44
+ export declare function toolDefinitionHash(tool: unknown): string | null;
31
45
  export interface Policy {
32
46
  version: 1;
33
47
  rules: Rule[];
@@ -36,7 +50,7 @@ export interface Policy {
36
50
  }
37
51
  export interface Decision {
38
52
  rule: string;
39
- action: "deny" | "allow" | "require_approval";
53
+ action: "deny" | "allow" | "require_approval" | "require_proof";
40
54
  reason?: string;
41
55
  }
42
56
  interface Compiled {
@@ -47,12 +61,15 @@ interface Compiled {
47
61
  /** Parses a policy file; throws on anything invalid (the server refuses to start). */
48
62
  export declare function loadPolicy(bytes: Uint8Array): Policy;
49
63
  export declare function compilePolicy(policy: Policy): Compiled[];
64
+ /** spec/api.md: the session's environment, from its `session.open` (the first line). */
65
+ export declare function environmentOf(lines: readonly string[]): string | undefined;
50
66
  /** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool
51
- * has two). `hints`: an MCP tool's annotations; a rule with `hints` never matches a call without. */
52
- export declare function decide(compiled: readonly Compiled[], names: readonly string[], args: unknown, hints?: ToolHints): Decision | null;
67
+ * has two). `hints`: an MCP tool's annotations; a rule with `hints` never matches a call without.
68
+ * `environment`: the session's; a rule with `environment` never matches a session without one. */
69
+ export declare function decide(compiled: readonly Compiled[], names: readonly string[], args: unknown, hints?: ToolHints, environment?: string): Decision | null;
53
70
  export type CompiledPolicy = ReturnType<typeof compilePolicy>;
54
71
  /** N3 (idea C5): every audit rule this call matches, and what it would do if it were enforced. */
55
- export declare function audited(compiled: readonly Compiled[], names: readonly string[], args: unknown, hints?: ToolHints): Array<{
72
+ export declare function audited(compiled: readonly Compiled[], names: readonly string[], args: unknown, hints?: ToolHints, environment?: string): Array<{
56
73
  rule: string;
57
74
  would: Rule["action"];
58
75
  }>;
@@ -1,7 +1,11 @@
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}$/;
5
9
  const HINTS = ["read_only", "destructive", "idempotent", "open_world"];
6
10
  /**
7
11
  * spec/policy.md §1: a tool's annotations (MCP `readOnlyHint`, `destructiveHint`, `idempotentHint`,
@@ -19,6 +23,33 @@ export function mcpToolHints(annotations) {
19
23
  open_world: a.openWorldHint !== false,
20
24
  };
21
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
+ }
22
53
  const hintsMatch = (want, have) => want === undefined ||
23
54
  (have !== undefined && Object.entries(want).every(([k, v]) => have[k] === v));
24
55
  const MAX_ARGS = 64 * 1024;
@@ -36,8 +67,13 @@ export function loadPolicy(bytes) {
36
67
  throw new Error(`policy: rule id "${r.id}" must match ^[a-z0-9-]{1,64}$`);
37
68
  if (typeof r.tool !== "string" || r.tool === "")
38
69
  throw new Error(`policy ${r.id}: tool is required`);
39
- if (r.action !== "deny" && r.action !== "allow" && r.action !== "require_approval")
40
- 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`);
41
77
  if (r.args_match !== undefined) {
42
78
  if (typeof r.args_match !== "string" || r.args_match.length > 200)
43
79
  throw new Error(`policy ${r.id}: args_match must be a regular expression of ≤ 200 characters`);
@@ -54,6 +90,12 @@ export function loadPolicy(bytes) {
54
90
  !Object.entries(h).every(([k, v]) => HINTS.includes(k) && typeof v === "boolean"))
55
91
  throw new Error(`policy ${r.id}: hints must set some of read_only, destructive, idempotent, open_world to true or false`);
56
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}$)`);
57
99
  }
58
100
  const ids = doc.rules.map((r) => r.id);
59
101
  const twice = ids.find((id, i) => ids.indexOf(id) !== i);
@@ -76,7 +118,10 @@ function checkShadowed(rules) {
76
118
  (e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case)) &&
77
119
  (e.hints === undefined ||
78
120
  (later.hints !== undefined &&
79
- Object.entries(e.hints).every(([k, v]) => later.hints[k] === v))));
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)))));
80
125
  if (first && first.action !== later.action)
81
126
  throw new Error(`policy ${later.id}: never applies: ${first.id} matches the same calls first (${first.action})`);
82
127
  });
@@ -90,15 +135,30 @@ export function compilePolicy(policy) {
90
135
  : new RegExp(rule.args_match, rule.ignore_case ? "i" : ""),
91
136
  }));
92
137
  }
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));
93
150
  /** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool
94
- * has two). `hints`: an MCP tool's annotations; a rule with `hints` never matches a call without. */
95
- export function decide(compiled, names, args, hints) {
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) {
96
154
  let text = null;
97
155
  for (const c of compiled) {
98
156
  if (c.rule.audit === true || !names.some((n) => c.tool.test(n)))
99
157
  continue;
100
158
  if (!hintsMatch(c.rule.hints, hints))
101
159
  continue;
160
+ if (!environmentMatch(c.rule, environment))
161
+ continue;
102
162
  if (c.args) {
103
163
  text ??= canonical(args ?? null).slice(0, MAX_ARGS);
104
164
  if (!c.args.test(text))
@@ -113,11 +173,12 @@ export function decide(compiled, names, args, hints) {
113
173
  return null;
114
174
  }
115
175
  /** N3 (idea C5): every audit rule this call matches, and what it would do if it were enforced. */
116
- export function audited(compiled, names, args, hints) {
176
+ export function audited(compiled, names, args, hints, environment) {
117
177
  const text = canonical(args ?? null).slice(0, MAX_ARGS);
118
178
  return compiled
119
179
  .filter((c) => c.rule.audit === true && names.some((n) => c.tool.test(n)))
120
180
  .filter((c) => hintsMatch(c.rule.hints, hints))
181
+ .filter((c) => environmentMatch(c.rule, environment))
121
182
  .filter((c) => !c.args || c.args.test(text))
122
183
  .map((c) => ({ rule: c.rule.id, would: c.rule.action }));
123
184
  }
@@ -131,6 +192,86 @@ function auditFinding(rule, would, at, tool) {
131
192
  detail: `Policy ${rule} (audit only) would ${would === "deny" ? "have refused" : "have held"} ${String(tool ?? "a tool call")}.`,
132
193
  };
133
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
+ }
134
275
  /**
135
276
  * spec/policy.md §2 as findings: POLICY_DENIED for MCP calls the gateway refused (tool.call meta.policy),
136
277
  * POLICY_VIOLATION for tools a model response asked for that a deny rule matches.
@@ -153,12 +294,14 @@ export function policyFindings(lines, bodies, compiled) {
153
294
  if (typeof a.rule === "string" && a.would !== "allow")
154
295
  out.push(auditFinding(a.rule, String(a.would), { seq: e.seq }, e.meta.tool));
155
296
  }
297
+ out.push(...unproven(lines, bodies, compiled));
156
298
  const calls = [...callsOf(lines, bodies)].sort((a, b) => a.endSeq - b.endSeq);
299
+ const environment = environmentOf(lines);
157
300
  const violation = (tool, id, args) => {
158
- for (const a of audited(compiled, [tool], args))
301
+ for (const a of audited(compiled, [tool], args, undefined, environment))
159
302
  if (a.would !== "allow")
160
303
  out.push(auditFinding(a.rule, a.would, { tool_use_id: id }, tool));
161
- const d = decide(compiled, [tool], args);
304
+ const d = decide(compiled, [tool], args, undefined, environment);
162
305
  if (d?.action === "deny")
163
306
  out.push({
164
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 {};