@zanii/blackbox 0.10.0 → 0.12.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,16 @@ 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.12.0
87
+
88
+ - Tax credit notes: `taxCreditNote` / `renderTaxCreditNote`, the document and printable copy behind gateway v0.14's `POST /v1/invoices/:number/credit-notes`.
89
+
90
+ ## New in 0.11.0
91
+
92
+ - 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.
93
+ - `blackbox policy-hook`: a Claude Code / Codex PreToolUse hook that asks the gateway's policy before a local tool runs, and fails closed.
94
+ - Works with gateway v0.13 (`POST /v1/sessions/:id/policy-check`).
95
+
86
96
  ## New in 0.10.0
87
97
 
88
98
  - `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`).
@@ -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;
@@ -95,5 +95,51 @@ export declare function taxInvoice(input: TaxInvoiceInput): {
95
95
  export type TaxInvoice = ReturnType<typeof taxInvoice>;
96
96
  /** spec/billing.md §5: the printable copy. */
97
97
  export declare function renderTaxInvoice(d: TaxInvoice): string;
98
+ export interface TaxCreditNoteInput {
99
+ number: string;
100
+ issued_at: string;
101
+ /** The invoice it corrects, as issued. */
102
+ invoice: TaxInvoice;
103
+ reason: string;
104
+ /** What this note credits, before VAT. */
105
+ subtotal_fils: number;
106
+ /** What earlier notes on the invoice credited already. */
107
+ credited?: {
108
+ subtotal_fils: number;
109
+ vat_fils: number;
110
+ };
111
+ }
112
+ /** spec/billing.md §5a: a tax credit note. The note that credits what's left of the invoice takes
113
+ * the rest of its VAT exactly, so the notes never credit more VAT than was invoiced. */
114
+ export declare function taxCreditNote(input: TaxCreditNoteInput): {
115
+ v: 1;
116
+ title: "Tax Credit Note";
117
+ number: string;
118
+ issued_at: string;
119
+ invoice: {
120
+ number: string;
121
+ issued_at: string;
122
+ };
123
+ reason: string;
124
+ supplier: {
125
+ name: string;
126
+ address: string;
127
+ trn: string;
128
+ };
129
+ customer: {
130
+ legal_name: string;
131
+ address: string;
132
+ trn: string | null;
133
+ };
134
+ currency: "AED";
135
+ subtotal_fils: number;
136
+ vat_percent: number;
137
+ vat_fils: number;
138
+ total_fils: number;
139
+ total_aed: string;
140
+ };
141
+ export type TaxCreditNote = ReturnType<typeof taxCreditNote>;
142
+ /** spec/billing.md §5a: the printable copy. */
143
+ export declare function renderTaxCreditNote(d: TaxCreditNote): string;
98
144
  /** spec/billing.md §6: a `Stripe-Signature` header checked against the raw body. */
99
145
  export declare function stripeSignatureOk(header: string, rawBody: Uint8Array, secret: string, nowSeconds: number, toleranceSeconds?: number): boolean;
@@ -151,6 +151,64 @@ export function renderTaxInvoice(d) {
151
151
  ];
152
152
  return out.join("\n");
153
153
  }
