@zanii/blackbox 0.22.0 → 0.23.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 +1 -0
- package/dist/analysis/accepted.d.ts +26 -0
- package/dist/analysis/accepted.js +61 -0
- package/dist/analysis/detectors.js +12 -9
- package/dist/analysis/faults.js +71 -0
- package/dist/analysis/grounding.js +5 -9
- package/dist/analysis/guard.d.ts +72 -0
- package/dist/analysis/guard.js +240 -0
- package/dist/analysis/hallucination.js +2 -2
- package/dist/analysis/index.d.ts +21 -1
- package/dist/analysis/index.js +101 -19
- package/dist/analysis/injection.d.ts +2 -1
- package/dist/analysis/injection.js +16 -16
- package/dist/analysis/landing.js +60 -14
- package/dist/analysis/plan-checks.d.ts +55 -0
- package/dist/analysis/plan-checks.js +748 -0
- package/dist/analysis/taxonomy.js +1 -0
- package/dist/analysis/waste.js +2 -3
- package/dist/approvals/index.d.ts +12 -3
- package/dist/approvals/index.js +36 -10
- package/dist/authority/index.js +3 -3
- package/dist/aviation/index.d.ts +422 -0
- package/dist/aviation/index.js +1014 -0
- package/dist/aviation/qrh.d.ts +4 -0
- package/dist/aviation/qrh.js +171 -0
- package/dist/budgets/index.d.ts +63 -0
- package/dist/budgets/index.js +161 -0
- package/dist/cli.js +172 -24
- package/dist/compliance/index.d.ts +2 -0
- package/dist/compliance/index.js +26 -2
- package/dist/cost/batch.d.ts +26 -0
- package/dist/cost/batch.js +173 -0
- package/dist/cost/index.d.ts +21 -0
- package/dist/cost/index.js +115 -30
- package/dist/cv/index.js +1 -1
- package/dist/duty/index.js +2 -1
- package/dist/evals/index.d.ts +233 -0
- package/dist/evals/index.js +488 -0
- package/dist/federation/index.d.ts +9 -0
- package/dist/federation/index.js +8 -1
- package/dist/finance/index.js +3 -4
- package/dist/fleet/index.d.ts +25 -0
- package/dist/fleet/index.js +57 -17
- package/dist/fleet/insights.js +4 -1
- package/dist/health/index.js +2 -2
- package/dist/hooks/ai-sdk.d.ts +17 -19
- package/dist/hooks/ai-sdk.js +13 -7
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/mastra.d.ts +26 -0
- package/dist/hooks/mastra.js +63 -0
- package/dist/index.d.ts +22 -9
- package/dist/index.js +23 -9
- package/dist/mcp-requests/index.d.ts +26 -0
- package/dist/mcp-requests/index.js +154 -0
- package/dist/mcp-server/index.d.ts +12 -0
- package/dist/mcp-server/index.js +84 -2
- package/dist/mcp-wrap/index.js +8 -4
- package/dist/otlp/index.d.ts +155 -0
- package/dist/otlp/index.js +468 -0
- package/dist/otlp/protobuf.d.ts +7 -0
- package/dist/otlp/protobuf.js +314 -0
- package/dist/packs/index.d.ts +2 -0
- package/dist/packs/index.js +5 -1
- package/dist/pdf/arabic.d.ts +45 -0
- package/dist/pdf/arabic.js +373 -0
- package/dist/pdf/index.d.ts +77 -0
- package/dist/pdf/index.js +673 -0
- package/dist/policy/index.d.ts +0 -4
- package/dist/policy/index.js +13 -5
- package/dist/realtime/index.d.ts +33 -0
- package/dist/realtime/index.js +204 -0
- package/dist/reconcile/record.d.ts +12 -0
- package/dist/reconcile/record.js +114 -65
- package/dist/session/index.d.ts +43 -0
- package/dist/session/index.js +83 -0
- package/dist/undo/index.js +3 -3
- package/dist/verify/anchor.d.ts +5 -1
- package/dist/verify/anchor.js +13 -4
- package/dist/verify/envelope.d.ts +1 -1
- package/dist/verify/envelope.js +5 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walls/index.js +2 -2
- package/dist/warehouse/index.d.ts +62 -0
- package/dist/warehouse/index.js +142 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ The same checks run offline as pure functions: `toolHallucinations`, `groundingF
|
|
|
156
156
|
| OpenAI Agents SDK | `attachBlackbox(runner, s)` |
|
|
157
157
|
| Claude Agent SDK | `blackboxAgentHooks(s)` |
|
|
158
158
|
| Vercel AI SDK | `blackboxMiddleware(s)` |
|
|
159
|
+
| Mastra | `exporters: [blackboxMastraExporter(s)]` in its observability config, and `blackboxMiddleware(s, { toolCalls: false })` on the agent's model for the model-call ids |
|
|
159
160
|
| Provider clients | `wrapAnthropic`, `wrapOpenAI`, `wrapGemini`, `wrapBedrock` link each call to the gateway's record |
|
|
160
161
|
|
|
161
162
|
## Command line
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface AcceptedEntry {
|
|
2
|
+
code: string;
|
|
3
|
+
ref?: {
|
|
4
|
+
[key: string]: string | number | boolean;
|
|
5
|
+
};
|
|
6
|
+
label?: string;
|
|
7
|
+
agent?: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
by: string;
|
|
10
|
+
expires_at?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Why a list of accepted findings is invalid, or undefined. */
|
|
13
|
+
export declare function checkAcceptedFindings(list: unknown): string | undefined;
|
|
14
|
+
/** The list's identity, recorded with every finding it accepts: sha256 over {v: 1, accepted}. */
|
|
15
|
+
export declare function acceptedHash(list: readonly AcceptedEntry[]): string;
|
|
16
|
+
/** The index of the first entry that accepts this finding, or -1. `now` is an ISO timestamp. */
|
|
17
|
+
export declare function acceptedMatch(list: readonly AcceptedEntry[], finding: {
|
|
18
|
+
code: string;
|
|
19
|
+
ref: {
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
}, context: {
|
|
23
|
+
label: string | null;
|
|
24
|
+
agent: string | null;
|
|
25
|
+
now: string;
|
|
26
|
+
}): number;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Accepted findings (spec/control.md §4b): a tenant's list of known, accepted findings. A finding
|
|
2
|
+
// that matches is still recorded, marked accepted, and raises no alert and no block. Mirrors
|
|
3
|
+
// analysis/accepted.py; pinned by spec/vectors/accepted-findings.json.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { canonical, isObj } from "../reconcile/shared.js";
|
|
6
|
+
const CODE = /^[A-Z0-9_]{1,64}\*?$/;
|
|
7
|
+
const UTC = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/;
|
|
8
|
+
const FIELDS = ["code", "ref", "label", "agent", "reason", "by", "expires_at"];
|
|
9
|
+
const text = (v, max) => typeof v === "string" && v.length > 0 && Array.from(v).length <= max;
|
|
10
|
+
/** Why a list of accepted findings is invalid, or undefined. */
|
|
11
|
+
export function checkAcceptedFindings(list) {
|
|
12
|
+
if (!Array.isArray(list) || list.length > 200)
|
|
13
|
+
return "accepted must be a list of at most 200 entries";
|
|
14
|
+
for (const [i, e] of list.entries()) {
|
|
15
|
+
const at = `accepted[${i}]`;
|
|
16
|
+
if (!isObj(e))
|
|
17
|
+
return `${at} must be an object`;
|
|
18
|
+
const extra = Object.keys(e).filter((k) => !FIELDS.includes(k));
|
|
19
|
+
if (extra.length)
|
|
20
|
+
return `${at} has unknown fields: ${extra.sort().join(", ")}`;
|
|
21
|
+
if (typeof e.code !== "string" || !CODE.test(e.code))
|
|
22
|
+
return `${at}.code must be a finding code, optionally ending in *`;
|
|
23
|
+
if (e.ref !== undefined &&
|
|
24
|
+
!(isObj(e.ref) &&
|
|
25
|
+
Object.keys(e.ref).length <= 20 &&
|
|
26
|
+
Object.values(e.ref).every((v) => typeof v === "boolean" ||
|
|
27
|
+
(typeof v === "number" && Number.isSafeInteger(v)) ||
|
|
28
|
+
(typeof v === "string" && Array.from(v).length <= 256))))
|
|
29
|
+
return `${at}.ref must be an object of at most 20 strings, integers or booleans`;
|
|
30
|
+
for (const k of ["label", "agent"])
|
|
31
|
+
if (e[k] !== undefined && !text(e[k], 128))
|
|
32
|
+
return `${at}.${k} must be 1-128 characters`;
|
|
33
|
+
if (!text(e.reason, 500))
|
|
34
|
+
return `${at}.reason must be 1-500 characters`;
|
|
35
|
+
if (!text(e.by, 128))
|
|
36
|
+
return `${at}.by must be 1-128 characters`;
|
|
37
|
+
if (e.expires_at !== undefined &&
|
|
38
|
+
!(typeof e.expires_at === "string" &&
|
|
39
|
+
UTC.test(e.expires_at) &&
|
|
40
|
+
!Number.isNaN(Date.parse(e.expires_at))))
|
|
41
|
+
return `${at}.expires_at must be a UTC timestamp like 2026-12-31T00:00:00Z`;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
/** The list's identity, recorded with every finding it accepts: sha256 over {v: 1, accepted}. */
|
|
46
|
+
export function acceptedHash(list) {
|
|
47
|
+
return `sha256:${createHash("sha256")
|
|
48
|
+
.update(canonical({ v: 1, accepted: list }))
|
|
49
|
+
.digest("hex")}`;
|
|
50
|
+
}
|
|
51
|
+
/** The index of the first entry that accepts this finding, or -1. `now` is an ISO timestamp. */
|
|
52
|
+
export function acceptedMatch(list, finding, context) {
|
|
53
|
+
const now = Date.parse(context.now);
|
|
54
|
+
return list.findIndex((e) => (e.code.endsWith("*")
|
|
55
|
+
? finding.code.startsWith(e.code.slice(0, -1))
|
|
56
|
+
: finding.code === e.code) &&
|
|
57
|
+
Object.entries(e.ref ?? {}).every(([k, v]) => Object.hasOwn(finding.ref, k) && finding.ref[k] === v) &&
|
|
58
|
+
(e.label === undefined || e.label === context.label) &&
|
|
59
|
+
(e.agent === undefined || e.agent === context.agent) &&
|
|
60
|
+
(e.expires_at === undefined || now < Date.parse(e.expires_at)));
|
|
61
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// any agent, instrumented or not. Mirrors sdks/python/src/zanii_blackbox/analysis/detectors.py.
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { priceCall, priceFor, promptTokens, tokensOf } from "../cost/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { realtimeUsage } from "../realtime/index.js";
|
|
6
|
+
import { callsOf, envelopesOf } from "../reconcile/record.js";
|
|
6
7
|
import { canonical, isObj } from "../reconcile/shared.js";
|
|
7
8
|
const VOLATILE = new Set([
|
|
8
9
|
"timestamp",
|
|
@@ -104,8 +105,7 @@ export function toolResultsOf(calls, lines) {
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
|
-
for (const
|
|
108
|
-
const e = JSON.parse(line);
|
|
108
|
+
for (const e of envelopesOf(lines)) {
|
|
109
109
|
if (e.kind === "tool.result" && typeof e.meta.is_error === "boolean")
|
|
110
110
|
out.push({ seq: e.seq, error: e.meta.is_error });
|
|
111
111
|
}
|
|
@@ -303,9 +303,14 @@ export function detectors(lines, options) {
|
|
|
303
303
|
if (options.prices && budget && budget > 0) {
|
|
304
304
|
let micro = 0;
|
|
305
305
|
const crossed = new Set();
|
|
306
|
-
|
|
306
|
+
// a realtime session's usage reports count too (spec/providers.md §7)
|
|
307
|
+
const spend = [
|
|
308
|
+
...priced.map((c) => ({ seq: c.endSeq, call: { ...c, usage: c.usage } })),
|
|
309
|
+
...realtimeUsage(lines).map((u) => ({ seq: u.seq, call: u })),
|
|
310
|
+
].sort((a, b) => a.seq - b.seq);
|
|
311
|
+
for (const { call } of spend) {
|
|
307
312
|
// the cost report's own pricing (spec/cost.md): tiers, provider scope, reported costs
|
|
308
|
-
const priced = priceCall(
|
|
313
|
+
const priced = priceCall(call, options.prices.table);
|
|
309
314
|
if (priced.micro_usd === null)
|
|
310
315
|
continue;
|
|
311
316
|
micro += priced.micro_usd;
|
|
@@ -355,8 +360,7 @@ export function detectors(lines, options) {
|
|
|
355
360
|
out.push(f("DANGLING_TOOL_CALL", "advisory", { tool_use_id: b.id }, "A tool call's result never reached the model; the tool may have run."));
|
|
356
361
|
}
|
|
357
362
|
// LOST_COMMS: the session went silent (squawk 7600).
|
|
358
|
-
for (const
|
|
359
|
-
const e = JSON.parse(line);
|
|
363
|
+
for (const e of envelopesOf(lines)) {
|
|
360
364
|
if (e.kind === "session.lost_contact")
|
|
361
365
|
out.push(f("LOST_COMMS", "warning", { seq: e.seq }, "The agent stopped sending heartbeats: lost contact."));
|
|
362
366
|
}
|
|
@@ -377,8 +381,7 @@ export function detectors(lines, options) {
|
|
|
377
381
|
/** The latest budget and call limit a person granted (spec/approvals.md §4), if any. */
|
|
378
382
|
export function grantedLimits(lines) {
|
|
379
383
|
const out = {};
|
|
380
|
-
for (const
|
|
381
|
-
const e = JSON.parse(line);
|
|
384
|
+
for (const e of envelopesOf(lines)) {
|
|
382
385
|
if (e.kind !== "control" || e.meta.action !== "permission")
|
|
383
386
|
continue;
|
|
384
387
|
if (Number.isSafeInteger(e.meta.budget_micro_usd))
|
package/dist/analysis/faults.js
CHANGED
|
@@ -95,6 +95,17 @@ export const FAULTS = {
|
|
|
95
95
|
ar: "بلوغ حدّ استدعاءات النموذج",
|
|
96
96
|
},
|
|
97
97
|
MODEL_DEPRECATED: { fault: "BBX-3103", en: "Deprecated model", ar: "نموذج متوقّف الدعم" },
|
|
98
|
+
AGENT_SPEND: { fault: "BBX-3104", en: "Agent spend limit", ar: "حدّ إنفاق الوكيل" },
|
|
99
|
+
AGENT_CALL_LIMIT: {
|
|
100
|
+
fault: "BBX-3105",
|
|
101
|
+
en: "Agent model-call limit reached",
|
|
102
|
+
ar: "بلوغ حدّ استدعاءات النموذج للوكيل",
|
|
103
|
+
},
|
|
104
|
+
AGENT_OUTLIER: {
|
|
105
|
+
fault: "BBX-3106",
|
|
106
|
+
en: "Agent far above its peers",
|
|
107
|
+
ar: "وكيل يتجاوز أقرانه بكثير",
|
|
108
|
+
},
|
|
98
109
|
PLAN_DEVIATION: {
|
|
99
110
|
fault: "BBX-4101",
|
|
100
111
|
en: "Deviation from the flight plan",
|
|
@@ -268,6 +279,11 @@ export const FAULTS = {
|
|
|
268
279
|
en: "A tool's definition differs from earlier sessions",
|
|
269
280
|
ar: "يختلف تعريف أداة عمّا رأته الجلسات السابقة",
|
|
270
281
|
},
|
|
282
|
+
ELICITATION_SECRET: {
|
|
283
|
+
fault: "BBX-7408",
|
|
284
|
+
en: "An MCP server asked the user for a credential",
|
|
285
|
+
ar: "طلب خادم MCP من المستخدم بيانات اعتماد",
|
|
286
|
+
},
|
|
271
287
|
REVOKED_MEMORY_READ: {
|
|
272
288
|
fault: "BBX-7101",
|
|
273
289
|
en: "A revoked memory was used",
|
|
@@ -394,6 +410,61 @@ export const FAULTS = {
|
|
|
394
410
|
en: "The agent can reach the internet around the gateway",
|
|
395
411
|
ar: "يستطيع الوكيل الوصول إلى الإنترنت متجاوزاً البوابة",
|
|
396
412
|
},
|
|
413
|
+
NO_CLEARANCE: {
|
|
414
|
+
fault: "BBX-8901",
|
|
415
|
+
en: "Changed a shared resource without a clearance",
|
|
416
|
+
ar: "غيّر موردًا مشتركًا دون تصريح",
|
|
417
|
+
},
|
|
418
|
+
NOTAM_CONFLICT: {
|
|
419
|
+
fault: "BBX-8902",
|
|
420
|
+
en: "Acted against an operator notice",
|
|
421
|
+
ar: "تصرّف بخلاف إشعار من المشغّل",
|
|
422
|
+
},
|
|
423
|
+
ENVELOPE_SHAPED: {
|
|
424
|
+
fault: "BBX-8903",
|
|
425
|
+
en: "A request was shaped to fit the operator's envelope",
|
|
426
|
+
ar: "عُدّل طلب ليبقى ضمن حدود المشغّل",
|
|
427
|
+
},
|
|
428
|
+
EMERGENCY_LOCATOR: {
|
|
429
|
+
fault: "BBX-8904",
|
|
430
|
+
en: "Contact lost with an action open",
|
|
431
|
+
ar: "انقطع الاتصال وإجراء ما زال مفتوحًا",
|
|
432
|
+
},
|
|
433
|
+
SAFETY_REPORT: {
|
|
434
|
+
fault: "BBX-8905",
|
|
435
|
+
en: "Safety report from the agent",
|
|
436
|
+
ar: "بلاغ سلامة من الوكيل",
|
|
437
|
+
},
|
|
438
|
+
MAYDAY: {
|
|
439
|
+
fault: "BBX-8906",
|
|
440
|
+
en: "Mayday: the agent asks for help",
|
|
441
|
+
ar: "نداء استغاثة: الوكيل يطلب المساعدة",
|
|
442
|
+
},
|
|
443
|
+
PAN_PAN: {
|
|
444
|
+
fault: "BBX-8907",
|
|
445
|
+
en: "Pan-pan: the agent is unsure",
|
|
446
|
+
ar: "نداء عاجل: الوكيل غير متأكد",
|
|
447
|
+
},
|
|
448
|
+
UNAPPROVED_MODIFICATION: {
|
|
449
|
+
fault: "BBX-8908",
|
|
450
|
+
en: "The agent runs a configuration with no type certificate",
|
|
451
|
+
ar: "يعمل الوكيل بإعداد بلا شهادة طراز",
|
|
452
|
+
},
|
|
453
|
+
CHECK_OVERDUE: {
|
|
454
|
+
fault: "BBX-8909",
|
|
455
|
+
en: "A scheduled check is overdue",
|
|
456
|
+
ar: "فحص مجدول متأخر",
|
|
457
|
+
},
|
|
458
|
+
RATING_LAPSED: {
|
|
459
|
+
fault: "BBX-8910",
|
|
460
|
+
en: "A type rating has lapsed",
|
|
461
|
+
ar: "انتهت صلاحية تأهيل الطراز",
|
|
462
|
+
},
|
|
463
|
+
APPROVER_FATIGUE: {
|
|
464
|
+
fault: "BBX-8911",
|
|
465
|
+
en: "An approver approves everything in seconds",
|
|
466
|
+
ar: "معتمِد يوافق على كل شيء في ثوانٍ",
|
|
467
|
+
},
|
|
397
468
|
};
|
|
398
469
|
/** The fault for a finding code; unknown codes get a generic entry, never an exception. */
|
|
399
470
|
export function faultOf(code) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the person's words, the system prompt, the tool results it passed back) and the tool results on
|
|
4
4
|
// the record. Exact, no model, Arabic and English. Mirrors
|
|
5
5
|
// sdks/python/src/zanii_blackbox/analysis/grounding.py; pinned by spec/vectors/grounding.json.
|
|
6
|
-
import { callsOf } from "../reconcile/record.js";
|
|
6
|
+
import { callsOf, envelopesOf } from "../reconcile/record.js";
|
|
7
7
|
import { canonical } from "../reconcile/shared.js";
|
|
8
8
|
import { toolCallsOf } from "./detectors.js";
|
|
9
9
|
import { answerText, canonicalNumber, identifierSpans, normaliseValue, western, } from "./hallucination.js";
|
|
@@ -490,8 +490,7 @@ export function answersOf(lines, bodies) {
|
|
|
490
490
|
// tool results on the record, by seq, to add those before each answer
|
|
491
491
|
const results = [];
|
|
492
492
|
// (each answer's evidence: its request, then the tool results before it, as record lines)
|
|
493
|
-
for (const
|
|
494
|
-
const e = JSON.parse(l);
|
|
493
|
+
for (const e of envelopesOf(lines)) {
|
|
495
494
|
if (e.kind === "tool.result" || (e.kind === "sdk.event" && e.meta.type === "tool.result")) {
|
|
496
495
|
const b = bodies(e.body_hash);
|
|
497
496
|
results.push({ seq: e.seq, text: b ? decoder.decode(b) : "" });
|
|
@@ -584,8 +583,7 @@ export function parseGroundingAnswer(v) {
|
|
|
584
583
|
* recorded as `control {action: "grounding", via: "model"}` with the claims as the body. */
|
|
585
584
|
export function modelGroundingFindings(lines, bodies) {
|
|
586
585
|
const out = [];
|
|
587
|
-
for (const
|
|
588
|
-
const e = JSON.parse(l);
|
|
586
|
+
for (const e of envelopesOf(lines)) {
|
|
589
587
|
if (e.kind !== "control" || e.meta.action !== "grounding" || e.meta.via !== "model")
|
|
590
588
|
continue;
|
|
591
589
|
const seq = e.meta.answer_seq;
|
|
@@ -673,8 +671,7 @@ const STEPS = 100;
|
|
|
673
671
|
export function judgeRequest(sessionId, answer, lines) {
|
|
674
672
|
const steps = [];
|
|
675
673
|
const callTools = new Map();
|
|
676
|
-
for (const
|
|
677
|
-
const e = JSON.parse(l);
|
|
674
|
+
for (const e of envelopesOf(lines)) {
|
|
678
675
|
if (e.seq >= answer.seq)
|
|
679
676
|
break;
|
|
680
677
|
const m = e.meta;
|
|
@@ -735,8 +732,7 @@ export function parseJudgeAnswer(v) {
|
|
|
735
732
|
/** §12.1: JUDGE_FAILED for each rubric step a judge failed, from its recorded verdicts. */
|
|
736
733
|
export function judgeFindings(lines, bodies) {
|
|
737
734
|
const out = [];
|
|
738
|
-
for (const
|
|
739
|
-
const e = JSON.parse(l);
|
|
735
|
+
for (const e of envelopesOf(lines)) {
|
|
740
736
|
if (e.kind !== "control" || e.meta.action !== "judge" || e.meta.via !== "model")
|
|
741
737
|
continue;
|
|
742
738
|
const seq = e.meta.answer_seq;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type GuardVerdict = "allow" | "hold" | "block";
|
|
2
|
+
export type GuardMode = "off" | "monitor" | "enforce";
|
|
3
|
+
export interface GuardConfig {
|
|
4
|
+
canary_tools: string[];
|
|
5
|
+
canary_tokens: string[];
|
|
6
|
+
untrusted_sources: string[];
|
|
7
|
+
sensitive: string[];
|
|
8
|
+
egress: string[];
|
|
9
|
+
high_risk: string[];
|
|
10
|
+
exfil_window_seconds: number;
|
|
11
|
+
}
|
|
12
|
+
export interface GuardFinding {
|
|
13
|
+
rule: string;
|
|
14
|
+
severity: "medium" | "high" | "critical";
|
|
15
|
+
/** The OWASP Top 10 for Agentic Applications entry. */
|
|
16
|
+
asi: string;
|
|
17
|
+
detail: string;
|
|
18
|
+
}
|
|
19
|
+
export interface GuardState {
|
|
20
|
+
taint: {
|
|
21
|
+
source: string;
|
|
22
|
+
markers: number;
|
|
23
|
+
} | null;
|
|
24
|
+
sensitive: {
|
|
25
|
+
tool: string;
|
|
26
|
+
at_ms: number;
|
|
27
|
+
} | null;
|
|
28
|
+
pins: Record<string, string>;
|
|
29
|
+
changed: string[];
|
|
30
|
+
}
|
|
31
|
+
/** A tool call: its names (an MCP tool has two, `mcp__<server>__<tool>` first), its arguments. */
|
|
32
|
+
export interface GuardCall {
|
|
33
|
+
names: string[];
|
|
34
|
+
args: unknown;
|
|
35
|
+
at_ms: number;
|
|
36
|
+
}
|
|
37
|
+
/** The strictest of several modes (off < monitor < enforce); unknown values count as off. */
|
|
38
|
+
export declare function guardMode(...modes: Array<string | null | undefined>): GuardMode;
|
|
39
|
+
/** spec/guard.md §1: a guard file, checked; throws on anything wrong. */
|
|
40
|
+
export declare function loadGuardConfig(doc: unknown): GuardConfig;
|
|
41
|
+
/** A session's state before anything was seen. */
|
|
42
|
+
export declare function guardState(): GuardState;
|
|
43
|
+
/** spec/guard.md §2: the decision on one call, and the state after it. */
|
|
44
|
+
export declare function guardCheck(config: GuardConfig, state: GuardState, call: GuardCall): {
|
|
45
|
+
verdict: GuardVerdict;
|
|
46
|
+
findings: GuardFinding[];
|
|
47
|
+
state: GuardState;
|
|
48
|
+
};
|
|
49
|
+
/** spec/guard.md §3: a tool's result coming back. From an untrusted source, it taints the session. */
|
|
50
|
+
export declare function guardObserve(config: GuardConfig, state: GuardState, result: {
|
|
51
|
+
names: string[];
|
|
52
|
+
content: unknown;
|
|
53
|
+
}): GuardState;
|
|
54
|
+
/** spec/guard.md §4: a tools/list answer's definition hashes. The first one seen for each tool is its
|
|
55
|
+
* pin; a later, different one marks the tool changed for the rest of the session. */
|
|
56
|
+
export declare function guardPin(state: GuardState, defs: Record<string, string>): {
|
|
57
|
+
state: GuardState;
|
|
58
|
+
changed: string[];
|
|
59
|
+
};
|
|
60
|
+
export interface GuardToolResult {
|
|
61
|
+
/** The model's id for the call (`tool_use_id`, `tool_call_id`, `call_id`). */
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
args: unknown;
|
|
65
|
+
content: unknown;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* spec/guard.md §5: the tool results a model request's newest turn hands back, each with the call
|
|
69
|
+
* that asked for it (from the same request). Anthropic Messages, Chat Completions and Responses.
|
|
70
|
+
* A result whose call isn't in the request (a Responses `previous_response_id`) is left out.
|
|
71
|
+
*/
|
|
72
|
+
export declare function guardToolResults(body: unknown): GuardToolResult[];
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// spec/guard.md: the inline guard. Decides on a tool call before the gateway forwards it (an MCP
|
|
2
|
+
// tools/call) or before it accepts the tool's result back (a model's tool_use): canary tools and
|
|
3
|
+
// tokens, tool pinning, exfiltration sequences, holds on high-risk calls after untrusted content.
|
|
4
|
+
// Pure: the caller keeps the state and passes the clock. Ported from @zanii/guard (the rules and
|
|
5
|
+
// their names), with Blackbox's own injection rules (analysis/injection.ts). Mirrors
|
|
6
|
+
// analysis/guard.py; pinned by spec/vectors/guard.json.
|
|
7
|
+
import { injectionRule } from "./injection.js";
|
|
8
|
+
const LISTS = [
|
|
9
|
+
"canary_tools",
|
|
10
|
+
"canary_tokens",
|
|
11
|
+
"untrusted_sources",
|
|
12
|
+
"sensitive",
|
|
13
|
+
"egress",
|
|
14
|
+
"high_risk",
|
|
15
|
+
];
|
|
16
|
+
const MODES = ["off", "monitor", "enforce"];
|
|
17
|
+
/** The strictest of several modes (off < monitor < enforce); unknown values count as off. */
|
|
18
|
+
export function guardMode(...modes) {
|
|
19
|
+
let best = 0;
|
|
20
|
+
for (const m of modes)
|
|
21
|
+
best = Math.max(best, MODES.indexOf(m));
|
|
22
|
+
return MODES[best];
|
|
23
|
+
}
|
|
24
|
+
/** spec/guard.md §1: a guard file, checked; throws on anything wrong. */
|
|
25
|
+
export function loadGuardConfig(doc) {
|
|
26
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc))
|
|
27
|
+
throw new Error("guard: the file must be a JSON object");
|
|
28
|
+
const d = doc;
|
|
29
|
+
if (d.version !== 1)
|
|
30
|
+
throw new Error("guard: version must be 1");
|
|
31
|
+
for (const k of Object.keys(d))
|
|
32
|
+
if (k !== "version" &&
|
|
33
|
+
k !== "exfil_window_seconds" &&
|
|
34
|
+
!LISTS.includes(k))
|
|
35
|
+
throw new Error(`guard: unknown field ${k}`);
|
|
36
|
+
const out = { exfil_window_seconds: 600 };
|
|
37
|
+
for (const k of LISTS) {
|
|
38
|
+
const v = d[k] ?? [];
|
|
39
|
+
const re = k === "canary_tokens" ? /^[A-Za-z0-9_.:-]{12,128}$/ : /^[^\s]{1,256}$/;
|
|
40
|
+
if (!Array.isArray(v) || v.length > 100 || !v.every((x) => typeof x === "string" && re.test(x)))
|
|
41
|
+
throw new Error(k === "canary_tokens"
|
|
42
|
+
? "guard: canary_tokens must be at most 100 strings of 12-128 letters, digits or _.:-"
|
|
43
|
+
: `guard: ${k} must be at most 100 tool patterns of 1-256 characters, no spaces`);
|
|
44
|
+
out[k] = v;
|
|
45
|
+
}
|
|
46
|
+
const w = d.exfil_window_seconds;
|
|
47
|
+
if (w !== undefined) {
|
|
48
|
+
if (!Number.isSafeInteger(w) || w < 1 || w > 86_400)
|
|
49
|
+
throw new Error("guard: exfil_window_seconds must be an integer 1-86400");
|
|
50
|
+
out.exfil_window_seconds = w;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** A session's state before anything was seen. */
|
|
55
|
+
export function guardState() {
|
|
56
|
+
return { taint: null, sensitive: null, pins: {}, changed: [] };
|
|
57
|
+
}
|
|
58
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
59
|
+
const glob = (p) => new RegExp(`^${p.split("*").map(escapeRe).join(".*")}$`);
|
|
60
|
+
const matches = (patterns, names) => patterns.some((p) => {
|
|
61
|
+
const re = glob(p);
|
|
62
|
+
return names.some((n) => re.test(n));
|
|
63
|
+
});
|
|
64
|
+
/** Every string in a JSON value, depth first, in key order, one per line. */
|
|
65
|
+
function textOf(v) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const walk = (x) => {
|
|
68
|
+
if (typeof x === "string")
|
|
69
|
+
out.push(x);
|
|
70
|
+
else if (Array.isArray(x))
|
|
71
|
+
for (const y of x)
|
|
72
|
+
walk(y);
|
|
73
|
+
else if (x && typeof x === "object")
|
|
74
|
+
for (const y of Object.values(x))
|
|
75
|
+
walk(y);
|
|
76
|
+
};
|
|
77
|
+
walk(v);
|
|
78
|
+
return out.join("\n");
|
|
79
|
+
}
|
|
80
|
+
/** spec/guard.md §2: the decision on one call, and the state after it. */
|
|
81
|
+
export function guardCheck(config, state, call) {
|
|
82
|
+
const tool = call.names[0] ?? "";
|
|
83
|
+
const findings = [];
|
|
84
|
+
let verdict = "allow";
|
|
85
|
+
const add = (f, v) => {
|
|
86
|
+
findings.push(f);
|
|
87
|
+
if (v === "block" || (v === "hold" && verdict === "allow"))
|
|
88
|
+
verdict = v;
|
|
89
|
+
};
|
|
90
|
+
const text = textOf(call.args);
|
|
91
|
+
if (matches(config.canary_tools, call.names))
|
|
92
|
+
add({
|
|
93
|
+
rule: "canary-tool",
|
|
94
|
+
severity: "critical",
|
|
95
|
+
asi: "ASI10",
|
|
96
|
+
detail: `${tool} is a canary no legitimate run calls`,
|
|
97
|
+
}, "block");
|
|
98
|
+
if (config.canary_tokens.some((t) => text.includes(t)))
|
|
99
|
+
add({
|
|
100
|
+
rule: "canary-token",
|
|
101
|
+
severity: "critical",
|
|
102
|
+
asi: "ASI02",
|
|
103
|
+
detail: `a planted canary token is leaving through ${tool}`,
|
|
104
|
+
}, "block");
|
|
105
|
+
if (state.changed.includes(tool))
|
|
106
|
+
add({
|
|
107
|
+
rule: "tool-changed",
|
|
108
|
+
severity: "high",
|
|
109
|
+
asi: "ASI04",
|
|
110
|
+
detail: `${tool} changed since it was first listed in this session`,
|
|
111
|
+
}, "block");
|
|
112
|
+
const s = state.sensitive;
|
|
113
|
+
if (s &&
|
|
114
|
+
matches(config.egress, call.names) &&
|
|
115
|
+
call.at_ms - s.at_ms <= config.exfil_window_seconds * 1000)
|
|
116
|
+
add({
|
|
117
|
+
rule: "exfil-sequence",
|
|
118
|
+
severity: "high",
|
|
119
|
+
asi: "ASI02",
|
|
120
|
+
detail: `egress through ${tool} right after reading ${s.tool}`,
|
|
121
|
+
}, "block");
|
|
122
|
+
if (injectionRule(text) !== null)
|
|
123
|
+
add({
|
|
124
|
+
rule: "injection-in-args",
|
|
125
|
+
severity: "high",
|
|
126
|
+
asi: "ASI01",
|
|
127
|
+
detail: `the arguments to ${tool} carry instructions aimed at an agent`,
|
|
128
|
+
}, "hold");
|
|
129
|
+
const t = state.taint;
|
|
130
|
+
if (t && matches(config.high_risk, call.names))
|
|
131
|
+
add({
|
|
132
|
+
rule: "tainted-high-risk",
|
|
133
|
+
severity: t.markers > 0 ? "high" : "medium",
|
|
134
|
+
asi: "ASI01",
|
|
135
|
+
detail: `a high-risk call after reading untrusted content from ${t.source}${t.markers > 0 ? " that contained injection attempts" : ""}`,
|
|
136
|
+
}, "hold");
|
|
137
|
+
const next = matches(config.sensitive, call.names)
|
|
138
|
+
? { ...state, sensitive: { tool, at_ms: call.at_ms } }
|
|
139
|
+
: state;
|
|
140
|
+
return { verdict, findings, state: next };
|
|
141
|
+
}
|
|
142
|
+
/** spec/guard.md §3: a tool's result coming back. From an untrusted source, it taints the session. */
|
|
143
|
+
export function guardObserve(config, state, result) {
|
|
144
|
+
if (!matches(config.untrusted_sources, result.names))
|
|
145
|
+
return state;
|
|
146
|
+
const marked = injectionRule(textOf(result.content)) !== null ? 1 : 0;
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
taint: { source: result.names[0] ?? "", markers: (state.taint?.markers ?? 0) + marked },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** spec/guard.md §4: a tools/list answer's definition hashes. The first one seen for each tool is its
|
|
153
|
+
* pin; a later, different one marks the tool changed for the rest of the session. */
|
|
154
|
+
export function guardPin(state, defs) {
|
|
155
|
+
const pins = { ...state.pins };
|
|
156
|
+
const changed = [];
|
|
157
|
+
for (const [name, hash] of Object.entries(defs)) {
|
|
158
|
+
const pin = pins[name];
|
|
159
|
+
if (pin === undefined)
|
|
160
|
+
pins[name] = hash;
|
|
161
|
+
else if (pin !== hash && !state.changed.includes(name))
|
|
162
|
+
changed.push(name);
|
|
163
|
+
}
|
|
164
|
+
return { state: { ...state, pins, changed: [...state.changed, ...changed] }, changed };
|
|
165
|
+
}
|
|
166
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
167
|
+
const parsed = (v) => {
|
|
168
|
+
if (typeof v !== "string")
|
|
169
|
+
return v ?? {};
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(v);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return v;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* spec/guard.md §5: the tool results a model request's newest turn hands back, each with the call
|
|
179
|
+
* that asked for it (from the same request). Anthropic Messages, Chat Completions and Responses.
|
|
180
|
+
* A result whose call isn't in the request (a Responses `previous_response_id`) is left out.
|
|
181
|
+
*/
|
|
182
|
+
export function guardToolResults(body) {
|
|
183
|
+
if (!isObj(body))
|
|
184
|
+
return [];
|
|
185
|
+
const out = [];
|
|
186
|
+
const calls = new Map();
|
|
187
|
+
const messages = body.messages;
|
|
188
|
+
if (Array.isArray(messages) && messages.length) {
|
|
189
|
+
let i = messages.length - 1;
|
|
190
|
+
while (i > 0 && messages[i]?.role === "tool")
|
|
191
|
+
i -= 1;
|
|
192
|
+
for (const m of messages.slice(0, i + 1).filter(isObj)) {
|
|
193
|
+
if (m.role !== "assistant")
|
|
194
|
+
continue;
|
|
195
|
+
if (Array.isArray(m.content))
|
|
196
|
+
for (const b of m.content.filter(isObj))
|
|
197
|
+
if (b.type === "tool_use" && typeof b.id === "string" && typeof b.name === "string")
|
|
198
|
+
calls.set(b.id, { name: b.name, args: b.input ?? {} });
|
|
199
|
+
if (Array.isArray(m.tool_calls))
|
|
200
|
+
for (const c of m.tool_calls.filter(isObj)) {
|
|
201
|
+
const f = isObj(c.function) ? c.function : {};
|
|
202
|
+
if (typeof c.id === "string" && typeof f.name === "string")
|
|
203
|
+
calls.set(c.id, { name: f.name, args: parsed(f.arguments) });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const take = (id, content) => {
|
|
207
|
+
const c = typeof id === "string" ? calls.get(id) : undefined;
|
|
208
|
+
if (c)
|
|
209
|
+
out.push({ id: id, name: c.name, args: c.args, content: content ?? null });
|
|
210
|
+
};
|
|
211
|
+
for (const m of messages.slice(i).filter(isObj)) {
|
|
212
|
+
if (m.role === "tool")
|
|
213
|
+
take(m.tool_call_id, m.content);
|
|
214
|
+
else if (m.role === "user" && Array.isArray(m.content))
|
|
215
|
+
for (const b of m.content.filter(isObj))
|
|
216
|
+
if (b.type === "tool_result")
|
|
217
|
+
take(b.tool_use_id, b.content);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
const input = body.input;
|
|
222
|
+
if (!Array.isArray(input) || input.length === 0)
|
|
223
|
+
return out;
|
|
224
|
+
let i = input.length - 1;
|
|
225
|
+
while (i > 0 && input[i]?.type === "function_call_output")
|
|
226
|
+
i -= 1;
|
|
227
|
+
for (const item of input.slice(0, i + 1).filter(isObj))
|
|
228
|
+
if (item.type === "function_call" &&
|
|
229
|
+
typeof item.call_id === "string" &&
|
|
230
|
+
typeof item.name === "string")
|
|
231
|
+
calls.set(item.call_id, { name: item.name, args: parsed(item.arguments) });
|
|
232
|
+
for (const item of input.slice(i).filter(isObj)) {
|
|
233
|
+
if (item.type !== "function_call_output" || typeof item.call_id !== "string")
|
|
234
|
+
continue;
|
|
235
|
+
const c = calls.get(item.call_id);
|
|
236
|
+
if (c)
|
|
237
|
+
out.push({ id: item.call_id, name: c.name, args: c.args, content: item.output ?? null });
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// right after an error; quoting a tool it never called. Pure functions over the record, no model.
|
|
4
4
|
// Mirrors sdks/python/src/zanii_blackbox/analysis/hallucination.py; pinned by
|
|
5
5
|
// spec/vectors/hallucination.json.
|
|
6
|
-
import { callsOf } from "../reconcile/record.js";
|
|
6
|
+
import { callsOf, envelopesOf } from "../reconcile/record.js";
|
|
7
7
|
import { canonical, isObj } from "../reconcile/shared.js";
|
|
8
8
|
import { toolCallsOf, toolResultsOf } from "./detectors.js";
|
|
9
9
|
const decoder = new TextDecoder();
|
|
@@ -217,7 +217,7 @@ export function toolHallucinations(lines, bodies) {
|
|
|
217
217
|
const calls = callsOf(lines, bodies);
|
|
218
218
|
const modelCalls = toolCallsOf(calls);
|
|
219
219
|
const results = toolResultsOf(calls, lines);
|
|
220
|
-
const events = lines
|
|
220
|
+
const events = envelopesOf(lines);
|
|
221
221
|
const out = [];
|
|
222
222
|
const push = (at, finding) => out.push({ ...finding, at });
|
|
223
223
|
// the model side: each tool call against the request that produced it
|
package/dist/analysis/index.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface Finding {
|
|
|
20
20
|
[key: string]: string | number | boolean;
|
|
21
21
|
};
|
|
22
22
|
detail?: string;
|
|
23
|
+
/** The numbers behind it (integers), recorded with it but not part of its identity. */
|
|
24
|
+
figures?: {
|
|
25
|
+
[key: string]: number;
|
|
26
|
+
};
|
|
23
27
|
}
|
|
24
28
|
export interface AnalyzeOptions {
|
|
25
29
|
/** Body bytes by hash (SDK events carry their ids in the body; requests and responses are bodies). */
|
|
@@ -47,8 +51,22 @@ export interface Ev extends Envelope {
|
|
|
47
51
|
[key: string]: Json;
|
|
48
52
|
};
|
|
49
53
|
}
|
|
50
|
-
/** Parses the lines once, attaching SDK event bodies.
|
|
54
|
+
/** Parses the lines once, attaching SDK event bodies. On a tracked record (`Analyzer`), only the
|
|
55
|
+
* lines added since the last call are read. */
|
|
51
56
|
export declare function eventsOf(lines: readonly string[], bodies: AnalyzeOptions["bodies"]): Ev[];
|
|
57
|
+
/**
|
|
58
|
+
* spec/findings.md §14 (G33): the analyses over one session's growing record. Each `findings` call
|
|
59
|
+
* reads only the lines added since the last one (each line is parsed once, the calls are rebuilt
|
|
60
|
+
* from the new events only) and gives exactly what `analyze` gives over the whole record.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Analyzer {
|
|
63
|
+
readonly lines: string[];
|
|
64
|
+
readonly bodies: AnalyzeOptions["bodies"];
|
|
65
|
+
constructor(bodies: AnalyzeOptions["bodies"]);
|
|
66
|
+
/** Appends the record's new lines (in seq order; a record only grows). */
|
|
67
|
+
push(lines: Iterable<string>): void;
|
|
68
|
+
findings(options: Omit<AnalyzeOptions, "bodies">): Finding[];
|
|
69
|
+
}
|
|
52
70
|
/** Every analysis, in spec order. */
|
|
53
71
|
export declare function analyze(lines: readonly string[], options: AnalyzeOptions): Finding[];
|
|
54
72
|
/**
|
|
@@ -94,6 +112,8 @@ export declare function toolDrifted(events: Ev[]): Finding[];
|
|
|
94
112
|
* the call went to an MCP tool. A request refused for it (`outbound_blocked`) sent nothing.
|
|
95
113
|
*/
|
|
96
114
|
export declare function secretSent(events: Ev[]): Finding[];
|
|
115
|
+
/** spec/mcp.md §6: an MCP server asked the user for a credential through `elicitation/create`. */
|
|
116
|
+
export declare function elicitationSecret(events: Ev[]): Finding[];
|
|
97
117
|
/** spec/findings.md §12b: a person the agent served said an answer was wrong (👎), once per answer. */
|
|
98
118
|
export declare function userDownvotes(events: Ev[]): Finding[];
|
|
99
119
|
/** N6 (idea S3): the gateway refused a request that carried the session's own token. */
|