@zanii/blackbox 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +109 -2
- package/dist/agents/index.d.ts +34 -0
- package/dist/agents/index.js +73 -0
- package/dist/analysis/detectors.d.ts +36 -0
- package/dist/analysis/detectors.js +339 -0
- package/dist/analysis/faults.d.ts +9 -0
- package/dist/analysis/faults.js +250 -0
- package/dist/analysis/index.d.ts +68 -0
- package/dist/analysis/index.js +388 -0
- package/dist/analysis/landing.d.ts +25 -0
- package/dist/analysis/landing.js +225 -0
- package/dist/analysis/memory.d.ts +13 -0
- package/dist/analysis/memory.js +33 -0
- package/dist/analysis/waste.d.ts +29 -0
- package/dist/analysis/waste.js +79 -0
- package/dist/approvals/index.d.ts +11 -0
- package/dist/approvals/index.js +27 -0
- package/dist/approvals/warnings.d.ts +2 -0
- package/dist/approvals/warnings.js +28 -0
- package/dist/attest/index.d.ts +17 -0
- package/dist/attest/index.js +106 -0
- package/dist/authority/index.d.ts +24 -0
- package/dist/authority/index.js +77 -0
- package/dist/billing/index.d.ts +99 -0
- package/dist/billing/index.js +174 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1057 -0
- package/dist/client/index.d.ts +146 -0
- package/dist/client/index.js +210 -0
- package/dist/compliance/index.d.ts +41 -0
- package/dist/compliance/index.js +96 -0
- package/dist/cost/index.d.ts +133 -0
- package/dist/cost/index.js +293 -0
- package/dist/data/index.d.ts +191 -0
- package/dist/data/index.js +762 -0
- package/dist/directives/index.d.ts +35 -0
- package/dist/directives/index.js +80 -0
- package/dist/drills/index.d.ts +43 -0
- package/dist/drills/index.js +101 -0
- package/dist/duty/index.d.ts +21 -0
- package/dist/duty/index.js +68 -0
- package/dist/fleet/index.d.ts +141 -0
- package/dist/fleet/index.js +454 -0
- package/dist/hooks/ai-sdk.d.ts +42 -0
- package/dist/hooks/ai-sdk.js +62 -0
- package/dist/hooks/claude-agent-sdk.d.ts +14 -0
- package/dist/hooks/claude-agent-sdk.js +70 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.js +10 -0
- package/dist/hooks/langchain-agent.d.ts +69 -0
- package/dist/hooks/langchain-agent.js +163 -0
- package/dist/hooks/langchain.d.ts +41 -0
- package/dist/hooks/langchain.js +216 -0
- package/dist/hooks/langgraph-checkpoint.d.ts +12 -0
- package/dist/hooks/langgraph-checkpoint.js +73 -0
- package/dist/hooks/memory.d.ts +17 -0
- package/dist/hooks/memory.js +64 -0
- package/dist/hooks/openai-agents.d.ts +6 -0
- package/dist/hooks/openai-agents.js +40 -0
- package/dist/hooks/protect.d.ts +7 -0
- package/dist/hooks/protect.js +39 -0
- package/dist/hooks/providers.d.ts +16 -0
- package/dist/hooks/providers.js +149 -0
- package/dist/hooks/shared.d.ts +11 -0
- package/dist/hooks/shared.js +39 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +48 -0
- package/dist/investigate/index.d.ts +66 -0
- package/dist/investigate/index.js +119 -0
- package/dist/mcp-server/index.d.ts +85 -0
- package/dist/mcp-server/index.js +216 -0
- package/dist/mcp-wrap/index.d.ts +17 -0
- package/dist/mcp-wrap/index.js +170 -0
- package/dist/money/index.d.ts +114 -0
- package/dist/money/index.js +622 -0
- package/dist/occurrence/index.d.ts +108 -0
- package/dist/occurrence/index.js +168 -0
- package/dist/ocsf/index.d.ts +22 -0
- package/dist/ocsf/index.js +168 -0
- package/dist/otlp/index.d.ts +24 -0
- package/dist/otlp/index.js +143 -0
- package/dist/packs/index.d.ts +48 -0
- package/dist/packs/index.js +343 -0
- package/dist/policy/delta.d.ts +11 -0
- package/dist/policy/delta.js +39 -0
- package/dist/policy/drafts.d.ts +34 -0
- package/dist/policy/drafts.js +129 -0
- package/dist/policy/index.d.ts +47 -0
- package/dist/policy/index.js +154 -0
- package/dist/precog/index.d.ts +96 -0
- package/dist/precog/index.js +167 -0
- package/dist/precog/intervention.d.ts +22 -0
- package/dist/precog/intervention.js +44 -0
- package/dist/precog/normal.d.ts +31 -0
- package/dist/precog/normal.js +89 -0
- package/dist/preflight/index.d.ts +11 -0
- package/dist/preflight/index.js +19 -0
- package/dist/ratings/index.d.ts +21 -0
- package/dist/ratings/index.js +48 -0
- package/dist/reconcile/claude-code.d.ts +19 -0
- package/dist/reconcile/claude-code.js +220 -0
- package/dist/reconcile/codex.d.ts +5 -0
- package/dist/reconcile/codex.js +191 -0
- package/dist/reconcile/index.d.ts +19 -0
- package/dist/reconcile/index.js +50 -0
- package/dist/reconcile/record.d.ts +49 -0
- package/dist/reconcile/record.js +225 -0
- package/dist/reconcile/shared.d.ts +65 -0
- package/dist/reconcile/shared.js +113 -0
- package/dist/replay/index.d.ts +11 -0
- package/dist/replay/index.js +64 -0
- package/dist/replay/repair.d.ts +10 -0
- package/dist/replay/repair.js +62 -0
- package/dist/session/drain.d.ts +13 -0
- package/dist/session/drain.js +35 -0
- package/dist/session/index.d.ts +275 -0
- package/dist/session/index.js +681 -0
- package/dist/undo/index.d.ts +45 -0
- package/dist/undo/index.js +212 -0
- package/dist/verify/anchor.d.ts +54 -0
- package/dist/verify/anchor.js +77 -0
- package/dist/verify/chain.d.ts +27 -0
- package/dist/verify/chain.js +105 -0
- package/dist/verify/envelope.d.ts +28 -0
- package/dist/verify/envelope.js +55 -0
- package/dist/verify/index.d.ts +3 -0
- package/dist/verify/index.js +3 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/weather/index.d.ts +24 -0
- package/dist/weather/index.js +45 -0
- package/dist/workspace-receipt/index.d.ts +15 -0
- package/dist/workspace-receipt/index.js +121 -0
- package/package.json +56 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `blackbox` command. Mirrors the Python SDK's `python -m zanii_blackbox.<command>` entry points.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseCommand } from "./attest/index.js";
|
|
7
|
+
import { serveMcp } from "./mcp-server/index.js";
|
|
8
|
+
import { mcpWrap } from "./mcp-wrap/index.js";
|
|
9
|
+
import { RecordNotVerified, reconcile } from "./reconcile/index.js";
|
|
10
|
+
import { session } from "./session/index.js";
|
|
11
|
+
import { verifyBundle } from "./verify/index.js";
|
|
12
|
+
import { VERSION } from "./version.js";
|
|
13
|
+
import { postReceipt, receiptFor, workspaceTree } from "./workspace-receipt/index.js";
|
|
14
|
+
const USAGE = `usage (spec/cli.md):
|
|
15
|
+
blackbox verify <bundle.json> [--proofs] [--bodies <dir>]
|
|
16
|
+
blackbox export <session_id> --out <bundle.json> [--bodies <dir>]
|
|
17
|
+
blackbox export-all --from <date> --to <date> --out <file.ndjson>
|
|
18
|
+
blackbox sessions [--limit N] [--label L] [--open | --closed] [--before <session_id>] [--from <date>] [--to <date>] [--json]
|
|
19
|
+
blackbox sessions <session_id> [--json]
|
|
20
|
+
blackbox summary <session_id>
|
|
21
|
+
blackbox incident <session_id> [--lang en|ar]
|
|
22
|
+
blackbox fleet
|
|
23
|
+
blackbox logbook [--limit N]
|
|
24
|
+
blackbox occurrence <session_id> --framework <id> [--lang en|ar] [--json]
|
|
25
|
+
blackbox occurrence-pack <session_id> --framework <id>
|
|
26
|
+
blackbox file <session_id> --framework <id> --reference <ref> [--channel <c>] [--filed-at <iso>]
|
|
27
|
+
blackbox replay <session_id> [--fork] [--fork-after N] [--label L] [--drill <drill.json>]
|
|
28
|
+
blackbox tail <session_id> [--after-seq N]
|
|
29
|
+
blackbox close <session_id> [--outcome success|failure|abandoned] [--note <text>]
|
|
30
|
+
blackbox resume <session_id> [--token <resume token>] [--note <text>] [--force] (a paused session needs no token)
|
|
31
|
+
blackbox emergency <session_id> [--note <text>]
|
|
32
|
+
blackbox pause <session_id> [--note <text>] (stops at the next call; resume needs no token)
|
|
33
|
+
blackbox erase <session_id>
|
|
34
|
+
blackbox approvals <session_id>
|
|
35
|
+
blackbox decide <session_id> <approval_id> approve|reject [--note <text>]
|
|
36
|
+
blackbox drain [--spool-dir <dir>] (ships what a stopped agent left in its spool)
|
|
37
|
+
blackbox analyze <bundle.json> [--bodies <dir>] [--prices <file>]
|
|
38
|
+
blackbox verify-certificate <certificate.json> [--did <did:key:…>]
|
|
39
|
+
blackbox investigate <bundle.json> [--bodies <dir>] [--directives <file>] [--identity <file>] [--proofs] [--out <prefix>]
|
|
40
|
+
blackbox statement --provider anthropic|openai --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--out <file>]
|
|
41
|
+
blackbox statement --provider csv --file <f> --model-column <name> --amount-column <name> --unit usd|cents|micro_usd|aed [--delimiter <c>] --from <d> --to <d> [--out <file>]
|
|
42
|
+
blackbox statement --provider custom --connector <file> --from <d> --to <d> [--out <file>]
|
|
43
|
+
blackbox preflight --require <items> [--optional <items>]
|
|
44
|
+
blackbox --version
|
|
45
|
+
blackbox mcp (an MCP server on stdio: spec/mcp-server.md)
|
|
46
|
+
blackbox mcp-wrap --server <name> -- <command> [args...]
|
|
47
|
+
blackbox reconcile --harness claude-code|codex --bundle <bundle.json> --bodies <dir> [--agent-session <id>]... [--require-receipts] <path>
|
|
48
|
+
blackbox reconcile-system --connector <connector.json> --log <export.csv|jsonl> [--pack <pack.json>] <session_id>
|
|
49
|
+
blackbox workspace-receipt (a Claude Code PreToolUse / PostToolUse hook; reads the hook JSON on stdin)
|
|
50
|
+
blackbox attest (a Claude Code PostToolUse hook: spec/attestation.md §4)`;
|
|
51
|
+
async function main(argv) {
|
|
52
|
+
const [command, ...rest] = argv;
|
|
53
|
+
if (command === "mcp")
|
|
54
|
+
return mcpCommand(rest);
|
|
55
|
+
if (command === "mcp-wrap")
|
|
56
|
+
return wrap(rest);
|
|
57
|
+
if (command === "reconcile")
|
|
58
|
+
return reconcileCommand(rest);
|
|
59
|
+
if (command === "reconcile-system")
|
|
60
|
+
return reconcileSystemCommand(rest);
|
|
61
|
+
if (command === "workspace-receipt")
|
|
62
|
+
return receiptCommand();
|
|
63
|
+
if (command === "attest")
|
|
64
|
+
return attestCommand();
|
|
65
|
+
if (command === "verify")
|
|
66
|
+
return verifyCommand(rest);
|
|
67
|
+
if (command === "export")
|
|
68
|
+
return exportCommand(rest);
|
|
69
|
+
if (command === "sessions")
|
|
70
|
+
return sessionsCommand(rest);
|
|
71
|
+
if (command === "summary")
|
|
72
|
+
return oneRead(rest, (id) => `/v1/sessions/${id}/summary`);
|
|
73
|
+
if (command === "incident")
|
|
74
|
+
return incidentCommand(rest);
|
|
75
|
+
if (command === "fleet")
|
|
76
|
+
return rest.length ? usage() : printJson("/v1/fleet");
|
|
77
|
+
if (command === "logbook")
|
|
78
|
+
return logbookCommand(rest);
|
|
79
|
+
if (command === "occurrence")
|
|
80
|
+
return occurrenceCommand(rest);
|
|
81
|
+
if (command === "replay")
|
|
82
|
+
return replayCommand(rest);
|
|
83
|
+
if (command === "analyze")
|
|
84
|
+
return analyzeCommand(rest);
|
|
85
|
+
if (command === "verify-certificate")
|
|
86
|
+
return certificateCommand(rest);
|
|
87
|
+
if (command === "preflight")
|
|
88
|
+
return preflightCommand(rest);
|
|
89
|
+
if (command === "investigate")
|
|
90
|
+
return investigateCommand(rest);
|
|
91
|
+
if (command === "statement")
|
|
92
|
+
return statementCommand(rest);
|
|
93
|
+
if (command === "export-all")
|
|
94
|
+
return exportAllCommand(rest);
|
|
95
|
+
if (command === "occurrence-pack")
|
|
96
|
+
return occurrencePackCommand(rest);
|
|
97
|
+
if (command === "file")
|
|
98
|
+
return fileCommand(rest);
|
|
99
|
+
if (command === "tail")
|
|
100
|
+
return tailCommand(rest);
|
|
101
|
+
if (command === "drain")
|
|
102
|
+
return drainCommand(rest);
|
|
103
|
+
if (command === "approvals")
|
|
104
|
+
return oneRead(rest, (id) => `/v1/sessions/${id}/approvals`);
|
|
105
|
+
if (command === "close" ||
|
|
106
|
+
command === "resume" ||
|
|
107
|
+
command === "emergency" ||
|
|
108
|
+
command === "pause" ||
|
|
109
|
+
command === "erase" ||
|
|
110
|
+
command === "decide")
|
|
111
|
+
return controlCommand(command, rest);
|
|
112
|
+
if (command === "--version" || command === "version") {
|
|
113
|
+
process.stdout.write(`blackbox ${VERSION} (typescript)\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
process.stderr.write(`${USAGE}\n`);
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
function wrap(rest) {
|
|
120
|
+
const split = rest.indexOf("--");
|
|
121
|
+
const flags = split < 0 ? rest : rest.slice(0, split);
|
|
122
|
+
const server = flags[0] === "--server" ? flags[1] : undefined;
|
|
123
|
+
const url = process.env.BLACKBOX_URL;
|
|
124
|
+
const token = process.env.BLACKBOX_TOKEN;
|
|
125
|
+
if (!server || flags.length !== 2 || split < 0 || !url || !token) {
|
|
126
|
+
process.stderr.write(`${USAGE}\n(mcp-wrap: BLACKBOX_URL and BLACKBOX_TOKEN must be set)\n`);
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
return mcpWrap({ server, command: rest.slice(split + 1), gatewayUrl: url, token });
|
|
130
|
+
}
|
|
131
|
+
async function reconcileCommand(rest) {
|
|
132
|
+
const agentSessions = [];
|
|
133
|
+
const flags = {};
|
|
134
|
+
const paths = [];
|
|
135
|
+
for (let i = 0; i < rest.length; i++) {
|
|
136
|
+
const a = rest[i];
|
|
137
|
+
if (a === "--agent-session")
|
|
138
|
+
agentSessions.push(rest[++i] ?? "");
|
|
139
|
+
else if (a === "--require-receipts")
|
|
140
|
+
flags["require-receipts"] = "1";
|
|
141
|
+
else if (a.startsWith("--"))
|
|
142
|
+
flags[a.slice(2)] = rest[++i] ?? "";
|
|
143
|
+
else
|
|
144
|
+
paths.push(a);
|
|
145
|
+
}
|
|
146
|
+
const harness = flags.harness;
|
|
147
|
+
if ((harness !== "claude-code" && harness !== "codex") ||
|
|
148
|
+
!flags.bundle ||
|
|
149
|
+
!flags.bodies ||
|
|
150
|
+
paths.length !== 1) {
|
|
151
|
+
process.stderr.write(`${USAGE}\n`);
|
|
152
|
+
return 2;
|
|
153
|
+
}
|
|
154
|
+
const bundle = JSON.parse(readFileSync(flags.bundle, "utf8"));
|
|
155
|
+
const bodies = bodiesIn(flags.bodies);
|
|
156
|
+
try {
|
|
157
|
+
const report = await reconcile({
|
|
158
|
+
harness,
|
|
159
|
+
bundle,
|
|
160
|
+
bodies,
|
|
161
|
+
local: paths[0],
|
|
162
|
+
agentSessions,
|
|
163
|
+
requireReceipts: flags["require-receipts"] === "1",
|
|
164
|
+
});
|
|
165
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
166
|
+
return report.ok ? 0 : 1;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!(error instanceof RecordNotVerified))
|
|
170
|
+
throw error;
|
|
171
|
+
process.stdout.write(`${JSON.stringify({ v: 1, ok: false, error: error.message })}\n`);
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Never blocks the agent: every failure is reported on stderr and the hook still exits 0. */
|
|
176
|
+
/** spec/attestation.md §4. Never blocks the agent: always exits 0, prints nothing on stdout. */
|
|
177
|
+
async function attestCommand() {
|
|
178
|
+
const url = process.env.BLACKBOX_URL;
|
|
179
|
+
const token = process.env.BLACKBOX_TOKEN;
|
|
180
|
+
const sessionId = process.env.BLACKBOX_SESSION_ID;
|
|
181
|
+
if (!url || !token || !sessionId)
|
|
182
|
+
return 0;
|
|
183
|
+
try {
|
|
184
|
+
const parts = [];
|
|
185
|
+
for await (const chunk of process.stdin)
|
|
186
|
+
parts.push(chunk);
|
|
187
|
+
const hook = JSON.parse(Buffer.concat(parts).toString("utf8"));
|
|
188
|
+
const argv = hook.tool_name === "Bash" && typeof hook.tool_input?.command === "string"
|
|
189
|
+
? parseCommand(hook.tool_input.command)
|
|
190
|
+
: null;
|
|
191
|
+
const stdout = hook.tool_response?.stdout;
|
|
192
|
+
if (!argv || typeof stdout !== "string")
|
|
193
|
+
return 0;
|
|
194
|
+
const s = await session({ url, token, sessionId });
|
|
195
|
+
const cwd = typeof hook.cwd === "string" && hook.cwd !== "" ? hook.cwd : undefined;
|
|
196
|
+
// L7.3.6: the exit code too, when the tool result reports one.
|
|
197
|
+
const rc = hook.tool_response?.exit_code ?? hook.tool_response?.returnCode;
|
|
198
|
+
const exit = Number.isSafeInteger(rc) ? rc : undefined;
|
|
199
|
+
if (s.attest(argv, stdout, cwd, exit))
|
|
200
|
+
await s.flush();
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
process.stderr.write(`blackbox attest: ${error instanceof Error ? error.message : error}\n`);
|
|
204
|
+
}
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
async function receiptCommand() {
|
|
208
|
+
const url = process.env.BLACKBOX_URL;
|
|
209
|
+
const token = process.env.BLACKBOX_TOKEN;
|
|
210
|
+
if (!url || !token) {
|
|
211
|
+
process.stderr.write("blackbox workspace-receipt: BLACKBOX_URL and BLACKBOX_TOKEN must be set; no receipt\n");
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
let hook;
|
|
215
|
+
try {
|
|
216
|
+
const parts = [];
|
|
217
|
+
for await (const chunk of process.stdin)
|
|
218
|
+
parts.push(chunk);
|
|
219
|
+
hook = JSON.parse(Buffer.concat(parts).toString("utf8"));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
process.stderr.write("blackbox workspace-receipt: the hook input is not JSON; no receipt\n");
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
const cwd = typeof hook.cwd === "string" && hook.cwd !== "" ? hook.cwd : process.cwd();
|
|
226
|
+
const receipt = receiptFor(hook, workspaceTree(cwd));
|
|
227
|
+
if (!receipt)
|
|
228
|
+
return 0;
|
|
229
|
+
const failure = await postReceipt(url, token, receipt);
|
|
230
|
+
if (failure)
|
|
231
|
+
process.stderr.write(`blackbox workspace-receipt: not recorded: ${failure}\n`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------- verify / export / sessions (spec/cli.md)
|
|
235
|
+
/** `--flag value` pairs and positionals; `booleans` are flags without a value. */
|
|
236
|
+
function parse(rest, booleans) {
|
|
237
|
+
const flags = {};
|
|
238
|
+
const positional = [];
|
|
239
|
+
for (let i = 0; i < rest.length; i++) {
|
|
240
|
+
const a = rest[i];
|
|
241
|
+
if (booleans.includes(a))
|
|
242
|
+
flags[a.slice(2)] = "1";
|
|
243
|
+
else if (a.startsWith("--"))
|
|
244
|
+
flags[a.slice(2)] = rest[++i] ?? "";
|
|
245
|
+
else
|
|
246
|
+
positional.push(a);
|
|
247
|
+
}
|
|
248
|
+
return { flags, positional };
|
|
249
|
+
}
|
|
250
|
+
const SESSION_ID = /^ses_[A-Za-z0-9]{16,64}$/;
|
|
251
|
+
async function verifyCommand(rest) {
|
|
252
|
+
const { flags, positional } = parse(rest, ["--proofs"]);
|
|
253
|
+
if (positional.length !== 1)
|
|
254
|
+
return usage();
|
|
255
|
+
let bundle;
|
|
256
|
+
try {
|
|
257
|
+
bundle = JSON.parse(readFileSync(positional[0], "utf8"));
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: String(error) })}\n`);
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
const report = await verifyBundle(bundle, {
|
|
264
|
+
proofs: flags.proofs === "1",
|
|
265
|
+
...(flags.bodies ? { body: bodiesIn(flags.bodies) } : {}),
|
|
266
|
+
});
|
|
267
|
+
const identity = { ok: report.identity.ok, message: report.identity.message ?? null };
|
|
268
|
+
process.stdout.write(`${JSON.stringify({ ...report, identity })}\n`);
|
|
269
|
+
return report.ok ? 0 : 1;
|
|
270
|
+
}
|
|
271
|
+
function mcpCommand(rest) {
|
|
272
|
+
const url = process.env.BLACKBOX_URL?.replace(/\/+$/, "");
|
|
273
|
+
// audit, also found: a tenant's key (read-only works) instead of the operator's
|
|
274
|
+
const key = process.env.BLACKBOX_API_KEY || process.env.BLACKBOX_ADMIN_KEY;
|
|
275
|
+
if (rest.length > 0 || !url || !key) {
|
|
276
|
+
process.stderr.write(`${USAGE}\n(mcp: BLACKBOX_URL and BLACKBOX_API_KEY or BLACKBOX_ADMIN_KEY must be set)\n`);
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
return serveMcp(async (path) => {
|
|
280
|
+
const res = await fetch(`${url}${path}`, {
|
|
281
|
+
headers: { authorization: `Bearer ${key}` },
|
|
282
|
+
signal: AbortSignal.timeout(30_000),
|
|
283
|
+
});
|
|
284
|
+
return { status: res.status, text: await res.text() };
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
class ServerError extends Error {
|
|
288
|
+
}
|
|
289
|
+
/** GET from the gateway's admin API (BLACKBOX_URL + BLACKBOX_ADMIN_KEY). */
|
|
290
|
+
async function admin(path, body, allow = []) {
|
|
291
|
+
const url = process.env.BLACKBOX_URL;
|
|
292
|
+
const key = process.env.BLACKBOX_ADMIN_KEY;
|
|
293
|
+
if (!url || !key)
|
|
294
|
+
throw new ServerError("BLACKBOX_URL and BLACKBOX_ADMIN_KEY must be set");
|
|
295
|
+
let res;
|
|
296
|
+
try {
|
|
297
|
+
res = await fetch(`${url.replace(/\/+$/, "")}${path}`, {
|
|
298
|
+
...(body === undefined ? {} : { method: "POST", body: JSON.stringify(body) }),
|
|
299
|
+
headers: {
|
|
300
|
+
authorization: `Bearer ${key}`,
|
|
301
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
302
|
+
},
|
|
303
|
+
signal: AbortSignal.timeout(30_000),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
throw new ServerError(`can't reach ${url}: ${error instanceof Error ? error.message : error}`);
|
|
308
|
+
}
|
|
309
|
+
if (!res.ok && !allow.includes(res.status))
|
|
310
|
+
throw new ServerError(`${path}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
311
|
+
return res;
|
|
312
|
+
}
|
|
313
|
+
/** Audit K3: a bodies folder, read by hash. Only a real sha256 names a file; the bytes are
|
|
314
|
+
* checked against the chain wherever the record is verified (verifyBundle's `body`). */
|
|
315
|
+
function bodiesIn(dir) {
|
|
316
|
+
return (hash) => {
|
|
317
|
+
if (!dir || !/^sha256:[0-9a-f]{64}$/.test(hash))
|
|
318
|
+
return undefined;
|
|
319
|
+
const file = join(dir, hash.slice("sha256:".length));
|
|
320
|
+
return existsSync(file) ? readFileSync(file) : undefined;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const adminJson = async (path) => (await (await admin(path)).json());
|
|
324
|
+
async function exportCommand(rest) {
|
|
325
|
+
const { flags, positional } = parse(rest, []);
|
|
326
|
+
const id = positional[0] ?? "";
|
|
327
|
+
if (positional.length !== 1 || !SESSION_ID.test(id) || !flags.out)
|
|
328
|
+
return usage();
|
|
329
|
+
const bundle = (await adminJson(`/v1/sessions/${id}/bundle`));
|
|
330
|
+
writeFileSync(flags.out, `${JSON.stringify(bundle)}\n`);
|
|
331
|
+
let bodies = 0;
|
|
332
|
+
let erased = 0;
|
|
333
|
+
if (flags.bodies) {
|
|
334
|
+
mkdirSync(flags.bodies, { recursive: true });
|
|
335
|
+
const hashes = new Set(bundle.lines.map((l) => JSON.parse(l).body_hash));
|
|
336
|
+
for (const hash of hashes) {
|
|
337
|
+
// The hash names a file: only a real sha256 may, and the bytes must be what it names.
|
|
338
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(hash))
|
|
339
|
+
throw new ServerError(`bad body hash in the bundle`);
|
|
340
|
+
const file = join(flags.bodies, hash.slice("sha256:".length));
|
|
341
|
+
if (existsSync(file))
|
|
342
|
+
continue;
|
|
343
|
+
// Audit K16: after a PDPL / GDPR erasure, the rest of the evidence still exports.
|
|
344
|
+
const res = await admin(`/v1/bodies/${hash}`, undefined, [410]);
|
|
345
|
+
if (res.status === 410) {
|
|
346
|
+
erased++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const data = Buffer.from(await res.arrayBuffer());
|
|
350
|
+
if (`sha256:${createHash("sha256").update(data).digest("hex")}` !== hash)
|
|
351
|
+
throw new ServerError(`the server sent a body that doesn't match ${hash}`);
|
|
352
|
+
writeFileSync(file, data);
|
|
353
|
+
bodies++;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
process.stdout.write(`${JSON.stringify({ ok: true, events: bundle.lines.length, bodies, ...(erased ? { erased } : {}) })}\n`);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
async function sessionsCommand(rest) {
|
|
360
|
+
const { flags, positional } = parse(rest, ["--json", "--open", "--closed"]);
|
|
361
|
+
const f = flags;
|
|
362
|
+
const asJson = flags.json === "1";
|
|
363
|
+
if (positional.length === 1)
|
|
364
|
+
return oneSession(positional[0], asJson);
|
|
365
|
+
const limit = Number(flags.limit ?? 20);
|
|
366
|
+
if (positional.length > 1 || !Number.isSafeInteger(limit) || limit < 1)
|
|
367
|
+
return usage();
|
|
368
|
+
if (f.open === "1" && f.closed === "1")
|
|
369
|
+
return usage();
|
|
370
|
+
// Audit S16: the server's filters (spec/api.md).
|
|
371
|
+
const q = new URLSearchParams({ limit: String(limit) });
|
|
372
|
+
if (f.label !== undefined)
|
|
373
|
+
q.set("label", f.label);
|
|
374
|
+
if (f.before !== undefined)
|
|
375
|
+
q.set("before", f.before);
|
|
376
|
+
if (f.from !== undefined)
|
|
377
|
+
q.set("from", f.from);
|
|
378
|
+
if (f.to !== undefined)
|
|
379
|
+
q.set("to", f.to);
|
|
380
|
+
if (f.open === "1")
|
|
381
|
+
q.set("status", "open");
|
|
382
|
+
if (f.closed === "1")
|
|
383
|
+
q.set("status", "closed");
|
|
384
|
+
const { sessions } = await adminJson(`/v1/sessions?${q}`);
|
|
385
|
+
const rows = [];
|
|
386
|
+
for (const s of sessions) {
|
|
387
|
+
const info = await adminJson(`/v1/sessions/${s.session_id}`);
|
|
388
|
+
rows.push({
|
|
389
|
+
session_id: s.session_id,
|
|
390
|
+
label: s.label,
|
|
391
|
+
created_at: s.created_at,
|
|
392
|
+
closed_at: s.closed_at,
|
|
393
|
+
verified: info.verification.ok === true,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (asJson)
|
|
397
|
+
process.stdout.write(`${JSON.stringify({ sessions: rows })}\n`);
|
|
398
|
+
else
|
|
399
|
+
table(["SESSION", "LABEL", "OPENED", "CLOSED", "VERIFIED"], rows.map((r) => [
|
|
400
|
+
r.session_id,
|
|
401
|
+
r.label ?? "",
|
|
402
|
+
r.created_at,
|
|
403
|
+
r.closed_at ?? "open",
|
|
404
|
+
r.verified ? "yes" : "NO",
|
|
405
|
+
]));
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
async function oneSession(id, asJson) {
|
|
409
|
+
if (!SESSION_ID.test(id))
|
|
410
|
+
return usage();
|
|
411
|
+
const info = await adminJson(`/v1/sessions/${id}`);
|
|
412
|
+
const cost = await adminJson(`/v1/sessions/${id}/cost`);
|
|
413
|
+
const landing = await adminJson(`/v1/sessions/${id}/landing`);
|
|
414
|
+
const waste = await adminJson(`/v1/sessions/${id}/waste`);
|
|
415
|
+
const text = await (await admin(`/v1/sessions/${id}/events`)).text();
|
|
416
|
+
const findings = text
|
|
417
|
+
.split("\n")
|
|
418
|
+
.filter(Boolean)
|
|
419
|
+
.map((l) => JSON.parse(l))
|
|
420
|
+
.filter((e) => e.kind === "finding")
|
|
421
|
+
.map((e) => ({
|
|
422
|
+
seq: e.seq,
|
|
423
|
+
code: e.meta.code,
|
|
424
|
+
fault: e.meta.fault ?? null,
|
|
425
|
+
severity: e.meta.severity,
|
|
426
|
+
}));
|
|
427
|
+
const { verification, anchors: _anchors, ...session } = info;
|
|
428
|
+
const out = {
|
|
429
|
+
session,
|
|
430
|
+
verification,
|
|
431
|
+
cost: { total_micro_usd: cost.total_micro_usd, total_aed: cost.total_aed },
|
|
432
|
+
findings,
|
|
433
|
+
landing,
|
|
434
|
+
waste,
|
|
435
|
+
};
|
|
436
|
+
const code = verification.ok ? 0 : 1;
|
|
437
|
+
if (asJson) {
|
|
438
|
+
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
439
|
+
return code;
|
|
440
|
+
}
|
|
441
|
+
const say = (s) => process.stdout.write(`${s}\n`);
|
|
442
|
+
say(`${id}${session.label ? ` ${session.label}` : ""}`);
|
|
443
|
+
say(` events ${session.events} opened ${session.created_at} ${session.closed_at ? `closed ${session.closed_at}` : "open"}`);
|
|
444
|
+
say(` verified ${verification.ok ? "yes" : `NO: ${verification.code} at ${verification.index}`} anchors ${session.anchor_status}`);
|
|
445
|
+
say(` cost ${cost.total_micro_usd} µUSD (${cost.total_aed}) wasted ${waste.wasted_micro_usd} µUSD outcome ${waste.outcome ?? "none"}`);
|
|
446
|
+
if (session.control?.state === "blocked")
|
|
447
|
+
say(` BLOCKED by ${session.control.blocked_by}`);
|
|
448
|
+
for (const c of landing.claims)
|
|
449
|
+
say(` claim seq ${c.seq}: ${c.verdict}`);
|
|
450
|
+
if (findings.length === 0)
|
|
451
|
+
say(" findings none");
|
|
452
|
+
else {
|
|
453
|
+
say(" findings");
|
|
454
|
+
table(["SEQ", "FAULT", "LEVEL", "CODE"], findings.map((f) => [String(f.seq), f.fault ?? "", f.severity, f.code]), " ");
|
|
455
|
+
}
|
|
456
|
+
return code;
|
|
457
|
+
}
|
|
458
|
+
// ---------------------------------------------------------------- reads, replay, offline analysis (audit S11–S15, S22)
|
|
459
|
+
async function printJson(path) {
|
|
460
|
+
process.stdout.write(`${JSON.stringify(await adminJson(path))}\n`);
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
function oneRead(rest, path) {
|
|
464
|
+
const { positional } = parse(rest, []);
|
|
465
|
+
const id = positional[0] ?? "";
|
|
466
|
+
if (positional.length !== 1 || !SESSION_ID.test(id))
|
|
467
|
+
return usage();
|
|
468
|
+
return printJson(path(id));
|
|
469
|
+
}
|
|
470
|
+
async function incidentCommand(rest) {
|
|
471
|
+
const { flags, positional } = parse(rest, []);
|
|
472
|
+
const id = positional[0] ?? "";
|
|
473
|
+
const lang = flags.lang ?? "en";
|
|
474
|
+
if (positional.length !== 1 || !SESSION_ID.test(id) || (lang !== "en" && lang !== "ar"))
|
|
475
|
+
return usage();
|
|
476
|
+
process.stdout.write(await (await admin(`/v1/sessions/${id}/incident?lang=${lang}`)).text());
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
function logbookCommand(rest) {
|
|
480
|
+
const { flags, positional } = parse(rest, []);
|
|
481
|
+
const limit = Number(flags.limit ?? 1000);
|
|
482
|
+
if (positional.length || !Number.isSafeInteger(limit) || limit < 1)
|
|
483
|
+
return usage();
|
|
484
|
+
return printJson(`/v1/logbook?limit=${limit}`);
|
|
485
|
+
}
|
|
486
|
+
async function occurrenceCommand(rest) {
|
|
487
|
+
const { flags, positional } = parse(rest, ["--json"]);
|
|
488
|
+
const id = positional[0] ?? "";
|
|
489
|
+
if (positional.length !== 1 || !SESSION_ID.test(id) || !flags.framework)
|
|
490
|
+
return usage();
|
|
491
|
+
if (flags.lang !== undefined && flags.lang !== "en" && flags.lang !== "ar")
|
|
492
|
+
return usage();
|
|
493
|
+
const q = new URLSearchParams({
|
|
494
|
+
framework: flags.framework,
|
|
495
|
+
format: flags.json === "1" ? "json" : "md",
|
|
496
|
+
...(flags.lang ? { lang: flags.lang } : {}),
|
|
497
|
+
});
|
|
498
|
+
const res = await admin(`/v1/sessions/${id}/occurrence?${q}`);
|
|
499
|
+
if (flags.json === "1")
|
|
500
|
+
process.stdout.write(`${JSON.stringify(await res.json())}\n`);
|
|
501
|
+
else
|
|
502
|
+
process.stdout.write(await res.text());
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
/** spec/replay.md: opens a replay (or a fork) of a recorded session. Prints the new session. */
|
|
506
|
+
async function replayCommand(rest) {
|
|
507
|
+
const { flags, positional } = parse(rest, ["--fork"]);
|
|
508
|
+
const id = positional[0] ?? "";
|
|
509
|
+
const after = flags["fork-after"] === undefined ? undefined : Number(flags["fork-after"]);
|
|
510
|
+
if (positional.length !== 1 || !SESSION_ID.test(id))
|
|
511
|
+
return usage();
|
|
512
|
+
if (after !== undefined && (!Number.isSafeInteger(after) || after < 0 || flags.fork !== "1"))
|
|
513
|
+
return usage();
|
|
514
|
+
const doc = (await (await admin("/v1/sessions", {
|
|
515
|
+
replay: {
|
|
516
|
+
of: id,
|
|
517
|
+
...(flags.fork === "1" ? { fork: true } : {}),
|
|
518
|
+
...(after === undefined ? {} : { fork_after: after }),
|
|
519
|
+
},
|
|
520
|
+
...(flags.label ? { label: flags.label } : {}),
|
|
521
|
+
...(flags.drill ? { drill: JSON.parse(readFileSync(flags.drill, "utf8")) } : {}),
|
|
522
|
+
})).json());
|
|
523
|
+
process.stdout.write(`${JSON.stringify(doc)}\n`);
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
/** Audit S12: an exported bundle, analysed offline: it must verify first, then the summary, the
|
|
527
|
+
* findings it recorded, and the findings this SDK's analyses find in it now. */
|
|
528
|
+
async function analyzeCommand(rest) {
|
|
529
|
+
const { flags, positional } = parse(rest, []);
|
|
530
|
+
if (positional.length !== 1)
|
|
531
|
+
return usage();
|
|
532
|
+
let bundle;
|
|
533
|
+
try {
|
|
534
|
+
bundle = JSON.parse(readFileSync(positional[0], "utf8"));
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: String(error) })}\n`);
|
|
538
|
+
return 2;
|
|
539
|
+
}
|
|
540
|
+
// Loaded here, not at startup: the CLI also runs as a hook on every tool call.
|
|
541
|
+
const { analyze } = await import("./analysis/index.js");
|
|
542
|
+
const { loadPrices } = await import("./cost/index.js");
|
|
543
|
+
const { aggregateFindings, sessionSummary } = await import("./fleet/index.js");
|
|
544
|
+
// Audit K3: the bodies it reads are checked against the chain, with the rest of the record.
|
|
545
|
+
const bodies = bodiesIn(flags.bodies);
|
|
546
|
+
const report = await verifyBundle(bundle, { body: bodies });
|
|
547
|
+
if (!report.ok) {
|
|
548
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: "the bundle doesn't verify" })}\n`);
|
|
549
|
+
return 2;
|
|
550
|
+
}
|
|
551
|
+
// Without a price table, every call is reported as unpriced, never guessed (spec/cost.md §1).
|
|
552
|
+
const prices = loadPrices(flags.prices
|
|
553
|
+
? readFileSync(flags.prices)
|
|
554
|
+
: Buffer.from('{"currency":"USD","unit":"micro_usd_per_mtok","models":{}}'));
|
|
555
|
+
const lines = bundle.lines;
|
|
556
|
+
const open = JSON.parse(lines[0]).meta;
|
|
557
|
+
const budget = typeof open.budget_micro_usd === "number" ? open.budget_micro_usd : undefined;
|
|
558
|
+
const counts = new Map();
|
|
559
|
+
for (const f of analyze(lines, {
|
|
560
|
+
bodies,
|
|
561
|
+
prices,
|
|
562
|
+
now: Number.POSITIVE_INFINITY,
|
|
563
|
+
...(budget ? { budgetMicroUsd: budget } : {}),
|
|
564
|
+
}))
|
|
565
|
+
counts.set(f.code, (counts.get(f.code) ?? 0) + 1);
|
|
566
|
+
const recomputed = [...counts.entries()]
|
|
567
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
568
|
+
.map(([code, count]) => ({ code, count }));
|
|
569
|
+
process.stdout.write(`${JSON.stringify({
|
|
570
|
+
ok: true,
|
|
571
|
+
events: lines.length,
|
|
572
|
+
summary: sessionSummary(lines, bodies, prices),
|
|
573
|
+
findings: { recorded: aggregateFindings(lines), recomputed },
|
|
574
|
+
})}\n`);
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
/** spec/preflight.md §2: the checklist; exit 0 for a go, 1 for a no-go. */
|
|
578
|
+
async function preflightCommand(rest) {
|
|
579
|
+
const { flags, positional } = parse(rest, []);
|
|
580
|
+
if (positional.length || (!flags.require && !flags.optional))
|
|
581
|
+
return usage();
|
|
582
|
+
const q = new URLSearchParams();
|
|
583
|
+
if (flags.require)
|
|
584
|
+
q.set("require", flags.require);
|
|
585
|
+
if (flags.optional)
|
|
586
|
+
q.set("optional", flags.optional);
|
|
587
|
+
const doc = await adminJson(`/v1/preflight?${q}`);
|
|
588
|
+
process.stdout.write(`${JSON.stringify(doc)}\n`);
|
|
589
|
+
return doc.go === true ? 0 : 1;
|
|
590
|
+
}
|
|
591
|
+
/** Audit S14: checks a signed airworthiness certificate offline (spec/fleet.md §5). */
|
|
592
|
+
async function certificateCommand(rest) {
|
|
593
|
+
const { flags, positional } = parse(rest, []);
|
|
594
|
+
if (positional.length !== 1)
|
|
595
|
+
return usage();
|
|
596
|
+
let cert;
|
|
597
|
+
try {
|
|
598
|
+
cert = JSON.parse(readFileSync(positional[0], "utf8"));
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
process.stdout.write(`${JSON.stringify({ ok: false, by: null, reason: String(error) })}\n`);
|
|
602
|
+
return 2;
|
|
603
|
+
}
|
|
604
|
+
const { verifyCertificate } = await import("./fleet/index.js");
|
|
605
|
+
const result = verifyCertificate(cert, flags.did);
|
|
606
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
607
|
+
return result.ok ? 0 : 1;
|
|
608
|
+
}
|
|
609
|
+
/** spec/investigations.md §2: an independent investigator's signed report, offline. */
|
|
610
|
+
async function investigateCommand(rest) {
|
|
611
|
+
const { flags, positional } = parse(rest, ["--proofs"]);
|
|
612
|
+
if (positional.length !== 1)
|
|
613
|
+
return usage();
|
|
614
|
+
const fail = (error) => {
|
|
615
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: String(error) })}\n`);
|
|
616
|
+
return 2;
|
|
617
|
+
};
|
|
618
|
+
const { investigation, renderInvestigation } = await import("./investigate/index.js");
|
|
619
|
+
const { loadPrices, sessionCost } = await import("./cost/index.js");
|
|
620
|
+
const { didFromSeed, sessionSummary, signCertificate } = await import("./fleet/index.js");
|
|
621
|
+
let raw;
|
|
622
|
+
let bundle;
|
|
623
|
+
let directives;
|
|
624
|
+
let identity;
|
|
625
|
+
try {
|
|
626
|
+
raw = readFileSync(positional[0]);
|
|
627
|
+
bundle = JSON.parse(raw.toString("utf8"));
|
|
628
|
+
if (!Array.isArray(bundle.lines) || bundle.lines.length === 0)
|
|
629
|
+
throw new Error("the bundle has no lines");
|
|
630
|
+
if (flags.directives) {
|
|
631
|
+
const d = JSON.parse(readFileSync(flags.directives, "utf8"));
|
|
632
|
+
const list = Array.isArray(d) ? d : d?.directives;
|
|
633
|
+
if (!Array.isArray(list) ||
|
|
634
|
+
!list.every((x) => typeof x?.signature === "string" &&
|
|
635
|
+
typeof x?.applies_to === "object" &&
|
|
636
|
+
x.applies_to !== null))
|
|
637
|
+
throw new Error("--directives must hold a list of directives");
|
|
638
|
+
directives = list;
|
|
639
|
+
}
|
|
640
|
+
if (flags.identity) {
|
|
641
|
+
const file = JSON.parse(readFileSync(flags.identity, "utf8"));
|
|
642
|
+
if (typeof file.private_key !== "string" || !/^[0-9a-f]{64}$/.test(file.private_key))
|
|
643
|
+
throw new Error("identity file: private_key must be 64 hex characters");
|
|
644
|
+
const seed = Buffer.from(file.private_key, "hex");
|
|
645
|
+
if (file.did !== didFromSeed(seed))
|
|
646
|
+
throw new Error("identity file: did doesn't match private_key");
|
|
647
|
+
identity = { did: file.did, seed };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
return fail(error instanceof Error ? error.message : error);
|
|
652
|
+
}
|
|
653
|
+
// Audit K3: the bodies it reads are checked against the chain (a changed file fails it).
|
|
654
|
+
const bodies = bodiesIn(flags.bodies);
|
|
655
|
+
const check = await verifyBundle(bundle, { proofs: flags.proofs === "1", body: bodies });
|
|
656
|
+
const lines = bundle.lines;
|
|
657
|
+
// no price table: the report prices nothing (spec/cost.md §1)
|
|
658
|
+
const prices = loadPrices(Buffer.from('{"version":"none","currency":"USD","unit":"micro_usd_per_mtok","models":{}}'));
|
|
659
|
+
const models = new Set();
|
|
660
|
+
for (const c of sessionCost(lines, prices).calls)
|
|
661
|
+
if (c.model)
|
|
662
|
+
models.add(c.model);
|
|
663
|
+
const summary = sessionSummary(lines, bodies, prices);
|
|
664
|
+
const report = {
|
|
665
|
+
...investigation(lines, {
|
|
666
|
+
summary,
|
|
667
|
+
models: [...models].sort(),
|
|
668
|
+
...(directives ? { directives: directives } : {}),
|
|
669
|
+
}),
|
|
670
|
+
verification: {
|
|
671
|
+
ok: check.ok,
|
|
672
|
+
identity: check.identity.ok,
|
|
673
|
+
proofs: flags.proofs === "1" ? "checked" : "not_checked",
|
|
674
|
+
},
|
|
675
|
+
investigated_at: new Date().toISOString(),
|
|
676
|
+
bundle_sha256: createHash("sha256").update(raw).digest("hex"),
|
|
677
|
+
};
|
|
678
|
+
const final = identity ? signCertificate(report, identity.did, identity.seed) : report;
|
|
679
|
+
const prefix = flags.out || `investigation-${summary.session_id}`;
|
|
680
|
+
try {
|
|
681
|
+
writeFileSync(`${prefix}.json`, `${JSON.stringify(final)}\n`);
|
|
682
|
+
writeFileSync(`${prefix}.md`, renderInvestigation(final));
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
return fail(error instanceof Error ? error.message : error);
|
|
686
|
+
}
|
|
687
|
+
process.stdout.write(`${JSON.stringify({
|
|
688
|
+
ok: check.ok,
|
|
689
|
+
json: `${prefix}.json`,
|
|
690
|
+
md: `${prefix}.md`,
|
|
691
|
+
signed_by: identity?.did ?? null,
|
|
692
|
+
})}\n`);
|
|
693
|
+
return check.ok ? 0 : 1;
|
|
694
|
+
}
|
|
695
|
+
/** spec/money.md §2a: the provider's statement for a period, from its cost API. */
|
|
696
|
+
async function statementCommand(rest) {
|
|
697
|
+
const { flags, positional } = parse(rest, []);
|
|
698
|
+
const { provider, from, to } = flags;
|
|
699
|
+
const date = /^\d{4}-\d{2}-\d{2}$/;
|
|
700
|
+
if (positional.length ||
|
|
701
|
+
!["anthropic", "openai", "csv", "custom"].includes(provider ?? "") ||
|
|
702
|
+
!date.test(from ?? "") ||
|
|
703
|
+
!date.test(to ?? "") ||
|
|
704
|
+
!(from < to))
|
|
705
|
+
return usage();
|
|
706
|
+
const fail = (error) => {
|
|
707
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error })}\n`);
|
|
708
|
+
return 2;
|
|
709
|
+
};
|
|
710
|
+
const emit = (doc) => {
|
|
711
|
+
if (flags.out) {
|
|
712
|
+
writeFileSync(flags.out, `${JSON.stringify(doc)}\n`);
|
|
713
|
+
process.stdout.write(`${JSON.stringify({ ok: true, out: flags.out, lines: doc.lines.length })}\n`);
|
|
714
|
+
}
|
|
715
|
+
else
|
|
716
|
+
process.stdout.write(`${JSON.stringify(doc)}\n`);
|
|
717
|
+
return 0;
|
|
718
|
+
};
|
|
719
|
+
if (provider === "csv" || provider === "custom") {
|
|
720
|
+
const got = await statementAny(provider, flags, from, to);
|
|
721
|
+
if (typeof got === "number")
|
|
722
|
+
return got;
|
|
723
|
+
return "error" in got ? fail(got.error) : emit(got.doc);
|
|
724
|
+
}
|
|
725
|
+
const env = provider === "anthropic" ? "ANTHROPIC_ADMIN_KEY" : "OPENAI_ADMIN_KEY";
|
|
726
|
+
const key = process.env[env];
|
|
727
|
+
if (!key)
|
|
728
|
+
return fail(`${env} must be set`);
|
|
729
|
+
const { anthropicStatement, openaiStatement } = await import("./money/index.js");
|
|
730
|
+
const pages = [];
|
|
731
|
+
let page = null;
|
|
732
|
+
try {
|
|
733
|
+
for (let n = 0; n < 100; n++) {
|
|
734
|
+
let url;
|
|
735
|
+
let headers;
|
|
736
|
+
if (provider === "anthropic") {
|
|
737
|
+
url = new URL("/v1/organizations/cost_report", process.env.BLACKBOX_ANTHROPIC_API_URL || "https://api.anthropic.com");
|
|
738
|
+
url.searchParams.set("starting_at", `${from}T00:00:00Z`);
|
|
739
|
+
url.searchParams.set("ending_at", `${to}T00:00:00Z`);
|
|
740
|
+
url.searchParams.set("bucket_width", "1d");
|
|
741
|
+
url.searchParams.append("group_by[]", "description");
|
|
742
|
+
headers = { "x-api-key": key, "anthropic-version": "2023-06-01" };
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
url = new URL("/v1/organization/costs", process.env.BLACKBOX_OPENAI_API_URL || "https://api.openai.com");
|
|
746
|
+
url.searchParams.set("start_time", String(Date.parse(`${from}T00:00:00Z`) / 1000));
|
|
747
|
+
url.searchParams.set("end_time", String(Date.parse(`${to}T00:00:00Z`) / 1000));
|
|
748
|
+
url.searchParams.set("bucket_width", "1d");
|
|
749
|
+
url.searchParams.set("group_by", "line_item");
|
|
750
|
+
headers = { authorization: `Bearer ${key}` };
|
|
751
|
+
}
|
|
752
|
+
url.searchParams.set("limit", "31");
|
|
753
|
+
if (page)
|
|
754
|
+
url.searchParams.set("page", page);
|
|
755
|
+
const res = await fetch(url, {
|
|
756
|
+
headers: { ...headers, "user-agent": `zanii-blackbox/${VERSION}` },
|
|
757
|
+
signal: AbortSignal.timeout(30_000),
|
|
758
|
+
});
|
|
759
|
+
const text = await res.text();
|
|
760
|
+
if (!res.ok)
|
|
761
|
+
return fail(`${provider} ${url.pathname}: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
762
|
+
const body = JSON.parse(text);
|
|
763
|
+
pages.push(body);
|
|
764
|
+
if (body.has_more !== true || typeof body.next_page !== "string") {
|
|
765
|
+
page = null;
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
page = body.next_page;
|
|
769
|
+
}
|
|
770
|
+
if (page)
|
|
771
|
+
return fail("more than 100 pages; ask for a shorter period");
|
|
772
|
+
const statement = provider === "anthropic" ? anthropicStatement(pages) : openaiStatement(pages);
|
|
773
|
+
return emit({ provider, from, to, lines: statement.lines });
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** spec/money.md §2b: a statement from a CSV export or a declarative connector. */
|
|
780
|
+
async function statementAny(provider, flags, from, to) {
|
|
781
|
+
const money = await import("./money/index.js");
|
|
782
|
+
const message = (e) => ({ error: e instanceof Error ? e.message : String(e) });
|
|
783
|
+
if (provider === "csv") {
|
|
784
|
+
const model = flags["model-column"];
|
|
785
|
+
const amount = flags["amount-column"];
|
|
786
|
+
const unit = flags.unit;
|
|
787
|
+
const delimiter = flags.delimiter;
|
|
788
|
+
if (!flags.file ||
|
|
789
|
+
!model ||
|
|
790
|
+
!amount ||
|
|
791
|
+
!unit ||
|
|
792
|
+
(delimiter !== undefined && delimiter.length !== 1))
|
|
793
|
+
return usage();
|
|
794
|
+
try {
|
|
795
|
+
const text = readFileSync(flags.file, "utf8");
|
|
796
|
+
const statement = money.csvStatement(text, {
|
|
797
|
+
model_column: model,
|
|
798
|
+
amount_column: amount,
|
|
799
|
+
unit,
|
|
800
|
+
...(delimiter ? { delimiter } : {}),
|
|
801
|
+
});
|
|
802
|
+
return { doc: { provider: "csv", from, to, lines: statement.lines } };
|
|
803
|
+
}
|
|
804
|
+
catch (e) {
|
|
805
|
+
return message(e);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (!flags.connector)
|
|
809
|
+
return usage();
|
|
810
|
+
let connector;
|
|
811
|
+
try {
|
|
812
|
+
connector = JSON.parse(readFileSync(flags.connector, "utf8"));
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
return message(e);
|
|
816
|
+
}
|
|
817
|
+
const bad = money.checkConnector(connector);
|
|
818
|
+
if (bad)
|
|
819
|
+
return { error: `connector: ${bad}` };
|
|
820
|
+
const headers = {};
|
|
821
|
+
for (const [k, v] of Object.entries(connector.request.headers ?? {})) {
|
|
822
|
+
let missing = null;
|
|
823
|
+
headers[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
824
|
+
const value = process.env[name];
|
|
825
|
+
if (!value)
|
|
826
|
+
missing = name;
|
|
827
|
+
return value ?? "";
|
|
828
|
+
});
|
|
829
|
+
if (missing)
|
|
830
|
+
return { error: `${missing} must be set` };
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const pages = [];
|
|
834
|
+
for (;;) {
|
|
835
|
+
const r = money.requestFor(connector, pages, from, to);
|
|
836
|
+
if (r === null)
|
|
837
|
+
break;
|
|
838
|
+
if (pages.length >= 100)
|
|
839
|
+
return { error: "more than 100 pages; ask for a shorter period" };
|
|
840
|
+
const url = new URL(r.url);
|
|
841
|
+
for (const [k, v] of r.query)
|
|
842
|
+
url.searchParams.set(k, v);
|
|
843
|
+
const res = await fetch(url, {
|
|
844
|
+
method: r.method,
|
|
845
|
+
headers: {
|
|
846
|
+
...headers,
|
|
847
|
+
...(r.content_type ? { "content-type": r.content_type } : {}),
|
|
848
|
+
"user-agent": `zanii-blackbox/${VERSION}`,
|
|
849
|
+
},
|
|
850
|
+
...(r.body !== undefined ? { body: r.body } : {}),
|
|
851
|
+
signal: AbortSignal.timeout(30_000),
|
|
852
|
+
});
|
|
853
|
+
const text = await res.text();
|
|
854
|
+
if (!res.ok)
|
|
855
|
+
return { error: `${connector.name}: HTTP ${res.status} ${text.slice(0, 200)}` };
|
|
856
|
+
pages.push(connector.format === "xml" ? money.parseXml(text) : JSON.parse(text));
|
|
857
|
+
}
|
|
858
|
+
const statement = money.customStatement(pages, connector);
|
|
859
|
+
return { doc: { provider: `custom:${connector.name}`, from, to, lines: statement.lines } };
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
return message(e);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function table(head, rows, indent = "") {
|
|
866
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
867
|
+
for (const r of [head, ...rows])
|
|
868
|
+
process.stdout.write(`${indent}${r
|
|
869
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
870
|
+
.join(" ")
|
|
871
|
+
.trimEnd()}\n`);
|
|
872
|
+
}
|
|
873
|
+
// ---------------------------------------------------------------- operator commands (audit K21, K20, K13, K5)
|
|
874
|
+
/** V16: every session created in [from, to) as NDJSON bundles, page by page, into one file. */
|
|
875
|
+
async function exportAllCommand(rest) {
|
|
876
|
+
const { flags, positional } = parse(rest, []);
|
|
877
|
+
if (positional.length || !flags.from || !flags.to || !flags.out)
|
|
878
|
+
return usage();
|
|
879
|
+
writeFileSync(flags.out, "");
|
|
880
|
+
let sessions = 0;
|
|
881
|
+
let before = "";
|
|
882
|
+
for (;;) {
|
|
883
|
+
const q = new URLSearchParams({ from: flags.from, to: flags.to });
|
|
884
|
+
if (before)
|
|
885
|
+
q.set("before", before);
|
|
886
|
+
const lines = (await (await admin(`/v1/export?${q}`)).text()).split("\n").filter(Boolean);
|
|
887
|
+
const last = lines.at(-1) ? JSON.parse(lines.at(-1)) : undefined;
|
|
888
|
+
const more = last?.more === true;
|
|
889
|
+
const page = more ? lines.slice(0, -1) : lines;
|
|
890
|
+
if (page.length)
|
|
891
|
+
appendFileSync(flags.out, `${page.join("\n")}\n`);
|
|
892
|
+
sessions += page.length;
|
|
893
|
+
if (!more)
|
|
894
|
+
break;
|
|
895
|
+
before = String(last?.before);
|
|
896
|
+
}
|
|
897
|
+
process.stdout.write(`${JSON.stringify({ ok: true, sessions })}\n`);
|
|
898
|
+
return 0;
|
|
899
|
+
}
|
|
900
|
+
async function occurrencePackCommand(rest) {
|
|
901
|
+
const { flags, positional } = parse(rest, []);
|
|
902
|
+
const id = positional[0] ?? "";
|
|
903
|
+
if (positional.length !== 1 || !SESSION_ID.test(id) || !flags.framework)
|
|
904
|
+
return usage();
|
|
905
|
+
return printJson(`/v1/sessions/${id}/occurrence/pack?${new URLSearchParams({ framework: flags.framework })}`);
|
|
906
|
+
}
|
|
907
|
+
async function fileCommand(rest) {
|
|
908
|
+
const { flags, positional } = parse(rest, []);
|
|
909
|
+
const id = positional[0] ?? "";
|
|
910
|
+
if (positional.length !== 1 || !SESSION_ID.test(id) || !flags.framework || !flags.reference)
|
|
911
|
+
return usage();
|
|
912
|
+
const doc = await (await admin(`/v1/sessions/${id}/occurrence/filings`, {
|
|
913
|
+
framework: flags.framework,
|
|
914
|
+
reference: flags.reference,
|
|
915
|
+
...(flags.channel ? { channel: flags.channel } : {}),
|
|
916
|
+
...(flags["filed-at"] ? { filed_at: flags["filed-at"] } : {}),
|
|
917
|
+
})).json();
|
|
918
|
+
process.stdout.write(`${JSON.stringify(doc)}\n`);
|
|
919
|
+
return 0;
|
|
920
|
+
}
|
|
921
|
+
/** The control calls, each one POST (spec/control.md, spec/approvals.md, spec/api.md). */
|
|
922
|
+
async function controlCommand(command, rest) {
|
|
923
|
+
const { flags, positional } = parse(rest, ["--force"]);
|
|
924
|
+
const id = positional[0] ?? "";
|
|
925
|
+
if (!SESSION_ID.test(id))
|
|
926
|
+
return usage();
|
|
927
|
+
const note = flags.note ? { note: flags.note } : {};
|
|
928
|
+
let path = `/v1/sessions/${id}/${command}`;
|
|
929
|
+
let body;
|
|
930
|
+
if (command === "close") {
|
|
931
|
+
const outcome = flags.outcome;
|
|
932
|
+
if (outcome !== undefined && !["success", "failure", "abandoned"].includes(outcome))
|
|
933
|
+
return usage();
|
|
934
|
+
body = { ...(outcome ? { outcome } : {}), ...note };
|
|
935
|
+
}
|
|
936
|
+
else if (command === "resume") {
|
|
937
|
+
body = {
|
|
938
|
+
...(flags.token ? { token: flags.token } : {}),
|
|
939
|
+
...(flags.force === "1" ? { force: true } : {}),
|
|
940
|
+
...note,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
else if (command === "decide") {
|
|
944
|
+
const [, approvalId, decision] = positional;
|
|
945
|
+
if (positional.length !== 3 || (decision !== "approve" && decision !== "reject"))
|
|
946
|
+
return usage();
|
|
947
|
+
path = `/v1/sessions/${id}/approvals/${encodeURIComponent(approvalId)}`;
|
|
948
|
+
body = { decision, ...note };
|
|
949
|
+
}
|
|
950
|
+
else
|
|
951
|
+
body = note;
|
|
952
|
+
if (command !== "decide" && positional.length !== 1)
|
|
953
|
+
return usage();
|
|
954
|
+
const doc = await (await admin(path, body)).json();
|
|
955
|
+
process.stdout.write(`${JSON.stringify(doc)}\n`);
|
|
956
|
+
return 0;
|
|
957
|
+
}
|
|
958
|
+
/** Audit K13: the session's events live, one stored line per line, until it closes. */
|
|
959
|
+
async function tailCommand(rest) {
|
|
960
|
+
const { flags, positional } = parse(rest, []);
|
|
961
|
+
const id = positional[0] ?? "";
|
|
962
|
+
const after = flags["after-seq"];
|
|
963
|
+
if (positional.length !== 1 || !SESSION_ID.test(id))
|
|
964
|
+
return usage();
|
|
965
|
+
if (after !== undefined && !/^-?\d+$/.test(after))
|
|
966
|
+
return usage();
|
|
967
|
+
// No timeout: a live tail lasts as long as the session does (admin() would cut it at 30 s).
|
|
968
|
+
const url = process.env.BLACKBOX_URL;
|
|
969
|
+
const key = process.env.BLACKBOX_ADMIN_KEY;
|
|
970
|
+
if (!url || !key)
|
|
971
|
+
throw new ServerError("BLACKBOX_URL and BLACKBOX_ADMIN_KEY must be set");
|
|
972
|
+
const path = `/v1/sessions/${id}/stream${after !== undefined ? `?after_seq=${after}` : ""}`;
|
|
973
|
+
const res = await fetch(`${url.replace(/\/+$/, "")}${path}`, {
|
|
974
|
+
headers: { authorization: `Bearer ${key}` },
|
|
975
|
+
}).catch((error) => {
|
|
976
|
+
throw new ServerError(`can't reach ${url}: ${error instanceof Error ? error.message : error}`);
|
|
977
|
+
});
|
|
978
|
+
if (!res.ok)
|
|
979
|
+
throw new ServerError(`${path}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
980
|
+
const reader = res.body.getReader();
|
|
981
|
+
const decoder = new TextDecoder();
|
|
982
|
+
let buffer = "";
|
|
983
|
+
for (;;) {
|
|
984
|
+
const { value, done } = await reader.read();
|
|
985
|
+
if (done)
|
|
986
|
+
return 0;
|
|
987
|
+
buffer += decoder.decode(value, { stream: true });
|
|
988
|
+
for (let end = buffer.indexOf("\n\n"); end !== -1; end = buffer.indexOf("\n\n")) {
|
|
989
|
+
const frame = buffer.slice(0, end);
|
|
990
|
+
buffer = buffer.slice(end + 2);
|
|
991
|
+
if (/^event: end$/m.test(frame))
|
|
992
|
+
return 0;
|
|
993
|
+
for (const l of frame.split("\n"))
|
|
994
|
+
if (l.startsWith("data: "))
|
|
995
|
+
process.stdout.write(`${l.slice(6)}\n`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/** Audit K5: ships what stopped agents left in the spool (BLACKBOX_URL; no key: the kept tokens). */
|
|
1000
|
+
async function drainCommand(rest) {
|
|
1001
|
+
const { flags, positional } = parse(rest, []);
|
|
1002
|
+
const url = process.env.BLACKBOX_URL;
|
|
1003
|
+
if (positional.length || !url)
|
|
1004
|
+
return usage();
|
|
1005
|
+
const { drainSpools } = await import("./session/drain.js");
|
|
1006
|
+
const results = await drainSpools({
|
|
1007
|
+
url,
|
|
1008
|
+
...(flags["spool-dir"] ? { spoolDir: flags["spool-dir"] } : {}),
|
|
1009
|
+
});
|
|
1010
|
+
process.stdout.write(`${JSON.stringify({ ok: results.every((r) => r.shipped), sessions: results })}\n`);
|
|
1011
|
+
return results.every((r) => r.shipped) ? 0 : 2;
|
|
1012
|
+
}
|
|
1013
|
+
/** spec/data.md §10: the session's record against a touched system's own audit-log export, run
|
|
1014
|
+
* here so the export never leaves this machine. Exits 1 on any finding. */
|
|
1015
|
+
async function reconcileSystemCommand(rest) {
|
|
1016
|
+
const { flags, positional } = parse(rest, []);
|
|
1017
|
+
const id = positional[0] ?? "";
|
|
1018
|
+
if (positional.length !== 1 || !SESSION_ID.test(id) || !flags.connector || !flags.log)
|
|
1019
|
+
return usage();
|
|
1020
|
+
const { checkAuditConnector, parseAuditLog, reconcileSystem, CORE_EVIDENCE } = await import("./data/index.js");
|
|
1021
|
+
const { packData } = await import("./packs/index.js");
|
|
1022
|
+
const connector = JSON.parse(readFileSync(flags.connector, "utf8"));
|
|
1023
|
+
const bad = checkAuditConnector(connector);
|
|
1024
|
+
if (bad)
|
|
1025
|
+
throw new ServerError(`--connector: ${bad}`);
|
|
1026
|
+
const evidence = flags.pack
|
|
1027
|
+
? packData([JSON.parse(readFileSync(flags.pack, "utf8"))]).evidence
|
|
1028
|
+
: CORE_EVIDENCE;
|
|
1029
|
+
const c = connector;
|
|
1030
|
+
const rows = parseAuditLog(readFileSync(flags.log, "utf8"), c);
|
|
1031
|
+
const bundle = (await adminJson(`/v1/sessions/${id}/bundle`));
|
|
1032
|
+
// Only the bodies the comparison reads: tool calls and results, and SDK events.
|
|
1033
|
+
const bodies = new Map();
|
|
1034
|
+
for (const line of bundle.lines) {
|
|
1035
|
+
const e = JSON.parse(line);
|
|
1036
|
+
if (!["tool.call", "tool.result", "sdk.event"].includes(e.kind) || bodies.has(e.body_hash))
|
|
1037
|
+
continue;
|
|
1038
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(e.body_hash))
|
|
1039
|
+
continue;
|
|
1040
|
+
const res = await admin(`/v1/bodies/${e.body_hash}`, undefined, [404, 410]);
|
|
1041
|
+
if (res.status === 200)
|
|
1042
|
+
bodies.set(e.body_hash, Buffer.from(await res.arrayBuffer()));
|
|
1043
|
+
}
|
|
1044
|
+
const report = reconcileSystem(bundle.lines, (h) => bodies.get(h), rows, c, evidence);
|
|
1045
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1046
|
+
return report.findings.length > 0 ? 1 : 0;
|
|
1047
|
+
}
|
|
1048
|
+
function usage() {
|
|
1049
|
+
process.stderr.write(`${USAGE}\n`);
|
|
1050
|
+
return 2;
|
|
1051
|
+
}
|
|
1052
|
+
main(process.argv.slice(2)).then((code) => {
|
|
1053
|
+
process.exitCode = code;
|
|
1054
|
+
}, (error) => {
|
|
1055
|
+
process.stderr.write(`blackbox: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1056
|
+
process.exitCode = 2;
|
|
1057
|
+
});
|