154
+ /** spec/billing.md §5a: a tax credit note. The note that credits what's left of the invoice takes
155
+ * the rest of its VAT exactly, so the notes never credit more VAT than was invoiced. */
156
+ export function taxCreditNote(input) {
157
+ const inv = input.invoice;
158
+ const before = input.credited ?? { subtotal_fils: 0, vat_fils: 0 };
159
+ const last = input.subtotal_fils === inv.subtotal_fils - before.subtotal_fils;
160
+ const vat = last
161
+ ? inv.vat_fils - before.vat_fils
162
+ : Math.floor((input.subtotal_fils * inv.vat_percent + 50) / 100);
163
+ return {
164
+ v: 1,
165
+ title: "Tax Credit Note",
166
+ number: input.number,
167
+ issued_at: input.issued_at,
168
+ invoice: { number: inv.number, issued_at: inv.issued_at },
169
+ reason: input.reason,
170
+ supplier: inv.supplier,
171
+ customer: inv.customer,
172
+ currency: "AED",
173
+ subtotal_fils: input.subtotal_fils,
174
+ vat_percent: inv.vat_percent,
175
+ vat_fils: vat,
176
+ total_fils: input.subtotal_fils + vat,
177
+ total_aed: formatAed(input.subtotal_fils + vat),
178
+ };
179
+ }
180
+ /** spec/billing.md §5a: the printable copy. */
181
+ export function renderTaxCreditNote(d) {
182
+ return [
183
+ "# Tax Credit Note",
184
+ "",
185
+ "| Field | Value |",
186
+ "|---|---|",
187
+ `| Credit note number | ${cell(d.number)} |`,
188
+ `| Date of issue | ${d.issued_at} |`,
189
+ `| Corrects invoice | ${cell(d.invoice.number)} of ${d.invoice.issued_at} |`,
190
+ `| Reason | ${cell(oneLine(d.reason))} |`,
191
+ "",
192
+ "## Supplier",
193
+ "",
194
+ `${oneLine(d.supplier.name)} `,
195
+ `${oneLine(d.supplier.address)} `,
196
+ `TRN: ${d.supplier.trn}`,
197
+ "",
198
+ "## Customer",
199
+ "",
200
+ `${oneLine(d.customer.legal_name)} `,
201
+ `${oneLine(d.customer.address)} `,
202
+ `TRN: ${d.customer.trn ?? "not registered"}`,
203
+ "",
204
+ "| Credited | AED |",
205
+ "|---|---:|",
206
+ `| Subtotal | ${aed(d.subtotal_fils)} |`,
207
+ `| VAT ${d.vat_percent}% | ${aed(d.vat_fils)} |`,
208
+ `| **Total credited** | **${aed(d.total_fils)}** |`,
209
+ "",
210
+ ].join("\n");
211
+ }
154
212
  // ---------------------------------------------------------------- Stripe webhook signature (§6)
155
213
  /** spec/billing.md §6: a `Stripe-Signature` header checked against the raw body. */
156
214
  export function stripeSignatureOk(header, rawBody, secret, nowSeconds, toleranceSeconds = 300) {
package/dist/cli.js CHANGED
@@ -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;
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { archiveParquet } from "./archive/parquet.ts";
11
11
  export { type Attestation, attest, isReadOnly, normalise, parseCommand } from "./attest/index.ts";
12
12
  export { type AuthorityFinding, authorityAt, authorityTimeline, automationSurprise, type Mode as AuthorityMode, type Span as AuthoritySpan, } from "./authority/index.ts";
13
13
  export { badgeSvg, RECORD_STATES, type RecordState, recordBadge, recordState, } from "./badge/index.ts";
14
- export { type Invoice, type InvoiceLine, invoice, loadPlans, type Plan, type Plans, renderTaxInvoice, stripeSignatureOk, type TaxInvoice, type TaxInvoiceInput, taxInvoice, } from "./billing/index.ts";
14
+ export { type Invoice, type InvoiceLine, invoice, loadPlans, type Plan, type Plans, renderTaxCreditNote, renderTaxInvoice, stripeSignatureOk, type TaxCreditNote, type TaxCreditNoteInput, type TaxInvoice, type TaxInvoiceInput, taxCreditNote, taxInvoice, } from "./billing/index.ts";
15
15
  export { type AgentIdentity, checkAgent, sessionBom } from "./bom/index.ts";
16
16
  export { BlackboxApiError, type Client, type ClientOptions, client, type SessionQuery, } from "./client/index.ts";
17
17
  export { ART12_MIN_RETENTION_DAYS, type Art12Check, type Art12Report, art12Check, } from "./compliance/art12.ts";
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { archiveParquet } from "./archive/parquet.js";
13
13
  export { attest, isReadOnly, normalise, parseCommand } from "./attest/index.js";
14
14
  export { authorityAt, authorityTimeline, automationSurprise, } from "./authority/index.js";
15
15
  export { badgeSvg, RECORD_STATES, recordBadge, recordState, } from "./badge/index.js";
16
- export { invoice, loadPlans, renderTaxInvoice, stripeSignatureOk, taxInvoice, } from "./billing/index.js";
16
+ export { invoice, loadPlans, renderTaxCreditNote, renderTaxInvoice, stripeSignatureOk, taxCreditNote, taxInvoice, } from "./billing/index.js";
17
17
  export { checkAgent, sessionBom } from "./bom/index.js";
18
18
  export { BlackboxApiError, client, } from "./client/index.js";
19
19
  export { ART12_MIN_RETENTION_DAYS, art12Check, } from "./compliance/art12.js";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.10.0";
1
+ export declare const VERSION = "0.12.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.10.0";
2
+ export const VERSION = "0.12.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.10.0",
4
+ "version": "0.12.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",