@zanii/blackbox 0.9.0 → 0.11.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 CHANGED
@@ -83,6 +83,19 @@ verifyAnswerCredential(credential, lines, bodies, packs); // { ok, problems }: t
83
83
 
84
84
  The same checks run offline as pure functions: `toolHallucinations`, `groundingFindings`, `factsIn`, `loadReferencePacks`, `answerRisk`, `detectorAccuracy`.
85
85
 
86
+ ## New in 0.11.0
87
+
88
+ - Facts against the record compare currencies and units: `AED 500` in a tool result no longer grounds `USD 500` in the answer, nor `500 g` grounds `500 mg` (`currencyCodes`, `unitOf`). Dates written with an English or Arabic month (`3 March 2026`, `3 مارس 2026`) are facts too.
89
+ - `blackbox policy-hook`: a Claude Code / Codex PreToolUse hook that asks the gateway's policy before a local tool runs, and fails closed.
90
+ - Works with gateway v0.13 (`POST /v1/sessions/:id/policy-check`).
91
+
92
+ ## New in 0.10.0
93
+
94
+ - `setTenantPolicy` / `tenantPolicy` / `removeTenantPolicy`: a tenant's own rules, hot on the gateway. `setProviderKey` / `providerKeys` / `removeProviderKey`: a tenant's own model keys, sealed. `cancelRun(sessionId, runId)`: stop one run (calls carrying `x-blackbox-run`).
95
+ - `loadPolicy` refuses an `args_match` that JavaScript and Python would read differently (`portableRegexError`).
96
+ - `blackbox sessions --outcome success|failure|abandoned`.
97
+ - Works with gateway v0.12: alert routing to PagerDuty, Teams, Alertmanager and Slack; the key passphrase from Vault or AWS KMS.
98
+
86
99
  ## New in 0.9.0
87
100
 
88
101
  - `client(...).createKey(tenantId, {scope: "approve", expires_in_days})`: a key that only decides approvals (and labels findings), and keys that expire.
@@ -10,7 +10,14 @@ export interface Fact {
10
10
  at: number;
11
11
  end: number;
12
12
  currency?: string;
13
+ /** Quantities: the canonical unit (spec/findings.md §9). */
14
+ unit?: string;
13
15
  }
16
+ /** spec/findings.md §9: the ISO codes a written currency may be (a symbol or a word names a
17
+ * family: "$" is any dollar). */
18
+ export declare function currencyCodes(written: string): string[];
19
+ /** spec/findings.md §9: a written unit's canonical unit. */
20
+ export declare const unitOf: (written: string) => string;
14
21
  /** The facts an answer states: money, percentages, quantities, ISO dates, then identifiers (as H1's). */
15
22
  export declare function factsIn(text: string): Fact[];
16
23
  /** What the agent was given, to check facts against: the record lines it came from (§11 points at
@@ -30,7 +37,9 @@ export declare class Evidence {
30
37
  /**
31
38
  * The seqs of the lines that hold a fact, or null. Money, percentages and quantities: the same
32
39
  * number, the same in minor units, a percentage as a fraction, or (money) the sum of two of the
33
- * evidence's numbers. Dates and identifiers: the normalised text.
40
+ * evidence's numbers. A number the evidence writes only with another currency or unit doesn't
41
+ * hold it (AED 500 isn't USD 500; 500 g isn't 500 mg); one it writes bare does. Dates and
42
+ * identifiers: the normalised text (a date in words as its ISO form).
34
43
  */
35
44
  support(f: Fact): number[] | null;
36
45
  private pairSums;
