@zanii/blackbox 0.21.2 → 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/actions/index.d.ts +29 -0
- package/dist/actions/index.js +114 -0
- package/dist/admission/index.d.ts +30 -0
- package/dist/admission/index.js +106 -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 +199 -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/evidence/index.d.ts +9 -0
- package/dist/evidence/index.js +50 -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 +24 -9
- package/dist/index.js +25 -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 +14 -0
- package/dist/reconcile/record.js +161 -66
- 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/chain.d.ts +4 -0
- package/dist/verify/chain.js +4 -3
- 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
|
@@ -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. */
|
package/dist/analysis/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { healthFindings } from "../health/index.js";
|
|
|
8
8
|
import { policyFindings } from "../policy/index.js";
|
|
9
9
|
import { precogFindings } from "../precog/index.js";
|
|
10
10
|
import { preflightFindings } from "../preflight/index.js";
|
|
11
|
+
import { envelopesOf, foldState, trackRecord } from "../reconcile/record.js";
|
|
11
12
|
import { canonical } from "../reconcile/shared.js";
|
|
12
13
|
import { undoFindings } from "../undo/index.js";
|
|
13
14
|
import { wallFindings } from "../walls/index.js";
|
|
@@ -26,27 +27,68 @@ export { checkFlightPlan, landing, } from "./landing.js";
|
|
|
26
27
|
export { checkAgainstPacks, loadReferencePacks, packsFor, referenceContext, } from "./reference.js";
|
|
27
28
|
export { OUTCOMES, rollup, waste, } from "./waste.js";
|
|
28
29
|
const decoder = new TextDecoder();
|
|
29
|
-
/** Parses the lines once, attaching SDK event bodies.
|
|
30
|
+
/** Parses the lines once, attaching SDK event bodies. On a tracked record (`Analyzer`), only the
|
|
31
|
+
* lines added since the last call are read. */
|
|
30
32
|
export function eventsOf(lines, bodies) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const parsed = b === undefined ? {} : JSON.parse(decoder.decode(b));
|
|
40
|
-
e.data =
|
|
41
|
-
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
e.data = {};
|
|
45
|
-
}
|
|
33
|
+
const fold = foldState(lines, "eventsOf", () => ({ done: 0, out: [], bodies }));
|
|
34
|
+
if (fold && fold.bodies === bodies) {
|
|
35
|
+
const all = envelopesOf(lines);
|
|
36
|
+
for (; fold.done < all.length; fold.done++) {
|
|
37
|
+
const e = withData(all[fold.done], bodies);
|
|
38
|
+
if (e)
|
|
39
|
+
fold.out.push(e);
|
|
46
40
|
}
|
|
47
|
-
return [
|
|
41
|
+
return [...fold.out];
|
|
42
|
+
}
|
|
43
|
+
return envelopesOf(lines).flatMap((e) => {
|
|
44
|
+
const out = withData(e, bodies);
|
|
45
|
+
return out ? [out] : [];
|
|
48
46
|
});
|
|
49
47
|
}
|
|
48
|
+
function withData(e, bodies) {
|
|
49
|
+
// N4 (idea R6): a re-sent SDK event (meta.duplicate_of) stays on the record but counts once.
|
|
50
|
+
if (e.kind === "sdk.event" && e.meta.duplicate_of !== undefined)
|
|
51
|
+
return null;
|
|
52
|
+
if (e.kind !== "sdk.event")
|
|
53
|
+
return e;
|
|
54
|
+
const b = bodies(e.body_hash);
|
|
55
|
+
let data = {};
|
|
56
|
+
try {
|
|
57
|
+
const parsed = b === undefined ? {} : JSON.parse(decoder.decode(b));
|
|
58
|
+
data = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
return Object.isFrozen(e) ? Object.freeze({ ...e, data: deepFreezeJson(data) }) : { ...e, data };
|
|
62
|
+
}
|
|
63
|
+
function deepFreezeJson(value) {
|
|
64
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
65
|
+
Object.freeze(value);
|
|
66
|
+
for (const v of Object.values(value))
|
|
67
|
+
deepFreezeJson(v);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* spec/findings.md §14 (G33): the analyses over one session's growing record. Each `findings` call
|
|
73
|
+
* reads only the lines added since the last one (each line is parsed once, the calls are rebuilt
|
|
74
|
+
* from the new events only) and gives exactly what `analyze` gives over the whole record.
|
|
75
|
+
*/
|
|
76
|
+
export class Analyzer {
|
|
77
|
+
lines = [];
|
|
78
|
+
bodies;
|
|
79
|
+
constructor(bodies) {
|
|
80
|
+
this.bodies = bodies;
|
|
81
|
+
trackRecord(this.lines);
|
|
82
|
+
}
|
|
83
|
+
/** Appends the record's new lines (in seq order; a record only grows). */
|
|
84
|
+
push(lines) {
|
|
85
|
+
for (const l of lines)
|
|
86
|
+
this.lines.push(l);
|
|
87
|
+
}
|
|
88
|
+
findings(options) {
|
|
89
|
+
return analyze(this.lines, { ...options, bodies: this.bodies });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
50
92
|
/** Every analysis, in spec order. */
|
|
51
93
|
export function analyze(lines, options) {
|
|
52
94
|
const events = eventsOf(lines, options.bodies);
|
|
@@ -60,6 +102,7 @@ export function analyze(lines, options) {
|
|
|
60
102
|
...toolChanged(events),
|
|
61
103
|
...toolDrifted(events),
|
|
62
104
|
...secretSent(events),
|
|
105
|
+
...elicitationSecret(events),
|
|
63
106
|
...userDownvotes(events),
|
|
64
107
|
...selfChanges(events),
|
|
65
108
|
...promptChanged(events),
|
|
@@ -473,16 +516,55 @@ export function secretSent(events) {
|
|
|
473
516
|
n = count(e.meta.redacted);
|
|
474
517
|
to = "tool";
|
|
475
518
|
}
|
|
519
|
+
else if ((e.kind === "tool.elicitation" || e.kind === "tool.sampling") &&
|
|
520
|
+
e.meta.phase === "reply" &&
|
|
521
|
+
typeof e.meta.method === "string") {
|
|
522
|
+
// spec/mcp.md §6: the client's answer to a server's request went to that MCP server
|
|
523
|
+
n = count(e.meta.redacted);
|
|
524
|
+
to = "server";
|
|
525
|
+
}
|
|
476
526
|
if (!to || n <= 0)
|
|
477
527
|
continue;
|
|
478
528
|
out.push({
|
|
479
529
|
code: "SECRET_SENT",
|
|
480
530
|
source: "detectors",
|
|
481
531
|
severity: "warning",
|
|
482
|
-
ref: {
|
|
532
|
+
ref: {
|
|
533
|
+
seq: e.seq,
|
|
534
|
+
to: to === "server" ? "tool" : to,
|
|
535
|
+
count: n,
|
|
536
|
+
...(to === "model"
|
|
537
|
+
? {}
|
|
538
|
+
: { tool: (to === "tool" ? e.meta.tool : e.meta.method) }),
|
|
539
|
+
},
|
|
483
540
|
detail: to === "model"
|
|
484
541
|
? "A credential (a key, a token, a password) went to the model provider in the request."
|
|
485
|
-
:
|
|
542
|
+
: to === "tool"
|
|
543
|
+
? "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."
|
|
544
|
+
: "A credential (a key, a token, a password) went to an MCP server in the client's answer to its request.",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
/** spec/mcp.md §6: an MCP server asked the user for a credential through `elicitation/create`. */
|
|
550
|
+
export function elicitationSecret(events) {
|
|
551
|
+
const out = [];
|
|
552
|
+
for (const e of events) {
|
|
553
|
+
const fields = e.meta.secret_fields;
|
|
554
|
+
if (e.kind !== "tool.elicitation" || e.meta.phase !== "request")
|
|
555
|
+
continue;
|
|
556
|
+
if (!Array.isArray(fields) || fields.length === 0)
|
|
557
|
+
continue;
|
|
558
|
+
out.push({
|
|
559
|
+
code: "ELICITATION_SECRET",
|
|
560
|
+
source: "detectors",
|
|
561
|
+
severity: "warning",
|
|
562
|
+
ref: {
|
|
563
|
+
seq: e.seq,
|
|
564
|
+
...(typeof e.meta.server === "string" ? { server: e.meta.server } : {}),
|
|
565
|
+
fields: fields.map(String).join(","),
|
|
566
|
+
},
|
|
567
|
+
detail: "An MCP server asked the user for a credential (a password, a token, a key) through elicitation; a server should never collect one this way.",
|
|
486
568
|
});
|
|
487
569
|
}
|
|
488
570
|
return out;
|
|
@@ -7,7 +7,8 @@ export declare const INJECTION_RULES: ReadonlyArray<{
|
|
|
7
7
|
export declare function hiddenText(text: string): boolean;
|
|
8
8
|
/** The first rule a text breaks, or null. */
|
|
9
9
|
export declare function injectionRule(text: string): string | null;
|
|
10
|
-
/** spec/findings.md §1b: PROMPT_INJECTION findings over a record.
|
|
10
|
+
/** spec/findings.md §1b: PROMPT_INJECTION findings over a record. Each event is judged on its own
|
|
11
|
+
* (on a tracked record, once: G33). */
|
|
11
12
|
export declare function injectionFindings(lines: readonly string[], bodies: (hash: string) => Uint8Array | undefined): {
|
|
12
13
|
code: string;
|
|
13
14
|
source: string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// role hijacks, exfiltration asks and hidden text, in English and Arabic. One finding per event and
|
|
5
5
|
// source; the finding names the rule, never the text. Mirrors analysis/injection.py; pinned by
|
|
6
6
|
// spec/vectors/injection.json.
|
|
7
|
+
import { perEvent } from "../reconcile/record.js";
|
|
7
8
|
const decoder = new TextDecoder();
|
|
8
9
|
const MAX_TEXT = 200_000; // characters scanned per event: an injection is near the start or not worth the cost
|
|
9
10
|
/** The rules, in the order they're tried. Patterns run on ASCII-lowercased, space-folded text. */
|
|
@@ -120,10 +121,10 @@ function newestTurn(body) {
|
|
|
120
121
|
}
|
|
121
122
|
return out;
|
|
122
123
|
}
|
|
123
|
-
/** spec/findings.md §1b: PROMPT_INJECTION findings over a record.
|
|
124
|
+
/** spec/findings.md §1b: PROMPT_INJECTION findings over a record. Each event is judged on its own
|
|
125
|
+
* (on a tracked record, once: G33). */
|
|
124
126
|
export function injectionFindings(lines, bodies) {
|
|
125
|
-
const
|
|
126
|
-
const add = (seq, source, rule, tool) => out.push({
|
|
127
|
+
const found = (seq, source, rule, tool) => ({
|
|
127
128
|
code: "PROMPT_INJECTION",
|
|
128
129
|
source: "detectors",
|
|
129
130
|
severity: "warning",
|
|
@@ -132,32 +133,31 @@ export function injectionFindings(lines, bodies) {
|
|
|
132
133
|
? "Text the reader can't see (hidden characters) reached the model: a classic way to smuggle instructions."
|
|
133
134
|
: "Text that reached the model tries to give it new instructions: an override, a role change, or a request to send out secrets.",
|
|
134
135
|
});
|
|
135
|
-
|
|
136
|
-
const e =
|
|
136
|
+
return perEvent(lines, "injection", bodies, (envelope) => {
|
|
137
|
+
const e = envelope;
|
|
137
138
|
if (e.kind !== "tool.result" && e.kind !== "llm.request")
|
|
138
|
-
|
|
139
|
+
return [];
|
|
139
140
|
const raw = bodies(e.body_hash);
|
|
140
141
|
if (!raw || raw.length === 0)
|
|
141
|
-
|
|
142
|
+
return [];
|
|
142
143
|
let doc;
|
|
143
144
|
try {
|
|
144
145
|
doc = JSON.parse(decoder.decode(raw));
|
|
145
146
|
}
|
|
146
147
|
catch {
|
|
147
|
-
|
|
148
|
+
return [];
|
|
148
149
|
}
|
|
149
150
|
if (e.kind === "tool.result") {
|
|
150
151
|
const result = doc?.result;
|
|
151
152
|
if (result === undefined)
|
|
152
|
-
|
|
153
|
+
return [];
|
|
153
154
|
const rule = injectionRule(textOf(result));
|
|
154
155
|
const tool = typeof e.meta.tool === "string" ? e.meta.tool : undefined;
|
|
155
|
-
|
|
156
|
-
add(e.seq, "tool_result", rule, tool);
|
|
157
|
-
continue;
|
|
156
|
+
return rule ? [found(e.seq, "tool_result", rule, tool)] : [];
|
|
158
157
|
}
|
|
159
158
|
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
160
|
-
|
|
159
|
+
return [];
|
|
160
|
+
const out = [];
|
|
161
161
|
const seen = new Set();
|
|
162
162
|
for (const part of newestTurn(doc)) {
|
|
163
163
|
if (seen.has(part.source))
|
|
@@ -165,9 +165,9 @@ export function injectionFindings(lines, bodies) {
|
|
|
165
165
|
const rule = injectionRule(part.text);
|
|
166
166
|
if (rule) {
|
|
167
167
|
seen.add(part.source);
|
|
168
|
-
|
|
168
|
+
out.push(found(e.seq, part.source, rule));
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
return out;
|
|
172
|
+
});
|
|
173
173
|
}
|