@zanii/blackbox 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -45,6 +45,7 @@ const USAGE = `usage (spec/cli.md):
45
45
  blackbox mcp (an MCP server on stdio: spec/mcp-server.md)
46
46
  blackbox mcp-wrap --server <name> -- <command> [args...]
47
47
  blackbox reconcile --harness claude-code|codex --bundle <bundle.json> --bodies <dir> [--agent-session <id>]... [--require-receipts] <path>
48
+ blackbox reconcile-system --connector <connector.json> --log <export.csv|jsonl> [--pack <pack.json>] <session_id>
48
49
  blackbox workspace-receipt (a Claude Code PreToolUse / PostToolUse hook; reads the hook JSON on stdin)
49
50
  blackbox attest (a Claude Code PostToolUse hook: spec/attestation.md §4)`;
50
51
  async function main(argv) {
@@ -55,6 +56,8 @@ async function main(argv) {
55
56
  return wrap(rest);
56
57
  if (command === "reconcile")
57
58
  return reconcileCommand(rest);
59
+ if (command === "reconcile-system")
60
+ return reconcileSystemCommand(rest);
58
61
  if (command === "workspace-receipt")
59
62
  return receiptCommand();
60
63
  if (command === "attest")
@@ -1007,6 +1010,41 @@ async function drainCommand(rest) {
1007
1010
  process.stdout.write(`${JSON.stringify({ ok: results.every((r) => r.shipped), sessions: results })}\n`);
1008
1011
  return results.every((r) => r.shipped) ? 0 : 2;
1009
1012
  }
1013
+ /** spec/data.md §10: the session's record against a touched system's own audit-log export, run
1014
+ * here so the export never leaves this machine. Exits 1 on any finding. */
1015
+ async function reconcileSystemCommand(rest) {
1016
+ const { flags, positional } = parse(rest, []);
1017
+ const id = positional[0] ?? "";
1018
+ if (positional.length !== 1 || !SESSION_ID.test(id) || !flags.connector || !flags.log)
1019
+ return usage();
1020
+ const { checkAuditConnector, parseAuditLog, reconcileSystem, CORE_EVIDENCE } = await import("./data/index.js");
1021
+ const { packData } = await import("./packs/index.js");
1022
+ const connector = JSON.parse(readFileSync(flags.connector, "utf8"));
1023
+ const bad = checkAuditConnector(connector);
1024
+ if (bad)
1025
+ throw new ServerError(`--connector: ${bad}`);
1026
+ const evidence = flags.pack
1027
+ ? packData([JSON.parse(readFileSync(flags.pack, "utf8"))]).evidence
1028
+ : CORE_EVIDENCE;
1029
+ const c = connector;
1030
+ const rows = parseAuditLog(readFileSync(flags.log, "utf8"), c);
1031
+ const bundle = (await adminJson(`/v1/sessions/${id}/bundle`));
1032
+ // Only the bodies the comparison reads: tool calls and results, and SDK events.
1033
+ const bodies = new Map();
1034
+ for (const line of bundle.lines) {
1035
+ const e = JSON.parse(line);
1036
+ if (!["tool.call", "tool.result", "sdk.event"].includes(e.kind) || bodies.has(e.body_hash))
1037
+ continue;
1038
+ if (!/^sha256:[0-9a-f]{64}$/.test(e.body_hash))
1039
+ continue;
1040
+ const res = await admin(`/v1/bodies/${e.body_hash}`, undefined, [404, 410]);
1041
+ if (res.status === 200)
1042
+ bodies.set(e.body_hash, Buffer.from(await res.arrayBuffer()));
1043
+ }
1044
+ const report = reconcileSystem(bundle.lines, (h) => bodies.get(h), rows, c, evidence);
1045
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1046
+ return report.findings.length > 0 ? 1 : 0;
1047
+ }
1010
1048
  function usage() {
1011
1049
  process.stderr.write(`${USAGE}\n`);
1012
1050
  return 2;
@@ -0,0 +1,35 @@
1
+ type Evidence = Record<string, number | string | boolean | null | string[] | Record<string, number>>;
2
+ export interface Art12Check {
3
+ id: string;
4
+ article: string;
5
+ requirement: string;
6
+ /** False: the paragraph covers only Annex III point 1(a) systems (remote biometric identification). */
7
+ applies: boolean;
8
+ /** True or false where the record can show it; null where a person must judge the evidence. */
9
+ ok: boolean | null;
10
+ evidence: Evidence;
11
+ }
12
+ export interface Art12Report {
13
+ session_id: string;
14
+ regulation: "Regulation (EU) 2024/1689";
15
+ annex_iii_1a: boolean;
16
+ /** Every applicable check that can pass, passes. */
17
+ ok: boolean;
18
+ checks: Art12Check[];
19
+ notes: string;
20
+ }
21
+ /** Art. 19(1) and 26(6): "at least six months". Counted as 183 days, the longest six months. */
22
+ export declare const ART12_MIN_RETENTION_DAYS = 183;
23
+ /**
24
+ * Checks one session's record against Article 12. `verified`: whether its chain verifies (the caller
25
+ * runs the check). `retentionDays`: the deployment's retention, null when records are kept forever.
26
+ * `annexIII1a`: the system is a remote biometric identification system, so 12(3) applies.
27
+ */
28
+ export declare function art12Check(record: {
29
+ lines: readonly string[];
30
+ verified: boolean;
31
+ }, options: {
32
+ retentionDays: number | null;
33
+ annexIII1a?: boolean;
34
+ }): Art12Report;
35
+ export {};
@@ -0,0 +1,163 @@
1
+ // EU AI Act Article 12 (record-keeping) for one session's record (spec/art12.md): what the record
2
+ // shows against Art. 12(1), 12(2)(a)-(c), 12(3)(a)-(d), and the six-month log retention of Art. 19(1)
3
+ // and 26(6). Evidence mapping from the public text of Regulation (EU) 2024/1689, not legal advice.
4
+ // Mirrors sdks/python/src/zanii_blackbox/art12.py.
5
+ /** Art. 19(1) and 26(6): "at least six months". Counted as 183 days, the longest six months. */
6
+ export const ART12_MIN_RETENTION_DAYS = 183;
7
+ const NOT_PEOPLE = new Set(["timeout", "unattended", "classifier"]);
8
+ const NOTES = "Evidence mapping from the public text of Regulation (EU) 2024/1689, not legal advice. Article 12 applies to high-risk AI systems; whether a system is one, and whether it complies, is for its provider, deployer and counsel to judge. One session shows one period of use, not the system's lifetime.";
9
+ const sorted = (s) => [...new Set(s)].sort();
10
+ const count = (into, key) => {
11
+ into[key] = (into[key] ?? 0) + 1;
12
+ };
13
+ /**
14
+ * Checks one session's record against Article 12. `verified`: whether its chain verifies (the caller
15
+ * runs the check). `retentionDays`: the deployment's retention, null when records are kept forever.
16
+ * `annexIII1a`: the system is a remote biometric identification system, so 12(3) applies.
17
+ */
18
+ export function art12Check(record, options) {
19
+ const events = record.lines.map((l) => JSON.parse(l));
20
+ const annex = options.annexIII1a === true;
21
+ let gaps = 0;
22
+ let lostContact = 0;
23
+ let errors = 0;
24
+ let inputs = 0;
25
+ let outcome = null;
26
+ const findings = {};
27
+ const codes = [];
28
+ const controls = {};
29
+ const models = [];
30
+ const providers = [];
31
+ const sources = [];
32
+ const deciders = [];
33
+ let decisions = 0;
34
+ for (const e of events) {
35
+ const m = e.meta;
36
+ if (e.kind === "capture.gap")
37
+ gaps += typeof m.missed === "number" ? m.missed : 1;
38
+ else if (e.kind === "session.lost_contact")
39
+ lostContact++;
40
+ else if (e.kind === "finding") {
41
+ count(findings, typeof m.severity === "string" ? m.severity : "unknown");
42
+ if (typeof m.code === "string")
43
+ codes.push(m.code);
44
+ }
45
+ else if (e.kind === "control" && typeof m.action === "string") {
46
+ count(controls, m.action);
47
+ if (m.action === "approval" && typeof m.by === "string") {
48
+ decisions++;
49
+ deciders.push(m.by);
50
+ }
51
+ }
52
+ else if (e.kind === "llm.request") {
53
+ inputs++;
54
+ if (typeof m.provider === "string")
55
+ providers.push(m.provider);
56
+ }
57
+ else if (e.kind === "llm.response") {
58
+ if (typeof m.model === "string")
59
+ models.push(m.model);
60
+ if (typeof m.status === "number" && m.status >= 400)
61
+ errors++;
62
+ }
63
+ else if (e.kind === "tool.call") {
64
+ inputs++;
65
+ if (typeof m.server === "string")
66
+ sources.push(typeof m.tool === "string" ? `${m.server}/${m.tool}` : m.server);
67
+ }
68
+ else if (e.kind === "outcome" && typeof m.outcome === "string")
69
+ outcome = m.outcome;
70
+ }
71
+ const open = events.find((e) => e.kind === "session.open");
72
+ const close = events.find((e) => e.kind === "session.close");
73
+ const people = sorted(deciders.filter((d) => !NOT_PEOPLE.has(d)));
74
+ const retention = options.retentionDays;
75
+ const checks = [
76
+ {
77
+ id: "automatic-recording",
78
+ article: "12(1)",
79
+ requirement: "Events are recorded automatically over the system's lifetime: here, a record that verifies and has no capture gaps.",
80
+ applies: true,
81
+ ok: record.verified && open !== undefined && gaps === 0,
82
+ evidence: {
83
+ events: events.length,
84
+ verified: record.verified,
85
+ capture_gaps: gaps,
86
+ lost_contact: lostContact,
87
+ },
88
+ },
89
+ {
90
+ id: "risk-situations",
91
+ article: "12(2)(a)",
92
+ requirement: "Events relevant to identifying situations that may present a risk (Art. 79(1)) or a substantial modification.",
93
+ applies: true,
94
+ ok: null,
95
+ evidence: { findings, finding_codes: sorted(codes), models: sorted(models) },
96
+ },
97
+ {
98
+ id: "post-market-monitoring",
99
+ article: "12(2)(b)",
100
+ requirement: "Events that facilitate post-market monitoring (Art. 72).",
101
+ applies: true,
102
+ ok: null,
103
+ evidence: { providers: sorted(providers), models: sorted(models), errors, outcome },
104
+ },
105
+ {
106
+ id: "operation-monitoring",
107
+ article: "12(2)(c)",
108
+ requirement: "Events for deployers' monitoring of the system's operation (Art. 26(5)).",
109
+ applies: true,
110
+ ok: null,
111
+ evidence: { controls, unattended: open?.meta.unattended === true },
112
+ },
113
+ {
114
+ id: "period-of-use",
115
+ article: "12(3)(a)",
116
+ requirement: "The start and end date and time of each use.",
117
+ applies: annex,
118
+ ok: open !== undefined && close !== undefined,
119
+ evidence: { start: open?.ts ?? null, end: close?.ts ?? null },
120
+ },
121
+ {
122
+ id: "reference-database",
123
+ article: "12(3)(b)",
124
+ requirement: "The reference database against which input data was checked.",
125
+ applies: annex,
126
+ ok: null,
127
+ evidence: { sources: sorted(sources) },
128
+ },
129
+ {
130
+ id: "input-data",
131
+ article: "12(3)(c)",
132
+ requirement: "The input data for which the search led to a match.",
133
+ applies: annex,
134
+ ok: null,
135
+ evidence: { inputs },
136
+ },
137
+ {
138
+ id: "verifying-persons",
139
+ article: "12(3)(d), 14(5)",
140
+ requirement: "The natural persons who verified the results; for Art. 14(5), at least two, separately.",
141
+ applies: annex,
142
+ // ponytail: decisions name a role (admin, tenant), not a person; false until SSO subjects are recorded
143
+ ok: decisions === 0 ? null : false,
144
+ evidence: { decisions, deciders: people, persons_identified: false },
145
+ },
146
+ {
147
+ id: "log-retention",
148
+ article: "19(1), 26(6)",
149
+ requirement: "Logs are kept for at least six months, unless other Union or national law says otherwise.",
150
+ applies: true,
151
+ ok: retention === null || retention >= ART12_MIN_RETENTION_DAYS,
152
+ evidence: { retention_days: retention, minimum_days: ART12_MIN_RETENTION_DAYS },
153
+ },
154
+ ];
155
+ return {
156
+ session_id: events[0]?.session_id ?? "",
157
+ regulation: "Regulation (EU) 2024/1689",
158
+ annex_iii_1a: annex,
159
+ ok: checks.every((c) => !c.applies || c.ok !== false),
160
+ checks,
161
+ notes: NOTES,
162
+ };
163
+ }
@@ -0,0 +1,191 @@
1
+ export interface Identifier {
2
+ id: string;
3
+ title: string;
4
+ pattern: string;
5
+ normalize: "digits" | "lower" | "upper" | "exact";
6
+ keep_last?: number;
7
+ check?: "luhn";
8
+ personal: boolean;
9
+ }
10
+ /** spec/data/identifiers-v1.json: always on. */
11
+ export declare const CORE_IDENTIFIERS: readonly Identifier[];
12
+ /** spec/data/core-v1.json: evidence defaults for common writes (§5). */
13
+ export declare const CORE_EVIDENCE: ReadonlyArray<{
14
+ tool: string;
15
+ field: string;
16
+ }>;
17
+ /**
18
+ * §6: the canary values as one identifier, matched literally. Longer values first, so a value that
19
+ * contains another wins. ASCII punctuation is escaped the same way both languages read it.
20
+ */
21
+ export declare function canaryIdentifier(values: readonly string[]): Identifier;
22
+ export declare function normalizeValue(value: string, how: Identifier["normalize"]): string;
23
+ export declare function luhn(digits: string): boolean;
24
+ export interface Found {
25
+ id: string;
26
+ value: string;
27
+ }
28
+ /** §1: each distinct (id, normalized value) in text order, at most 200. Earlier identifiers win a span. */
29
+ export declare function dataScan(text: string, identifiers: readonly Identifier[]): Found[];
30
+ /** Every match counted (not only distinct ones), for `data.classes`. */
31
+ export declare function dataClasses(text: string, identifiers: readonly Identifier[]): Record<string, number>;
32
+ /** §2: the tenant's fingerprint of one identifier value. */
33
+ export declare function dataFingerprint(key: Uint8Array | string, tenant: string, id: string, value: string): string;
34
+ /** §2: the `data` meta of a body, or undefined when it holds no identifier. */
35
+ export declare function dataMeta(text: string, identifiers: readonly Identifier[], key: Uint8Array | string, tenant: string): {
36
+ classes: Record<string, number>;
37
+ fps: Array<{
38
+ id: string;
39
+ fp: string;
40
+ }>;
41
+ } | undefined;
42
+ export interface Lineage {
43
+ subjects: Array<{
44
+ fp: string;
45
+ id: string;
46
+ first: {
47
+ seq: number;
48
+ kind: string;
49
+ from: string;
50
+ };
51
+ sent: Array<{
52
+ seq: number;
53
+ to: string;
54
+ region?: string;
55
+ }>;
56
+ }>;
57
+ destinations: Array<{
58
+ to: string;
59
+ region?: string;
60
+ classes: Record<string, number>;
61
+ subjects: number;
62
+ }>;
63
+ }
64
+ /** §3: where each subject's data came from and went in one session. */
65
+ export declare function lineage(lines: readonly string[]): Lineage;
66
+ export interface Topology {
67
+ nodes: Array<{
68
+ id: string;
69
+ kind: string;
70
+ region?: string;
71
+ sessions: number;
72
+ }>;
73
+ edges: Array<{
74
+ from: string;
75
+ to: string;
76
+ region?: string;
77
+ classes: Record<string, number>;
78
+ subjects: number;
79
+ sessions: number;
80
+ flags: Array<"outside_region" | "region_unknown">;
81
+ }>;
82
+ }
83
+ /**
84
+ * §3a: many sessions' lineage as one map: where data is read from (nodes on the left) and sent to,
85
+ * with how many subjects and sessions each flow carried. With `dataRegion`, a flow to a model in
86
+ * another region is flagged `outside_region`, one to a model of no known region `region_unknown`.
87
+ */
88
+ export declare function dataTopology(sessions: ReadonlyArray<{
89
+ session_id: string;
90
+ lines: readonly string[];
91
+ }>, dataRegion?: string | null): Topology;
92
+ export interface DataRules {
93
+ identifiers: readonly Identifier[];
94
+ residency: boolean;
95
+ restricted: ReadonlyArray<{
96
+ identifiers: readonly string[];
97
+ allow: readonly string[];
98
+ }>;
99
+ evidence: ReadonlyArray<{
100
+ tool: string;
101
+ field: string;
102
+ }>;
103
+ }
104
+ export interface DataFinding {
105
+ code: "DATA_LEFT_REGION" | "DATA_REGION_UNKNOWN" | "DATA_TO_UNAPPROVED_DESTINATION" | "DATA_CANARY_TRIPPED" | "CONSENT_WITHDRAWN_USE" | "EGRESS_OPEN" | "TOOL_WITNESS_INVALID" | "UNEVIDENCED_SIDE_EFFECT";
106
+ severity: "advisory" | "caution" | "warning";
107
+ ref: Record<string, string | number>;
108
+ }
109
+ /** §4, §6, §7, §9: findings over a session's events. */
110
+ export declare function dataFindings(lines: readonly string[], rules: Pick<DataRules, "identifiers" | "residency" | "restricted">, dataRegion: string | null): DataFinding[];
111
+ export declare const hasEvidence: (value: unknown, field: string) => boolean;
112
+ /** §5: successful results of state-changing tools with no evidence of their own. */
113
+ export declare function unevidenced(lines: readonly string[], body: (hash: string) => Uint8Array | undefined, rules: DataRules["evidence"]): DataFinding[];
114
+ /** §9: a tool signs what it returns, so the result is the tool's word, not the agent's. */
115
+ export declare function toolWitness(keypair: {
116
+ did?: string;
117
+ privateKey: Uint8Array;
118
+ }): {
119
+ did: string;
120
+ sign(result: unknown): {
121
+ result: unknown;
122
+ witness: {
123
+ did: string;
124
+ sig: string;
125
+ };
126
+ };
127
+ };
128
+ /** §9: does `witness` sign `result`? */
129
+ export declare function verifyToolWitness(result: unknown, witness: {
130
+ did?: unknown;
131
+ sig?: unknown;
132
+ }): boolean;
133
+ /** §9: the witness an MCP result or an SDK tool.result body carries, checked; undefined if none. */
134
+ export declare function witnessOf(bodyJson: unknown): {
135
+ did: string;
136
+ ok: boolean;
137
+ } | undefined;
138
+ export interface Connector {
139
+ v: 1;
140
+ id: string;
141
+ format: "csv" | "jsonl";
142
+ fields: {
143
+ at: string;
144
+ action: string;
145
+ object?: string;
146
+ evidence?: string;
147
+ amount?: string;
148
+ actor?: string;
149
+ };
150
+ actions: Record<string, string>;
151
+ /** Which tool arguments hold the object and the amount the call acted on. */
152
+ arguments?: {
153
+ object?: string;
154
+ amount?: string;
155
+ };
156
+ window_seconds?: number;
157
+ }
158
+ export interface AuditRow {
159
+ row: number;
160
+ at: number;
161
+ action: string;
162
+ object?: string;
163
+ evidence?: string;
164
+ amount?: string;
165
+ actor?: string;
166
+ }
167
+ /** §10: why a connector is invalid, or undefined. */
168
+ export declare function checkAuditConnector(value: unknown): string | undefined;
169
+ /** RFC 4180: a header row, then rows; quoted fields may hold commas, quotes ("") and newlines. */
170
+ export declare function parseCsv(text: string): string[][];
171
+ /** §10: the rows of an audit-log export, in file order; rows without a time or action are skipped. */
172
+ export declare function parseAuditLog(text: string, connector: Connector): AuditRow[];
173
+ export interface SystemReport {
174
+ connector: string;
175
+ calls: number;
176
+ rows: number;
177
+ matched: Array<{
178
+ seq: number;
179
+ row: number;
180
+ by: "evidence" | "action";
181
+ }>;
182
+ findings: Array<{
183
+ code: "AGENT_ACTION_NOT_IN_SYSTEM" | "SYSTEM_CHANGE_NOT_IN_RECORD" | "SYSTEM_RECORD_MISMATCH";
184
+ ref: {
185
+ seq?: number;
186
+ row?: number;
187
+ };
188
+ }>;
189
+ }
190
+ /** §10: the session's successful mapped tool calls against the system's own log. */
191
+ export declare function reconcileSystem(lines: readonly string[], body: (hash: string) => Uint8Array | undefined, rows: readonly AuditRow[], connector: Connector, evidence: DataRules["evidence"]): SystemReport;