@@ -19,9 +19,291 @@ const MONEY = new RegExp(`(?<![A-Za-z])(${CURRENCY})\\s?(${AMOUNT})(?![0-9])|(?<
19
19
  const PERCENT = new RegExp(`(?<![0-9.,])(${AMOUNT})\\s?(?:%|٪|percent|per cent|بالمائة|في المائة)`, "gi");
20
20
  // any measured quantity: a dose, a weight, a distance, a duration ("within 14 days")
21
21
  const UNIT = "mg/dL|mmol/L|mcg|µg|mg|kg|g|mL|ml|L|km|cm|mm|m|mi|ft|lbs|lb|oz|°C|°F|kWh|MWh|kW|MW|IU|hours|hour|hrs|hr|h|minutes|minute|mins|min|seconds|second|secs|days|day|weeks|week|months|month|years|year|units|unit|items|pieces|tablets|doses|ملغ|مجم|غرام|كغ|كيلوغرام|مل|لتر|كم|متر|ساعة|ساعات|دقيقة|دقائق|يوم|أيام|أسبوع|أسابيع|شهر|أشهر|سنة|سنوات|وحدة|حبة|جرعة";
22
- const QUANTITY = new RegExp(`(?<![0-9.,])(${AMOUNT})\\s?(?:${UNIT})(?![A-Za-z\u0600-\u06FF])`, "g");
22
+ const QUANTITY = new RegExp(`(?<![0-9.,])(${AMOUNT})\\s?(${UNIT})(?![A-Za-z\u0600-\u06FF])`, "g");
23
23
  const ISO_DATE = /(?<![0-9])[0-9]{4}-[0-9]{2}-[0-9]{2}(?![0-9])/g;
24
24
  const NUMBER = new RegExp(`(?<![0-9.,])(?:${AMOUNT})(?![0-9])`, "g");
25
+ // spec/grounding-tables.json, inline (both SDKs carry the same tables)
26
+ const FAMILIES = {
27
+ $: [
28
+ "USD",
29
+ "CAD",
30
+ "AUD",
31
+ "NZD",
32
+ "SGD",
33
+ "HKD",
34
+ "TWD",
35
+ "JMD",
36
+ "BSD",
37
+ "BBD",
38
+ "BZD",
39
+ "BMD",
40
+ "BND",
41
+ "FJD",
42
+ "GYD",
43
+ "KYD",
44
+ "LRD",
45
+ "NAD",
46
+ "SBD",
47
+ "SRD",
48
+ "TTD",
49
+ "XCD",
50
+ ],
51
+ dollar: [
52
+ "USD",
53
+ "CAD",
54
+ "AUD",
55
+ "NZD",
56
+ "SGD",
57
+ "HKD",
58
+ "TWD",
59
+ "JMD",
60
+ "BSD",
61
+ "BBD",
62
+ "BZD",
63
+ "BMD",
64
+ "BND",
65
+ "FJD",
66
+ "GYD",
67
+ "KYD",
68
+ "LRD",
69
+ "NAD",
70
+ "SBD",
71
+ "SRD",
72
+ "TTD",
73
+ "XCD",
74
+ ],
75
+ دولار: [
76
+ "USD",
77
+ "CAD",
78
+ "AUD",
79
+ "NZD",
80
+ "SGD",
81
+ "HKD",
82
+ "TWD",
83
+ "JMD",
84
+ "BSD",
85
+ "BBD",
86
+ "BZD",
87
+ "BMD",
88
+ "BND",
89
+ "FJD",
90
+ "GYD",
91
+ "KYD",
92
+ "LRD",
93
+ "NAD",
94
+ "SBD",
95
+ "SRD",
96
+ "TTD",
97
+ "XCD",
98
+ ],
99
+ "€": ["EUR"],
100
+ euro: ["EUR"],
101
+ يورو: ["EUR"],
102
+ "£": ["GBP", "EGP", "LBP", "SYP", "SDG", "SSP", "GIP", "FKP", "SHP"],
103
+ pound: ["GBP", "EGP", "LBP", "SYP", "SDG", "SSP", "GIP", "FKP", "SHP"],
104
+ جنيه: ["GBP", "EGP", "LBP", "SYP", "SDG", "SSP", "GIP", "FKP", "SHP"],
105
+ "¥": ["JPY", "CNY"],
106
+ yen: ["JPY"],
107
+ yuan: ["CNY"],
108
+ "₹": ["INR"],
109
+ rupee: ["INR", "PKR", "LKR", "NPR", "MUR", "SCR"],
110
+ "₩": ["KRW"],
111
+ "₽": ["RUB"],
112
+ "₺": ["TRY"],
113
+ lira: ["TRY", "LBP", "SYP"],
114
+ "₪": ["ILS"],
115
+ "฿": ["THB"],
116
+ "₫": ["VND"],
117
+ "₦": ["NGN"],
118
+ naira: ["NGN"],
119
+ "₱": ["PHP"],
120
+ dirham: ["AED", "MAD"],
121
+ dh: ["AED", "MAD"],
122
+ dhs: ["AED", "MAD"],
123
+ "د.إ": ["AED"],
124
+ درهم: ["AED", "MAD"],
125
+ دراهم: ["AED", "MAD"],
126
+ riyal: ["SAR", "QAR", "OMR", "YER", "IRR"],
127
+ rial: ["SAR", "QAR", "OMR", "YER", "IRR"],
128
+ ريال: ["SAR", "QAR", "OMR", "YER", "IRR"],
129
+ dinar: ["KWD", "BHD", "JOD", "IQD", "LYD", "TND", "DZD", "RSD", "MKD"],
130
+ دينار: ["KWD", "BHD", "JOD", "IQD", "LYD", "TND", "DZD", "RSD", "MKD"],
131
+ franc: ["CHF", "XAF", "XOF", "XPF", "CDF", "BIF", "DJF", "GNF", "KMF", "RWF"],
132
+ peso: ["MXN", "ARS", "CLP", "COP", "CUP", "DOP", "PHP", "UYU"],
133
+ ringgit: ["MYR"],
134
+ rand: ["ZAR"],
135
+ shilling: ["KES", "UGX", "TZS", "SOS"],
136
+ taka: ["BDT"],
137
+ };
138
+ const UNITS = {
139
+ "mg/dl": "mg/dL",
140
+ "mmol/l": "mmol/L",
141
+ mcg: "mcg",
142
+ µg: "mcg",
143
+ mg: "mg",
144
+ ملغ: "mg",
145
+ مجم: "mg",
146
+ g: "g",
147
+ غرام: "g",
148
+ kg: "kg",
149
+ كغ: "kg",
150
+ كيلوغرام: "kg",
151
+ ml: "ml",
152
+ مل: "ml",
153
+ l: "l",
154
+ لتر: "l",
155
+ km: "km",
156
+ كم: "km",
157
+ cm: "cm",
158
+ mm: "mm",
159
+ m: "m",
160
+ متر: "m",
161
+ mi: "mi",
162
+ ft: "ft",
163
+ lb: "lb",
164
+ lbs: "lb",
165
+ oz: "oz",
166
+ "°c": "°C",
167
+ "°f": "°F",
168
+ kwh: "kWh",
169
+ mwh: "MWh",
170
+ kw: "kW",
171
+ mw: "MW",
172
+ iu: "IU",
173
+ hours: "hour",
174
+ hour: "hour",
175
+ hrs: "hour",
176
+ hr: "hour",
177
+ h: "hour",
178
+ ساعة: "hour",
179
+ ساعات: "hour",
180
+ minutes: "minute",
181
+ minute: "minute",
182
+ mins: "minute",
183
+ min: "minute",
184
+ دقيقة: "minute",
185
+ دقائق: "minute",
186
+ seconds: "second",
187
+ second: "second",
188
+ secs: "second",
189
+ days: "day",
190
+ day: "day",
191
+ يوم: "day",
192
+ أيام: "day",
193
+ weeks: "week",
194
+ week: "week",
195
+ أسبوع: "week",
196
+ أسابيع: "week",
197
+ months: "month",
198
+ month: "month",
199
+ شهر: "month",
200
+ أشهر: "month",
201
+ years: "year",
202
+ year: "year",
203
+ سنة: "year",
204
+ سنوات: "year",
205
+ units: "unit",
206
+ unit: "unit",
207
+ وحدة: "unit",
208
+ items: "item",
209
+ pieces: "piece",
210
+ tablets: "tablet",
211
+ حبة: "tablet",
212
+ doses: "dose",
213
+ جرعة: "dose",
214
+ };
215
+ const MONTHS = {
216
+ january: 1,
217
+ february: 2,
218
+ march: 3,
219
+ april: 4,
220
+ may: 5,
221
+ june: 6,
222
+ july: 7,
223
+ august: 8,
224
+ september: 9,
225
+ october: 10,
226
+ november: 11,
227
+ december: 12,
228
+ jan: 1,
229
+ feb: 2,
230
+ mar: 3,
231
+ apr: 4,
232
+ jun: 6,
233
+ jul: 7,
234
+ aug: 8,
235
+ sept: 9,
236
+ sep: 9,
237
+ oct: 10,
238
+ nov: 11,
239
+ dec: 12,
240
+ يناير: 1,
241
+ فبراير: 2,
242
+ مارس: 3,
243
+ أبريل: 4,
244
+ إبريل: 4,
245
+ ابريل: 4,
246
+ مايو: 5,
247
+ يونيو: 6,
248
+ يوليو: 7,
249
+ أغسطس: 8,
250
+ اغسطس: 8,
251
+ سبتمبر: 9,
252
+ أكتوبر: 10,
253
+ اكتوبر: 10,
254
+ نوفمبر: 11,
255
+ ديسمبر: 12,
256
+ "كانون الثاني": 1,
257
+ شباط: 2,
258
+ آذار: 3,
259
+ نيسان: 4,
260
+ أيار: 5,
261
+ حزيران: 6,
262
+ تموز: 7,
263
+ آب: 8,
264
+ أيلول: 9,
265
+ "تشرين الأول": 10,
266
+ "تشرين الثاني": 11,
267
+ "كانون الأول": 12,
268
+ };
269
+ /** spec/findings.md §9: the ISO codes a written currency may be (a symbol or a word names a
270
+ * family: "$" is any dollar). */
271
+ export function currencyCodes(written) {
272
+ if (/^[A-Z]{3}$/.test(written))
273
+ return [written];
274
+ const w = written.toLowerCase();
275
+ return FAMILIES[w] ?? FAMILIES[w.replace(/s$/, "")] ?? [written];
276
+ }
277
+ /** spec/findings.md §9: a written unit's canonical unit. */
278
+ export const unitOf = (written) => UNITS[written.toLowerCase()] ?? written;
279
+ const MONTH = Object.keys(MONTHS)
280
+ .sort((a, b) => b.length - a.length)
281
+ .join("|");
282
+ const DATE_DMY = new RegExp(`(?<![0-9])([0-9]{1,2})(?:st|nd|rd|th)?\\s+(${MONTH})\\.?,?\\s+([0-9]{4})(?![0-9])`, "gi");
283
+ const DATE_MDY = new RegExp(`(?<![A-Za-z])(${MONTH})\\.?\\s+([0-9]{1,2})(?:st|nd|rd|th)?,?\\s+([0-9]{4})(?![0-9])`, "gi");
284
+ /** Dates written in words (English or Arabic months), as ISO dates with their spans. */
285
+ function wordDates(s) {
286
+ const out = [];
287
+ const add = (d, m, y, at, end) => {
288
+ const month = MONTHS[m.toLowerCase()];
289
+ const day = Number(d);
290
+ const date = new Date(Date.UTC(Number(y), (month ?? 0) - 1, day));
291
+ if (!month || date.getUTCDate() !== day || date.getUTCMonth() !== month - 1)
292
+ return;
293
+ if (out.some((o) => at < o.end && end > o.at))
294
+ return;
295
+ out.push({ iso: `${y}-${String(month).padStart(2, "0")}-${d.padStart(2, "0")}`, at, end });
296
+ };
297
+ for (const m of s.matchAll(DATE_DMY)) {
298
+ const at = m.index ?? 0;
299
+ add(m[1], m[2], m[3], at, at + m[0].length);
300
+ }
301
+ for (const m of s.matchAll(DATE_MDY)) {
302
+ const at = m.index ?? 0;
303
+ add(m[2], m[1], m[3], at, at + m[0].length);
304
+ }
305
+ return out.sort((a, b) => a.at - b.at);
306
+ }
25
307
  /** A canonical decimal as an integer of millionths, exactly (sums without floats). */
26
308
  function micros(c) {
27
309
  const [w = "0", f = ""] = c.split(".");
@@ -58,12 +340,20 @@ export function factsIn(text) {
58
340
  }
59
341
  for (const m of s.matchAll(QUANTITY)) {
60
342
  const at = m.index ?? 0;
61
- add({ kind: "quantity", value: canonicalNumber(m[1]), at, end: at + m[0].length });
343
+ add({
344
+ kind: "quantity",
345
+ value: canonicalNumber(m[1]),
346
+ at,
347
+ end: at + m[0].length,
348
+ unit: unitOf(m[2]),
349
+ });
62
350
  }
63
351
  for (const m of s.matchAll(ISO_DATE)) {
64
352
  const at = m.index ?? 0;
65
353
  add({ kind: "date", value: normaliseValue(m[0]), at, end: at + m[0].length });
66
354
  }
355
+ for (const d of wordDates(s))
356
+ add({ kind: "date", value: normaliseValue(d.iso), at: d.at, end: d.end });
67
357
  for (const x of identifierSpans(s, taken))
68
358
  out.push({ kind: x.kind, value: x.value, at: x.at, end: x.end });
69
359
  return out.sort((a, b) => a.at - b.at);
@@ -80,10 +370,32 @@ export class Evidence {
80
370
  this.raw = raw;
81
371
  this.pieces = (pieces ?? [{ seq: -1, text: raw }]).map((p) => {
82
372
  const s = westernText(p.text);
373
+ const tags = new Map();
374
+ let blank = s;
375
+ const tag = (value, t, at, end) => {
376
+ const set = tags.get(value) ?? new Set();
377
+ for (const x of t)
378
+ set.add(x);
379
+ tags.set(value, set);
380
+ blank = blank.slice(0, at) + " ".repeat(end - at) + blank.slice(end);
381
+ };
382
+ for (const m of s.matchAll(MONEY)) {
383
+ const at = m.index ?? 0;
384
+ const codes = currencyCodes((m[1] ?? m[4])).map((c) => `cur:${c}`);
385
+ tag(canonicalNumber((m[2] ?? m[3])), codes, at, at + m[0].length);
386
+ }
387
+ for (const m of s.matchAll(QUANTITY)) {
388
+ const at = m.index ?? 0;
389
+ tag(canonicalNumber(m[1]), [`unit:${unitOf(m[2])}`], at, at + m[0].length);
390
+ }
391
+ const numbersIn = (t) => new Set([...t.matchAll(NUMBER)].map((m) => canonicalNumber(m[0])));
83
392
  return {
84
393
  seq: p.seq,
85
- text: normaliseValue(s),
86
- numbers: new Set([...s.matchAll(NUMBER)].map((m) => canonicalNumber(m[0]))),
394
+ // dates in words count as their ISO form too
395
+ text: normaliseValue([s, ...wordDates(s).map((d) => d.iso)].join(" ")),
396
+ numbers: numbersIn(s),
397
+ bare: numbersIn(blank),
398
+ tags,
87
399
  };
88
400
  });
89
401
  this.numbers = new Set(this.pieces.flatMap((p) => [...p.numbers]));
@@ -95,7 +407,9 @@ export class Evidence {
95
407
  /**
96
408
  * The seqs of the lines that hold a fact, or null. Money, percentages and quantities: the same
97
409
  * number, the same in minor units, a percentage as a fraction, or (money) the sum of two of the
98
- * evidence's numbers. Dates and identifiers: the normalised text.
410
+ * evidence's numbers. A number the evidence writes only with another currency or unit doesn't
411
+ * hold it (AED 500 isn't USD 500; 500 g isn't 500 mg); one it writes bare does. Dates and
412
+ * identifiers: the normalised text (a date in words as its ISO form).
99
413
  */
100
414
  support(f) {
101
415
  if (f.kind === "money" || f.kind === "percent" || f.kind === "quantity") {
@@ -104,7 +418,14 @@ export class Evidence {
104
418
  shift(f.value, 2),
105
419
  ...(f.kind === "percent" ? [shift(f.value, -2)] : []),
106
420
  ];
107
- const hit = this.pieces.find((p) => wanted.some((w) => p.numbers.has(w)));
421
+ const mine = f.kind === "money" && f.currency
422
+ ? currencyCodes(f.currency).map((c) => `cur:${c}`)
423
+ : f.kind === "quantity" && f.unit
424
+ ? [`unit:${f.unit}`]
425
+ : null;
426
+ const fits = (p, w) => p.numbers.has(w) &&
427
+ (mine === null || p.bare.has(w) || mine.some((t) => p.tags.get(w)?.has(t) === true));
428
+ const hit = this.pieces.find((p) => wanted.some((w) => fits(p, w)));
108
429
  if (hit)
109
430
  return [hit.seq];
110
431
  return f.kind === "money" ? (this.pairSums().get(f.value) ?? null) : null;
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ const USAGE = `usage (spec/cli.md):
15
15
  blackbox verify <bundle.json> [--proofs] [--bodies <dir>]
16
16
  blackbox export <session_id> --out <bundle.json> [--bodies <dir>]
17
17
  blackbox export-all --from <date> --to <date> --out <file.ndjson>
18
- blackbox sessions [--limit N] [--label L] [--open | --closed] [--before <session_id>] [--from <date>] [--to <date>] [--json]
18
+ blackbox sessions [--limit N] [--label L] [--open | --closed] [--outcome success|failure|abandoned] [--before <session_id>] [--from <date>] [--to <date>] [--json]
19
19
  blackbox sessions <session_id> [--json]
20
20
  blackbox summary <session_id>
21
21
  blackbox incident <session_id> [--lang en|ar]
@@ -52,7 +52,8 @@ const USAGE = `usage (spec/cli.md):
52
52
  blackbox reconcile --harness claude-code|codex --bundle <bundle.json> --bodies <dir> [--agent-session <id>]... [--require-receipts] <path>
53
53
  blackbox reconcile-system --connector <connector.json> --log <export.csv|jsonl> [--pack <pack.json>] <session_id>
54
54
  blackbox workspace-receipt (a Claude Code PreToolUse / PostToolUse hook; reads the hook JSON on stdin)
55
- blackbox attest (a Claude Code PostToolUse hook: spec/attestation.md §4)`;
55
+ blackbox attest (a Claude Code PostToolUse hook: spec/attestation.md §4)
56
+ blackbox policy-hook (a Claude Code / Codex PreToolUse hook: asks the gateway's policy; fails closed)`;
56
57
  async function main(argv) {
57
58
  const [command, ...rest] = argv;
58
59
  if (command === "mcp")
@@ -67,6 +68,8 @@ async function main(argv) {
67
68
  return receiptCommand();
68
69
  if (command === "attest")
69
70
  return attestCommand();
71
+ if (command === "policy-hook")
72
+ return policyHookCommand();
70
73
  if (command === "verify")
71
74
  return verifyCommand(rest);
72
75
  if (command === "export")
@@ -215,6 +218,69 @@ async function attestCommand() {
215
218
  }
216
219
  return 0;
217
220
  }
221
+ /** spec/policy.md §2a: a PreToolUse hook. Deny: exit 2, the reason on stderr (the agent sees it).
222
+ * Ask: Claude Code's `permissionDecision: "ask"` on stdout. Allow: exit 0, nothing printed. Any
223
+ * failure denies, unless BLACKBOX_HOOK_FAIL_OPEN=1. */
224
+ async function policyHookCommand() {
225
+ const refuse = (why) => {
226
+ if (process.env.BLACKBOX_HOOK_FAIL_OPEN === "1") {
227
+ process.stderr.write(`blackbox policy-hook: ${why}; allowed (BLACKBOX_HOOK_FAIL_OPEN=1)\n`);
228
+ return 0;
229
+ }
230
+ process.stderr.write(`blackbox policy-hook: ${why}; refused (fails closed)\n`);
231
+ return 2;
232
+ };
233
+ const url = process.env.BLACKBOX_URL;
234
+ const token = process.env.BLACKBOX_TOKEN;
235
+ const sessionId = process.env.BLACKBOX_SESSION_ID;
236
+ if (!url || !token || !sessionId)
237
+ return refuse("BLACKBOX_URL, BLACKBOX_TOKEN and BLACKBOX_SESSION_ID are needed");
238
+ let hook;
239
+ try {
240
+ const parts = [];
241
+ for await (const chunk of process.stdin)
242
+ parts.push(chunk);
243
+ hook = JSON.parse(Buffer.concat(parts).toString("utf8"));
244
+ }
245
+ catch {
246
+ return refuse("the hook input is not JSON");
247
+ }
248
+ if (typeof hook.tool_name !== "string")
249
+ return refuse("the hook input has no tool_name");
250
+ let answer;
251
+ try {
252
+ const res = await fetch(new URL(`/v1/sessions/${encodeURIComponent(sessionId)}/policy-check`, url), {
253
+ method: "POST",
254
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
255
+ body: JSON.stringify({ tool: hook.tool_name, args: hook.tool_input ?? {} }),
256
+ signal: AbortSignal.timeout(5_000),
257
+ });
258
+ if (!res.ok)
259
+ return refuse(`the gateway answered HTTP ${res.status}`);
260
+ answer = (await res.json());
261
+ }
262
+ catch (error) {
263
+ return refuse(`the gateway couldn't be asked (${error instanceof Error ? error.message : error})`);
264
+ }
265
+ const why = `${typeof answer.reason === "string" ? answer.reason : "the policy says so"} (rule ${String(answer.rule)})`;
266
+ if (answer.decision === "deny") {
267
+ process.stderr.write(`Blackbox policy refused ${hook.tool_name}: ${why}\n`);
268
+ return 2;
269
+ }
270
+ if (answer.decision === "ask") {
271
+ process.stdout.write(`${JSON.stringify({
272
+ hookSpecificOutput: {
273
+ hookEventName: "PreToolUse",
274
+ permissionDecision: "ask",
275
+ permissionDecisionReason: `Blackbox policy: ${why}`,
276
+ },
277
+ })}\n`);
278
+ return 0;
279
+ }
280
+ if (answer.decision === "allow")
281
+ return 0;
282
+ return refuse("the gateway's answer has no decision");
283
+ }
218
284
  async function receiptCommand() {
219
285
  const url = process.env.BLACKBOX_URL;
220
286
  const token = process.env.BLACKBOX_TOKEN;
@@ -388,6 +454,8 @@ async function sessionsCommand(rest) {
388
454
  q.set("from", f.from);
389
455
  if (f.to !== undefined)
390
456
  q.set("to", f.to);
457
+ if (f.outcome !== undefined)
458
+ q.set("outcome", f.outcome);
391
459
  if (f.open === "1")
392
460
  q.set("status", "open");
393
461
  if (f.closed === "1")
@@ -102,6 +102,10 @@ export declare function client(options: ClientOptions): {
102
102
  emergency: (id: string, o?: {
103
103
  note?: string;
104
104
  }) => Promise<Json>;
105
+ /** Refuses the session's later calls that name this run (x-blackbox-run), spec/control.md §3a. */
106
+ cancelRun: (id: string, runId: string, o?: {
107
+ note?: string;
108
+ }) => Promise<Json>;
105
109
  /** Hands the controls over (spec/authority.md): `human` takes over; `agent`/`supervised` hand back. */
106
110
  authority: (id: string, to: "human" | "agent" | "supervised", o?: {
107
111
  reason?: string;
@@ -143,5 +147,13 @@ export declare function client(options: ClientOptions): {
143
147
  expires_in_days?: number;
144
148
  }) => Promise<Json>;
145
149
  revokeKey: (tenantId: string, keyId: string) => Promise<Json>;
150
+ /** Operator only: a tenant's own rules (spec/policy.md §7a). */
151
+ tenantPolicy: (tenantId: string) => Promise<Json>;
152
+ setTenantPolicy: (tenantId: string, rules: object[]) => Promise<Json>;
153
+ removeTenantPolicy: (tenantId: string) => Promise<Json>;
154
+ /** Operator only: a tenant's own provider keys, sealed, never shown (spec/tenancy.md §5a). */
155
+ providerKeys: (tenantId: string) => Promise<Json>;
156
+ setProviderKey: (tenantId: string, provider: string, apiKey: string) => Promise<Json>;
157
+ removeProviderKey: (tenantId: string, provider: string) => Promise<Json>;
146
158
  };
147
159
  export type Client = ReturnType<typeof client>;
@@ -187,6 +187,8 @@ export function client(options) {
187
187
  pause: (id, o = {}) => json("POST", ses(id, "pause"), compact(o)),
188
188
  /** The emergency stop (squawk 7700, spec/control.md). */
189
189
  emergency: (id, o = {}) => json("POST", ses(id, "emergency"), compact(o)),
190
+ /** Refuses the session's later calls that name this run (x-blackbox-run), spec/control.md §3a. */
191
+ cancelRun: (id, runId, o = {}) => json("POST", ses(id, `runs/${encodeURIComponent(runId)}/cancel`), compact(o), true),
190
192
  /** Hands the controls over (spec/authority.md): `human` takes over; `agent`/`supervised` hand back. */
191
193
  authority: (id, to, o = {}) => json("POST", ses(id, "authority"), compact({ to, ...o })),
192
194
  /** The pending approvals (spec/approvals.md). */
@@ -205,6 +207,14 @@ export function client(options) {
205
207
  updateTenant: (tenantId, o) => json("PATCH", `/v1/tenants/${encodeURIComponent(tenantId)}`, compact(o)),
206
208
  createKey: (tenantId, o = {}) => json("POST", `/v1/tenants/${encodeURIComponent(tenantId)}/keys`, compact(o)),
207
209
  revokeKey: (tenantId, keyId) => json("POST", `/v1/tenants/${encodeURIComponent(tenantId)}/keys/${encodeURIComponent(keyId)}/revoke`, {}, true),
210
+ /** Operator only: a tenant's own rules (spec/policy.md §7a). */
211
+ tenantPolicy: (tenantId) => json("GET", `/v1/tenants/${encodeURIComponent(tenantId)}/policy`),
212
+ setTenantPolicy: (tenantId, rules) => json("PUT", `/v1/tenants/${encodeURIComponent(tenantId)}/policy`, { policy: { rules } }, true),
213
+ removeTenantPolicy: (tenantId) => json("DELETE", `/v1/tenants/${encodeURIComponent(tenantId)}/policy`, undefined, true),
214
+ /** Operator only: a tenant's own provider keys, sealed, never shown (spec/tenancy.md §5a). */
215
+ providerKeys: (tenantId) => json("GET", `/v1/tenants/${encodeURIComponent(tenantId)}/providers`),
216
+ setProviderKey: (tenantId, provider, apiKey) => json("PUT", `/v1/tenants/${encodeURIComponent(tenantId)}/providers/${encodeURIComponent(provider)}`, { api_key: apiKey }, true),
217
+ removeProviderKey: (tenantId, provider) => json("DELETE", `/v1/tenants/${encodeURIComponent(tenantId)}/providers/${encodeURIComponent(provider)}`, undefined, true),
208
218
  };
209
219
  return api;
210
220
  }
@@ -59,6 +59,9 @@ interface Compiled {
59
59
  args: RegExp | null;
60
60
  }
61
61
  /** Parses a policy file; throws on anything invalid (the server refuses to start). */
62
+ /** spec/policy.md §1 (L6.5.4): null when `args_match` uses only syntax JavaScript and Python
63
+ * share, else what doesn't (vectors: spec/vectors/policy-regex.json). */
64
+ export declare function portableRegexError(p: string): string | null;
62
65
  export declare function loadPolicy(bytes: Uint8Array): Policy;
63
66
  export declare function compilePolicy(policy: Policy): Compiled[];
64
67
  /** spec/api.md: the session's environment, from its `session.open` (the first line). */
@@ -58,6 +58,52 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
58
58
  /** A tool pattern: `*` is any run of characters, the rest is literal, anchored at both ends. */
59
59
  const glob = (pattern) => new RegExp(`^${pattern.split("*").map(escapeRe).join(".*")}$`);
60
60
  /** Parses a policy file; throws on anything invalid (the server refuses to start). */
61
+ /** spec/policy.md §1 (L6.5.4): null when `args_match` uses only syntax JavaScript and Python
62
+ * share, else what doesn't (vectors: spec/vectors/policy-regex.json). */
63
+ export function portableRegexError(p) {
64
+ const hex = (s) => /^[0-9A-Fa-f]+$/.test(s);
65
+ let inClass = false;
66
+ for (let i = 0; i < p.length; i++) {
67
+ const c = p[i];
68
+ if (c === "\\") {
69
+ const n = p[i + 1];
70
+ if (n === undefined)
71
+ return "a trailing \\";
72
+ if (n === "x" && !(p.length >= i + 4 && hex(p.slice(i + 2, i + 4))))
73
+ return "\\x needs 2 hex digits";
74
+ if (n === "u" && !(p.length >= i + 6 && hex(p.slice(i + 2, i + 6))))
75
+ return "\\u needs 4 hex digits";
76
+ if (/[A-Za-z]/.test(n) && !"dDwWsSbBnrtfvxu".includes(n))
77
+ return `\\${n}`;
78
+ i++;
79
+ continue;
80
+ }
81
+ if (inClass) {
82
+ if (c === "]")
83
+ inClass = false;
84
+ else if (c === "[" && p[i + 1] === ":")
85
+ return "[:";
86
+ continue;
87
+ }
88
+ if (c === "[") {
89
+ if (p[i + 1] === ":")
90
+ return "[:";
91
+ const first = p[i + 1] === "^" ? i + 2 : i + 1;
92
+ if (p[first] === "]")
93
+ return "an empty class";
94
+ inClass = true;
95
+ i = first - 1;
96
+ continue;
97
+ }
98
+ if (c === "(" && p[i + 1] === "?" && !":=!".includes(p[i + 2] ?? ""))
99
+ return "(? other than (?: (?= (?!";
100
+ if ("*+?}".includes(c) && p[i + 1] === "+" && !(c === "?" && "*+?}".includes(p[i - 1] ?? "")))
101
+ return "a possessive quantifier";
102
+ if (c === "{" && p[i + 1] === ",")
103
+ return "{,";
104
+ }
105
+ return null;
106
+ }
61
107
  export function loadPolicy(bytes) {
62
108
  const doc = JSON.parse(new TextDecoder().decode(bytes));
63
109
  if (doc.version !== 1 || !Array.isArray(doc.rules))
@@ -77,6 +123,9 @@ export function loadPolicy(bytes) {
77
123
  if (r.args_match !== undefined) {
78
124
  if (typeof r.args_match !== "string" || r.args_match.length > 200)
79
125
  throw new Error(`policy ${r.id}: args_match must be a regular expression of ≤ 200 characters`);
126
+ const unportable = portableRegexError(r.args_match);
127
+ if (unportable)
128
+ throw new Error(`policy ${r.id}: args_match uses syntax JavaScript and Python don't share (${unportable})`);
80
129
  new RegExp(r.args_match); // throws on bad syntax
81
130
  }
82
131
  if (r.audit !== undefined && typeof r.audit !== "boolean")
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.0";
1
+ export declare const VERSION = "0.11.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The SDK version, on its own so the CLI can print it without loading the whole SDK.
2
- export const VERSION = "0.9.0";
2
+ export const VERSION = "0.11.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zanii/blackbox",
3
3
  "license": "Apache-2.0",
4
- "version": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "description": "The flight recorder for AI agents: sessions, a zero-loss spool, framework hooks, approvals, and offline verification of the gateway's hash-chained record.",
6
6
  "keywords": [
7
7
  "ai-agents",