@zanii/blackbox 0.17.0 → 0.19.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.
@@ -232,6 +232,16 @@ export const FAULTS = {
232
232
  en: "A tool's definition changed mid-session",
233
233
  ar: "تغيّر تعريف أداة أثناء الجلسة",
234
234
  },
235
+ USER_DOWNVOTE: {
236
+ fault: "BBX-4601",
237
+ en: "The person served said an answer was wrong",
238
+ ar: "قال الشخص المخدوم إن الإجابة خاطئة",
239
+ },
240
+ SECRET_SENT: {
241
+ fault: "BBX-9106",
242
+ en: "A credential was sent to a model or tool",
243
+ ar: "أُرسلت بيانات اعتماد إلى نموذج أو أداة",
244
+ },
235
245
  PROMPT_INJECTION: {
236
246
  fault: "BBX-7403",
237
247
  squawk: "HIJACK",
@@ -88,6 +88,14 @@ export declare function toolChanged(events: Ev[]): Finding[];
88
88
  * (`meta.tool_defs_prev`); a tool listed with another hash is a finding, once per server and tool.
89
89
  */
90
90
  export declare function toolDrifted(events: Ev[]): Finding[];
91
+ /**
92
+ * spec/data.md §6a: a credential left for a model or a tool. The record's own redaction counted it
93
+ * (`meta.redacted.secrets`); what the model was sent had fewer masked (`meta.outbound_redacted`), or
94
+ * the call went to an MCP tool. A request refused for it (`outbound_blocked`) sent nothing.
95
+ */
96
+ export declare function secretSent(events: Ev[]): Finding[];
97
+ /** spec/findings.md §12b: a person the agent served said an answer was wrong (👎), once per answer. */
98
+ export declare function userDownvotes(events: Ev[]): Finding[];
91
99
  /** N6 (idea S3): the gateway refused a request that carried the session's own token. */
92
100
  export declare function tokenMisuse(events: Ev[]): Finding[];
93
101
  /** Stage 2 R3: a `verify` check that passed, then failed later in the run. */
@@ -58,6 +58,8 @@ export function analyze(lines, options) {
58
58
  ...tokenMisuse(events),
59
59
  ...toolChanged(events),
60
60
  ...toolDrifted(events),
61
+ ...secretSent(events),
62
+ ...userDownvotes(events),
61
63
  ...injectionFindings(lines, options.bodies),
62
64
  ...verifyRegression(events),
63
65
  ...falseSuccess(events, now),
@@ -440,6 +442,69 @@ export function toolDrifted(events) {
440
442
  }
441
443
  return out;
442
444
  }
445
+ /**
446
+ * spec/data.md §6a: a credential left for a model or a tool. The record's own redaction counted it
447
+ * (`meta.redacted.secrets`); what the model was sent had fewer masked (`meta.outbound_redacted`), or
448
+ * the call went to an MCP tool. A request refused for it (`outbound_blocked`) sent nothing.
449
+ */
450
+ export function secretSent(events) {
451
+ const blocked = new Set();
452
+ for (const e of events)
453
+ if (e.kind === "llm.incomplete" &&
454
+ e.meta.reason === "outbound_blocked" &&
455
+ typeof e.meta.request_seq === "number")
456
+ blocked.add(e.meta.request_seq);
457
+ const count = (v) => {
458
+ const n = v?.secrets;
459
+ return typeof n === "number" && Number.isSafeInteger(n) && n > 0 ? n : 0;
460
+ };
461
+ const out = [];
462
+ for (const e of events) {
463
+ let to = null;
464
+ let n = 0;
465
+ if (e.kind === "llm.request" && !blocked.has(e.seq)) {
466
+ n = count(e.meta.redacted) - count(e.meta.outbound_redacted);
467
+ to = "model";
468
+ }
469
+ else if (e.kind === "tool.call" && typeof e.meta.tool === "string") {
470
+ n = count(e.meta.redacted);
471
+ to = "tool";
472
+ }
473
+ if (!to || n <= 0)
474
+ continue;
475
+ out.push({
476
+ code: "SECRET_SENT",
477
+ source: "detectors",
478
+ severity: "warning",
479
+ ref: { seq: e.seq, to, count: n, ...(to === "tool" ? { tool: e.meta.tool } : {}) },
480
+ detail: to === "model"
481
+ ? "A credential (a key, a token, a password) went to the model provider in the request."
482
+ : "A credential (a key, a token, a password) went to a tool in its arguments; the gateway's credential broker keeps them out of the agent's hands.",
483
+ });
484
+ }
485
+ return out;
486
+ }
487
+ /** spec/findings.md §12b: a person the agent served said an answer was wrong (👎), once per answer. */
488
+ export function userDownvotes(events) {
489
+ const out = [];
490
+ const seen = new Set();
491
+ for (const e of events) {
492
+ const about = e.meta.about_seq;
493
+ if (e.kind !== "control" || e.meta.action !== "feedback" || e.meta.rating !== "down")
494
+ continue;
495
+ if (typeof about !== "number" || seen.has(about))
496
+ continue;
497
+ seen.add(about);
498
+ out.push({
499
+ code: "USER_DOWNVOTE",
500
+ source: "detectors",
501
+ severity: "advisory",
502
+ ref: { seq: about, feedback_seq: e.seq },
503
+ detail: "The person this answer was for said it was wrong.",
504
+ });
505
+ }
506
+ return out;
507
+ }
443
508
  /** N6 (idea S3): the gateway refused a request that carried the session's own token. */
444
509
  export function tokenMisuse(events) {
445
510
  return events
@@ -56,6 +56,7 @@ export const TAXONOMY = {
56
56
  TOOL_CHANGED: { owasp: ["ASI04"], atlas: ["AML.T0110.002"] },
57
57
  TOOL_DRIFTED: { owasp: ["ASI04"], atlas: ["AML.T0110.002"] },
58
58
  PROMPT_INJECTION: { owasp: ["ASI01"], atlas: ["AML.T0051"] },
59
+ SECRET_SENT: { owasp: ["ASI03"], atlas: ["AML.T0057"] },
59
60
  TOOL_OUTPUT_MISMATCH: { owasp: ["ASI04", "ASI06"], atlas: ["AML.T0110.002", "AML.T0099"] },
60
61
  TOOL_WITNESS_INVALID: { owasp: ["ASI04"], atlas: ["AML.T0110.002"] },
61
62
  };
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // The `blackbox` command. Mirrors the Python SDK's `python -m zanii_blackbox.<command>` entry points.
3
+ import { spawnSync } from "node:child_process";
3
4
  import { createHash } from "node:crypto";
4
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
6
  import { join } from "node:path";
@@ -25,6 +26,8 @@ const USAGE = `usage (spec/cli.md):
25
26
  blackbox occurrence-pack <session_id> --framework <id>
26
27
  blackbox file <session_id> --framework <id> --reference <ref> [--channel <c>] [--filed-at <iso>]
27
28
  blackbox replay <session_id> [--fork] [--fork-after N] [--label L] [--drill <drill.json>]
29
+ blackbox scenario save <session_id> [--out <file>]
30
+ blackbox scenario run <scenario.json>... -- <agent command> (1 when an incident's findings recur)
28
31
  blackbox tail <session_id> [--after-seq N]
29
32
  blackbox close <session_id> [--outcome success|failure|abandoned] [--note <text>]
30
33
  blackbox resume <session_id> [--token <resume token>] [--note <text>] [--force] (a paused session needs no token)
@@ -94,6 +97,8 @@ async function main(argv) {
94
97
  return occurrenceCommand(rest);
95
98
  if (command === "replay")
96
99
  return replayCommand(rest);
100
+ if (command === "scenario")
101
+ return scenarioCommand(rest);
97
102
  if (command === "analyze")
98
103
  return analyzeCommand(rest);
99
104
  if (command === "verify-certificate")
@@ -405,9 +410,10 @@ class ServerError extends Error {
405
410
  /** GET from the gateway's admin API (BLACKBOX_URL + BLACKBOX_ADMIN_KEY). */
406
411
  async function admin(path, body, allow = []) {
407
412
  const url = process.env.BLACKBOX_URL;
408
- const key = process.env.BLACKBOX_ADMIN_KEY;
413
+ // a tenant's key works too, for what a tenant may read (the scenarios in CI)
414
+ const key = process.env.BLACKBOX_ADMIN_KEY || process.env.BLACKBOX_API_KEY;
409
415
  if (!url || !key)
410
- throw new ServerError("BLACKBOX_URL and BLACKBOX_ADMIN_KEY must be set");
416
+ throw new ServerError("BLACKBOX_URL and BLACKBOX_ADMIN_KEY must be set (or a tenant's BLACKBOX_API_KEY)");
411
417
  let res;
412
418
  try {
413
419
  res = await fetch(`${url.replace(/\/+$/, "")}${path}`, {
@@ -641,6 +647,81 @@ async function replayCommand(rest) {
641
647
  process.stdout.write(`${JSON.stringify(doc)}\n`);
642
648
  return 0;
643
649
  }
650
+ /** spec/replay.md §3a: saves an incident as a regression test, or reruns the agent against each
651
+ * saved one and fails when any of the incident's findings comes back. */
652
+ async function scenarioCommand(rest) {
653
+ const at = rest.indexOf("--");
654
+ const command = at === -1 ? [] : rest.slice(at + 1);
655
+ const { flags, positional } = parse(at === -1 ? rest : rest.slice(0, at), []);
656
+ const [verb, ...args] = positional;
657
+ if (verb === "save" && args.length === 1 && SESSION_ID.test(args[0])) {
658
+ const doc = await (await admin(`/v1/sessions/${args[0]}/scenario`)).json();
659
+ const out = flags.out || `${args[0]}.scenario.json`;
660
+ writeFileSync(out, `${JSON.stringify(doc)}\n`);
661
+ process.stdout.write(`${out}\n`);
662
+ return 0;
663
+ }
664
+ if (verb !== "run" || args.length === 0 || command.length === 0)
665
+ return usage();
666
+ let failed = false;
667
+ for (const file of args) {
668
+ const result = await scenarioRun(file, command);
669
+ process.stdout.write(`${JSON.stringify(result)}\n`);
670
+ failed ||= !result.ok;
671
+ }
672
+ return failed ? 1 : 0;
673
+ }
674
+ /** One scenario: a fork of its incident, the agent run against it (its output on stderr, so
675
+ * stdout stays the results), closed, and its findings compared with the incident's. */
676
+ async function scenarioRun(file, command) {
677
+ let scenario;
678
+ try {
679
+ scenario = JSON.parse(readFileSync(file, "utf8"));
680
+ }
681
+ catch (error) {
682
+ throw new ServerError(`${file}: ${error instanceof Error ? error.message : error}`);
683
+ }
684
+ const source = scenario?.source;
685
+ const expect = scenario?.expect?.finding_codes;
686
+ if (scenario?.v !== 1 || typeof source !== "string" || !SESSION_ID.test(source))
687
+ throw new ServerError(`${file}: not a scenario (spec/replay.md §3)`);
688
+ const before = (expect && typeof expect === "object" ? expect : {});
689
+ const label = `regression: ${scenario.label || source}`.slice(0, 200);
690
+ const opened = (await (await admin("/v1/sessions", { label, replay: { of: source, fork: true } })).json());
691
+ const sid = String(opened.session_id);
692
+ const token = String(opened.token);
693
+ const url = String(process.env.BLACKBOX_URL).replace(/\/+$/, "");
694
+ const run = spawnSync(command[0], command.slice(1), {
695
+ stdio: ["inherit", 2, "inherit"], // the agent prints to stderr: stdout is the results
696
+ env: {
697
+ ...process.env,
698
+ BLACKBOX_SESSION_ID: sid,
699
+ BLACKBOX_TOKEN: token,
700
+ BLACKBOX_SCENARIO: file,
701
+ // the official clients read these: an agent that doesn't set its own goes through the fork
702
+ ANTHROPIC_BASE_URL: `${url}/anthropic`,
703
+ ANTHROPIC_API_KEY: token,
704
+ OPENAI_BASE_URL: `${url}/openai/v1`,
705
+ OPENAI_API_KEY: token,
706
+ },
707
+ });
708
+ if (run.error)
709
+ process.stderr.write(`blackbox: can't run ${command[0]}: ${run.error.message}\n`);
710
+ const code = run.error ? 127 : (run.status ?? 1);
711
+ await admin(`/v1/sessions/${sid}/close`, { outcome: code === 0 ? "success" : "failure" });
712
+ const after = (await (await admin(`/v1/sessions/${sid}/scenario`)).json());
713
+ const got = (after?.expect?.finding_codes ?? {});
714
+ const recurred = Object.fromEntries(Object.keys(before).flatMap((c) => (got[c] ? [[c, got[c]]] : [])));
715
+ return {
716
+ scenario: file,
717
+ source,
718
+ session_id: sid,
719
+ exit_code: code,
720
+ ok: code === 0 && Object.keys(recurred).length === 0,
721
+ recurred,
722
+ new: Object.fromEntries(Object.entries(got).filter(([c]) => !(c in before))),
723
+ };
724
+ }
644
725
  /** Audit S12: an exported bundle, analysed offline: it must verify first, then the summary, the
645
726
  * findings it recorded, and the findings this SDK's analyses find in it now. */
646
727
  async function analyzeCommand(rest) {
@@ -1,7 +1,8 @@
1
1
  import { type CompiledPolicy } from "../policy/index.ts";
2
2
  import { type EventSink } from "./shared.ts";
3
+ import { type WitnessOptions, type WitnessSink } from "./witness.ts";
3
4
  /** A BlackboxSession, or anything with the same methods. */
4
- export interface AgentSink extends EventSink {
5
+ export interface AgentSink extends EventSink, WitnessSink {
5
6
  state?(): Promise<{
6
7
  control?: {
7
8
  state?: string;
@@ -29,7 +30,7 @@ export declare function blackboxAgentMiddleware(session: AgentSink, options?: {
29
30
  checkBlock?: boolean;
30
31
  /** Files with a machine-managed block (idea S5): file tools may not change it. */
31
32
  protect?: readonly string[];
32
- }): {
33
+ } & WitnessOptions): {
33
34
  [x: symbol]: true;
34
35
  name: string;
35
36
  beforeAgent(): Promise<undefined>;
@@ -11,6 +11,7 @@ import { decide } from "../policy/index.js";
11
11
  import { stableEventId } from "../session/index.js";
12
12
  import { protectedEdit } from "./protect.js";
13
13
  import { llmIds, neverThrow, providerOf } from "./shared.js";
14
+ import { shellCommand, toolText } from "./witness.js";
14
15
  /** Thrown by `beforeAgent` when the gateway has stopped this session (spec/control.md). */
15
16
  export class BlackboxStoppedError extends Error {
16
17
  constructor(state) {
@@ -117,13 +118,28 @@ export function blackboxAgentMiddleware(session, options = {}) {
117
118
  return (await refusal(call, `a second person's approval is needed (${answer})`));
118
119
  }
119
120
  }
121
+ const receipt = async (phase) => {
122
+ if (!options.workspace || !key || !session.workspaceReceipt)
123
+ return;
124
+ try {
125
+ await session.workspaceReceipt(phase, key, { tool: call.name, cwd: options.workspace });
126
+ }
127
+ catch { } // a receipt never replaces the tool's own result or error
128
+ };
129
+ await receipt("pre");
120
130
  try {
121
131
  const out = await handler(request);
132
+ await receipt("post");
133
+ const command = options.witnessShell ? shellCommand(call.args) : null;
134
+ const text = command ? toolText(out) : null;
135
+ if (command && text !== null)
136
+ neverThrow(() => session.witness?.(command, text, { cwd: options.workspace }));
122
137
  const status = out?.status;
123
138
  emit("tool.result", call.name, { ok: status !== "error", ...(key ? { run_id: key } : {}) }, key);
124
139
  return out;
125
140
  }
126
141
  catch (error) {
142
+ await receipt("post");
127
143
  if (isControl(error)) {
128
144
  emit("interrupt", call.name, { ...(key ? { run_id: key } : {}) }, key);
129
145
  }
@@ -1,6 +1,10 @@
1
1
  import { type EventSink } from "./shared.ts";
2
+ import { type WitnessOptions, type WitnessSink } from "./witness.ts";
2
3
  interface HookEmitter {
3
4
  on(event: any, listener: any): unknown;
4
5
  }
5
- export declare function attachBlackbox(runner: HookEmitter, session: EventSink): void;
6
+ /** `options.workspace`: a receipt of that git workspace before and after each tool call;
7
+ * `options.witnessShell`: re-run each read-only shell command and compare its output. The pre
8
+ * receipt's tree is taken synchronously in the listener, so before the tool runs. */
9
+ export declare function attachBlackbox(runner: HookEmitter, session: EventSink & WitnessSink, options?: WitnessOptions): void;
6
10
  export {};
@@ -5,23 +5,37 @@
5
5
  // The JS SDK's hooks are events on the Runner (agent / handoff / tool); it has no model-call event,
6
6
  // so model-call ids come from the gateway alone here.
7
7
  import { neverThrow } from "./shared.js";
8
+ import { shellCommand } from "./witness.js";
8
9
  const nameOf = (x) => String(x?.name ?? "agent").slice(0, 256);
9
10
  /** Audit K23: the SDK turns a tool's exception into this answer (its default error function), so a
10
11
  * tool that failed isn't recorded as a success. */
11
12
  const TOOL_ERROR = "An error occurred while running the tool.";
12
13
  const failed = (result) => typeof result === "string" && result.startsWith(TOOL_ERROR);
13
- export function attachBlackbox(runner, session) {
14
+ const argsOf = (details) => {
15
+ const raw = details?.toolCall?.arguments;
16
+ try {
17
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
18
+ }
19
+ catch {
20
+ return raw;
21
+ }
22
+ };
23
+ /** `options.workspace`: a receipt of that git workspace before and after each tool call;
24
+ * `options.witnessShell`: re-run each read-only shell command and compare its output. The pre
25
+ * receipt's tree is taken synchronously in the listener, so before the tool runs. */
26
+ export function attachBlackbox(runner, session, options = {}) {
27
+ const receipt = (phase, tool, details) => {
28
+ const callId = details?.toolCall?.callId;
29
+ if (options.workspace && callId && session.workspaceReceipt)
30
+ Promise.resolve(session.workspaceReceipt(phase, callId, { tool: nameOf(tool), cwd: options.workspace })).catch(() => { }); // never an unhandled rejection in the agent
31
+ };
14
32
  const on = (event, listener) => runner.on(event, ((...args) => neverThrow(() => listener(...args))));
15
33
  on("agent_start", (_context, agent) => session.event("step", nameOf(agent), {}));
16
34
  on("agent_handoff", (_context, from, to) => session.event("step", nameOf(to), { handoff_from: nameOf(from) }));
17
35
  on("agent_tool_start", (_context, agent, tool, details) => {
18
- const call = details
19
- ?.toolCall;
20
- let args = call?.arguments;
21
- try {
22
- args = typeof args === "string" ? JSON.parse(args) : args;
23
- }
24
- catch { }
36
+ const call = details?.toolCall;
37
+ const args = argsOf(details);
38
+ receipt("pre", tool, details);
25
39
  session.event("tool.call", nameOf(tool), {
26
40
  agent: nameOf(agent),
27
41
  ...(call?.callId ? { call_id: call.callId } : {}),
@@ -30,11 +44,15 @@ export function attachBlackbox(runner, session) {
30
44
  });
31
45
  on("agent_tool_end", (_context, agent, tool, result, details) => {
32
46
  const callId = details?.toolCall?.callId;
47
+ receipt("post", tool, details);
33
48
  session.event("tool.result", nameOf(tool), {
34
49
  ok: !failed(result),
35
50
  agent: nameOf(agent),
36
51
  ...(callId ? { call_id: callId } : {}),
37
52
  ...(failed(result) ? { error: String(result).slice(0, 500) } : {}),
38
53
  });
54
+ const command = options.witnessShell ? shellCommand(argsOf(details)) : null;
55
+ if (command && typeof result === "string" && !failed(result))
56
+ session.witness?.(command, result, { cwd: options.workspace });
39
57
  });
40
58
  }
@@ -0,0 +1,22 @@
1
+ /** What a framework hook passes for the receipts and the witness. Both off unless set. */
2
+ export interface WitnessOptions {
3
+ /** A directory in a git repository: a receipt of its files before and after each tool call. */
4
+ workspace?: string;
5
+ /** Re-run each read-only shell command the agent ran and compare its output (attestation.md). */
6
+ witnessShell?: boolean;
7
+ }
8
+ /** The session methods the hooks call, when the sink has them (a BlackboxSession does). */
9
+ export interface WitnessSink {
10
+ workspaceReceipt?(phase: "pre" | "post", toolUseId: string, options?: {
11
+ tool?: string;
12
+ cwd?: string;
13
+ }): Promise<string | null>;
14
+ witness?(command: string, claimed: string, options?: {
15
+ cwd?: string | undefined;
16
+ exitCode?: number;
17
+ }): unknown;
18
+ }
19
+ /** The shell command a tool call ran, from its arguments (`command`, `cmd`, or `commands`). */
20
+ export declare function shellCommand(args: unknown): string | null;
21
+ /** A tool's answer as text: a string, or a LangChain message's content (string or text parts). */
22
+ export declare function toolText(out: unknown): string | null;
@@ -0,0 +1,28 @@
1
+ // File receipts and a shell witness from any framework's tool hooks (spec/sdk.md §5.3). The
2
+ // session does the work (`workspaceReceipt`, `witness`); this finds what to give it in a tool call.
3
+ // Mirrors hooks/witness.py.
4
+ /** The shell command a tool call ran, from its arguments (`command`, `cmd`, or `commands`). */
5
+ export function shellCommand(args) {
6
+ const a = (args ?? {});
7
+ for (const k of ["command", "cmd", "commands"]) {
8
+ const v = a[k];
9
+ if (typeof v === "string" && v.trim())
10
+ return v;
11
+ if (Array.isArray(v) && v.length === 1 && typeof v[0] === "string")
12
+ return v[0];
13
+ }
14
+ return null;
15
+ }
16
+ /** A tool's answer as text: a string, or a LangChain message's content (string or text parts). */
17
+ export function toolText(out) {
18
+ if (typeof out === "string")
19
+ return out;
20
+ const content = out?.content;
21
+ if (typeof content === "string")
22
+ return content;
23
+ if (Array.isArray(content))
24
+ return content
25
+ .map((p) => (typeof p === "string" ? p : (p?.text ?? "")))
26
+ .join("");
27
+ return null;
28
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { A2A_STREAMING, type AgentCardReport, a2aMethod, a2aRequestMeta, a2aResp
2
2
  export { causality, memoryXray } from "./agents/index.ts";
3
3
  export { type DetectorOptions, detectors, fingerprint } from "./analysis/detectors.ts";
4
4
  export { FAULTS, type Fault, faultOf } from "./analysis/faults.ts";
5
- export { type Accuracy, type AnalyzeOptions, type AnswerClaims, analyze, answerClaims, answerCredential, answerRisk, answersOf, answerText, type ClaimEntry, type ClaimVerdict, type CriterionResult, canonicalNumber, checkAgainstPacks, checkFlightPlan, checkpointReplay, detectorAccuracy, dualWitness, Evidence, eventsOf, type Fact, type Finding as AnalysisFinding, factsIn, findingKey, type GroundedClaim, groundingFindings, groundingRequest, HOLD_TRIGGERS, identifiersIn, judgeFindings, judgeRequest, type Label as AccuracyLabel, type Landing, landing, loadReferencePacks, modelGroundingFindings, normaliseValue, OUTCOMES, type Outcome, type OutcomeRollup, packsFor, parseGroundingAnswer, parseJudgeAnswer, type ReferenceFact, type ReferencePack, type ReferenceVerdict, referenceContext, rollup, type Severity, schemaProblem, toolDrifted, toolHallucinations, verifyAnswerCredential, type WasteReason, type WasteReport, waste, wilsonLow, } from "./analysis/index.ts";
5
+ export { type Accuracy, type AnalyzeOptions, type AnswerClaims, analyze, answerClaims, answerCredential, answerRisk, answersOf, answerText, type ClaimEntry, type ClaimVerdict, type CriterionResult, canonicalNumber, checkAgainstPacks, checkFlightPlan, checkpointReplay, detectorAccuracy, dualWitness, Evidence, eventsOf, type Fact, type Finding as AnalysisFinding, factsIn, findingKey, type GroundedClaim, groundingFindings, groundingRequest, HOLD_TRIGGERS, identifiersIn, judgeFindings, judgeRequest, type Label as AccuracyLabel, type Landing, landing, loadReferencePacks, modelGroundingFindings, normaliseValue, OUTCOMES, type Outcome, type OutcomeRollup, packsFor, parseGroundingAnswer, parseJudgeAnswer, type ReferenceFact, type ReferencePack, type ReferenceVerdict, referenceContext, rollup, type Severity, schemaProblem, secretSent, toolDrifted, toolHallucinations, userDownvotes, verifyAnswerCredential, type WasteReason, type WasteReport, waste, wilsonLow, } from "./analysis/index.ts";
6
6
  export { injectionFindings, injectionRule } from "./analysis/injection.ts";
7
7
  export { MITRE_ATLAS, OWASP_ASI, TAXONOMY, type Taxonomy, taxonomyOf, } from "./analysis/taxonomy.ts";
8
8
  export { type ApprovalFinding, approvalConfirmation, approvalFindings, argsHash, verifyConfirmation, } from "./approvals/index.ts";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { A2A_STREAMING, a2aMethod, a2aRequestMeta, a2aResponseMeta, a2aRestMetho
4
4
  export { causality, memoryXray } from "./agents/index.js";
5
5
  export { detectors, fingerprint } from "./analysis/detectors.js";
6
6
  export { FAULTS, faultOf } from "./analysis/faults.js";
7
- export { analyze, answerClaims, answerCredential, answerRisk, answersOf, answerText, canonicalNumber, checkAgainstPacks, checkFlightPlan, checkpointReplay, detectorAccuracy, dualWitness, Evidence, eventsOf, factsIn, findingKey, groundingFindings, groundingRequest, HOLD_TRIGGERS, identifiersIn, judgeFindings, judgeRequest, landing, loadReferencePacks, modelGroundingFindings, normaliseValue, OUTCOMES, packsFor, parseGroundingAnswer, parseJudgeAnswer, referenceContext, rollup, schemaProblem, toolDrifted, toolHallucinations, verifyAnswerCredential, waste, wilsonLow, } from "./analysis/index.js";
7
+ export { analyze, answerClaims, answerCredential, answerRisk, answersOf, answerText, canonicalNumber, checkAgainstPacks, checkFlightPlan, checkpointReplay, detectorAccuracy, dualWitness, Evidence, eventsOf, factsIn, findingKey, groundingFindings, groundingRequest, HOLD_TRIGGERS, identifiersIn, judgeFindings, judgeRequest, landing, loadReferencePacks, modelGroundingFindings, normaliseValue, OUTCOMES, packsFor, parseGroundingAnswer, parseJudgeAnswer, referenceContext, rollup, schemaProblem, secretSent, toolDrifted, toolHallucinations, userDownvotes, verifyAnswerCredential, waste, wilsonLow, } from "./analysis/index.js";
8
8
  export { injectionFindings, injectionRule } from "./analysis/injection.js";
9
9
  export { MITRE_ATLAS, OWASP_ASI, TAXONOMY, taxonomyOf, } from "./analysis/taxonomy.js";
10
10
  export { approvalConfirmation, approvalFindings, argsHash, verifyConfirmation, } from "./approvals/index.js";
@@ -315,6 +315,25 @@ export declare class BlackboxSession {
315
315
  nearMiss(description: string): void;
316
316
  /** spec/attestation.md: re-runs a read-only command and records whether the output matches. */
317
317
  attest(argv: string[], claimed: string, cwd?: string, claimedExit?: number): Attestation | undefined;
318
+ /**
319
+ * The witness for a framework's shell tool (spec/attestation.md §2): parses `command` and, when
320
+ * it's one simple read-only command, re-runs it and records whether its output matches `claimed`.
321
+ * Anything else (a pipe, a write, a parse failure) records nothing. Never throws.
322
+ */
323
+ witness(command: string, claimed: string, options?: {
324
+ cwd?: string | undefined;
325
+ exitCode?: number;
326
+ }): Attestation | undefined;
327
+ /**
328
+ * A workspace receipt (spec/reconcile.md §7) from any framework's tool hook: the git tree of
329
+ * `cwd` before (`pre`) or after (`post`) a tool call, so a change no tool claimed is
330
+ * UNATTRIBUTED_CHANGE. Resolves with null when posted, else the reason; never rejects.
331
+ */
332
+ workspaceReceipt(phase: "pre" | "post", toolUseId: string, options?: {
333
+ tool?: string;
334
+ cwd?: string;
335
+ }): Promise<string | null>;
336
+ private receipts;
318
337
  /**
319
338
  * spec/sdk.md §1.1: a computer-use screenshot (PNG, JPEG or WebP, at most 2 MB), recorded as the
320
339
  * body of a `screen` event, so its hash is in the chain. `action` says what the agent did or is
@@ -7,11 +7,12 @@ import { request as httpsRequest } from "node:https";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { argsHash } from "../approvals/index.js";
10
- import { attest, isReadOnly } from "../attest/index.js";
10
+ import { attest, isReadOnly, parseCommand } from "../attest/index.js";
11
11
  import { buildPayment, ftaPayload, screeningPayload, } from "../finance/index.js";
12
12
  import { decisionPayload } from "../gov/index.js";
13
13
  import { accessPayload, checkEpisodeTag, recommendationPayload, verifyHealthSignature, } from "../health/index.js";
14
14
  import { memoryEntry } from "../memory/index.js";
15
+ import { postReceipt, receiptFor, workspaceTree } from "../workspace-receipt/index.js";
15
16
  /** spec/preflight.md §1 (L3): with an `egress` item, the SDK checks it here, on the agent's host. */
16
17
  async function withEgress(p) {
17
18
  if (![...(p.require ?? []), ...(p.optional ?? [])].includes("egress"))
@@ -464,6 +465,44 @@ export class BlackboxSession {
464
465
  this.event("attestation", argv[0], { ...result });
465
466
  return result;
466
467
  }
468
+ /**
469
+ * The witness for a framework's shell tool (spec/attestation.md §2): parses `command` and, when
470
+ * it's one simple read-only command, re-runs it and records whether its output matches `claimed`.
471
+ * Anything else (a pipe, a write, a parse failure) records nothing. Never throws.
472
+ */
473
+ witness(command, claimed, options = {}) {
474
+ const argv = parseCommand(command);
475
+ if (!argv)
476
+ return undefined;
477
+ try {
478
+ return this.attest(argv, claimed, options.cwd, options.exitCode);
479
+ }
480
+ catch {
481
+ return undefined;
482
+ }
483
+ }
484
+ /**
485
+ * A workspace receipt (spec/reconcile.md §7) from any framework's tool hook: the git tree of
486
+ * `cwd` before (`pre`) or after (`post`) a tool call, so a change no tool claimed is
487
+ * UNATTRIBUTED_CHANGE. Resolves with null when posted, else the reason; never rejects.
488
+ */
489
+ async workspaceReceipt(phase, toolUseId, options = {}) {
490
+ if (!this.enabled || this.closed)
491
+ return "the session is not recording";
492
+ const receipt = receiptFor({
493
+ hook_event_name: phase === "pre" ? "PreToolUse" : "PostToolUse",
494
+ tool_use_id: toolUseId.slice(0, 256),
495
+ ...(options.tool ? { tool_name: options.tool } : {}),
496
+ }, workspaceTree(options.cwd ?? process.cwd()));
497
+ if (!receipt)
498
+ return "no tool use id";
499
+ // The tree is taken now; the posts go one after another, so a hook that doesn't wait (the
500
+ // OpenAI Agents runner's events) can't have a call's post land before its pre.
501
+ const posted = this.receipts.then(() => postReceipt(this.options.url, this.token, receipt));
502
+ this.receipts = posted;
503
+ return posted;
504
+ }
505
+ receipts = Promise.resolve();
467
506
  /**
468
507
  * spec/sdk.md §1.1: a computer-use screenshot (PNG, JPEG or WebP, at most 2 MB), recorded as the
469
508
  * body of a `screen` event, so its hash is in the chain. `action` says what the agent did or is
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.17.0";
1
+ export declare const VERSION = "0.19.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.17.0";
2
+ export const VERSION = "0.19.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.17.0",
4
+ "version": "0.19.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",