@zanii/blackbox 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/analysis/accepted.d.ts +26 -0
- package/dist/analysis/accepted.js +61 -0
- package/dist/analysis/detectors.js +12 -9
- package/dist/analysis/faults.js +71 -0
- package/dist/analysis/grounding.js +5 -9
- package/dist/analysis/guard.d.ts +72 -0
- package/dist/analysis/guard.js +240 -0
- package/dist/analysis/hallucination.js +2 -2
- package/dist/analysis/index.d.ts +21 -1
- package/dist/analysis/index.js +101 -19
- package/dist/analysis/injection.d.ts +2 -1
- package/dist/analysis/injection.js +16 -16
- package/dist/analysis/landing.js +60 -14
- package/dist/analysis/plan-checks.d.ts +55 -0
- package/dist/analysis/plan-checks.js +748 -0
- package/dist/analysis/taxonomy.js +1 -0
- package/dist/analysis/waste.js +2 -3
- package/dist/approvals/index.d.ts +12 -3
- package/dist/approvals/index.js +36 -10
- package/dist/authority/index.js +3 -3
- package/dist/aviation/index.d.ts +422 -0
- package/dist/aviation/index.js +1014 -0
- package/dist/aviation/qrh.d.ts +4 -0
- package/dist/aviation/qrh.js +171 -0
- package/dist/budgets/index.d.ts +63 -0
- package/dist/budgets/index.js +161 -0
- package/dist/cli.js +172 -24
- package/dist/compliance/index.d.ts +2 -0
- package/dist/compliance/index.js +26 -2
- package/dist/cost/batch.d.ts +26 -0
- package/dist/cost/batch.js +173 -0
- package/dist/cost/index.d.ts +21 -0
- package/dist/cost/index.js +115 -30
- package/dist/cv/index.js +1 -1
- package/dist/duty/index.js +2 -1
- package/dist/evals/index.d.ts +233 -0
- package/dist/evals/index.js +488 -0
- package/dist/federation/index.d.ts +9 -0
- package/dist/federation/index.js +8 -1
- package/dist/finance/index.js +3 -4
- package/dist/fleet/index.d.ts +25 -0
- package/dist/fleet/index.js +57 -17
- package/dist/fleet/insights.js +4 -1
- package/dist/health/index.js +2 -2
- package/dist/hooks/ai-sdk.d.ts +17 -19
- package/dist/hooks/ai-sdk.js +13 -7
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/mastra.d.ts +26 -0
- package/dist/hooks/mastra.js +63 -0
- package/dist/index.d.ts +22 -9
- package/dist/index.js +23 -9
- package/dist/mcp-requests/index.d.ts +26 -0
- package/dist/mcp-requests/index.js +154 -0
- package/dist/mcp-server/index.d.ts +12 -0
- package/dist/mcp-server/index.js +84 -2
- package/dist/mcp-wrap/index.js +8 -4
- package/dist/otlp/index.d.ts +155 -0
- package/dist/otlp/index.js +468 -0
- package/dist/otlp/protobuf.d.ts +7 -0
- package/dist/otlp/protobuf.js +314 -0
- package/dist/packs/index.d.ts +2 -0
- package/dist/packs/index.js +5 -1
- package/dist/pdf/arabic.d.ts +45 -0
- package/dist/pdf/arabic.js +373 -0
- package/dist/pdf/index.d.ts +77 -0
- package/dist/pdf/index.js +673 -0
- package/dist/policy/index.d.ts +0 -4
- package/dist/policy/index.js +13 -5
- package/dist/realtime/index.d.ts +33 -0
- package/dist/realtime/index.js +204 -0
- package/dist/reconcile/record.d.ts +12 -0
- package/dist/reconcile/record.js +114 -65
- package/dist/session/index.d.ts +43 -0
- package/dist/session/index.js +83 -0
- package/dist/undo/index.js +3 -3
- package/dist/verify/anchor.d.ts +5 -1
- package/dist/verify/anchor.js +13 -4
- package/dist/verify/envelope.d.ts +1 -1
- package/dist/verify/envelope.js +5 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walls/index.js +2 -2
- package/dist/warehouse/index.d.ts +62 -0
- package/dist/warehouse/index.js +142 -0
- package/package.json +6 -3
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
// Aviation practices for agent fleets (spec/aviation.md): clearances and resolution advisories,
|
|
2
|
+
// NOTAMs, the emergency locator, the protected reasoning track, safety reports, the quick
|
|
3
|
+
// reference handbook, the commit point, mayday, envelope protection, the load sheet and OpSpecs.
|
|
4
|
+
// Pure; mirrors sdks/python/src/zanii_blackbox/aviation.py; pinned by spec/vectors/aviation-*.json.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { docHash } from "../admission/index.js";
|
|
7
|
+
import { faultOf } from "../analysis/faults.js";
|
|
8
|
+
import { authorityTimeline } from "../authority/index.js";
|
|
9
|
+
import { callMicroUsd, priceFor, sessionCost } from "../cost/index.js";
|
|
10
|
+
import { resourceChanges, verifyCertificate } from "../fleet/index.js";
|
|
11
|
+
import { canonical } from "../reconcile/shared.js";
|
|
12
|
+
import { QRH } from "./qrh.js";
|
|
13
|
+
export { QRH };
|
|
14
|
+
const parse = (lines) => lines.map((l) => JSON.parse(l));
|
|
15
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
16
|
+
/** A `*` pattern as a whole-name match (spec/policy.md §1's tool patterns). */
|
|
17
|
+
export function globMatch(pattern, name) {
|
|
18
|
+
const re = pattern
|
|
19
|
+
.split("*")
|
|
20
|
+
.map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
21
|
+
.join(".*");
|
|
22
|
+
return new RegExp(`^${re}$`, "s").test(name);
|
|
23
|
+
}
|
|
24
|
+
/** spec/aviation.md §1: may this session have the resource now? §2: and what each side does. */
|
|
25
|
+
export function clearance(active, request) {
|
|
26
|
+
const at = Date.parse(request.at);
|
|
27
|
+
const conflicts = active
|
|
28
|
+
.filter((c) => c.resource === request.resource &&
|
|
29
|
+
c.session_id !== request.session_id &&
|
|
30
|
+
Date.parse(c.expires_at) > at &&
|
|
31
|
+
!(c.mode === "shared" && request.mode === "shared"))
|
|
32
|
+
.map(({ session_id, mode, expires_at }) => ({ session_id, mode, expires_at }))
|
|
33
|
+
.sort((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at) || cmp(a.session_id, b.session_id));
|
|
34
|
+
if (conflicts.length === 0)
|
|
35
|
+
return { decision: "granted", conflicts: [], advisories: [] };
|
|
36
|
+
const first = Date.parse(conflicts[0].expires_at);
|
|
37
|
+
return {
|
|
38
|
+
decision: "hold",
|
|
39
|
+
retry_after_s: Math.max(1, Math.ceil((first - at) / 1000)),
|
|
40
|
+
conflicts,
|
|
41
|
+
advisories: [
|
|
42
|
+
{ session_id: request.session_id, action: "hold" },
|
|
43
|
+
...conflicts.map((c) => ({ session_id: c.session_id, action: "proceed" })),
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** spec/aviation.md §1: each change made without an exclusive clearance in force for it. */
|
|
48
|
+
export function clearanceFindings(lines, bodies) {
|
|
49
|
+
const events = parse(lines);
|
|
50
|
+
const ts = new Map(events.map((e) => [e.seq, Date.parse(e.ts)]));
|
|
51
|
+
const granted = new Map();
|
|
52
|
+
const released = new Map();
|
|
53
|
+
for (const e of events) {
|
|
54
|
+
if (e.kind !== "control")
|
|
55
|
+
continue;
|
|
56
|
+
const m = e.meta;
|
|
57
|
+
if (m.action === "clearance" &&
|
|
58
|
+
m.decision === "granted" &&
|
|
59
|
+
m.mode === "exclusive" &&
|
|
60
|
+
typeof m.clearance_id === "string" &&
|
|
61
|
+
typeof m.resource === "string" &&
|
|
62
|
+
typeof m.expires_at === "string")
|
|
63
|
+
granted.set(m.clearance_id, {
|
|
64
|
+
seq: e.seq,
|
|
65
|
+
resource: m.resource,
|
|
66
|
+
expires: Date.parse(m.expires_at),
|
|
67
|
+
});
|
|
68
|
+
if (m.action === "clearance_release" && typeof m.clearance_id === "string")
|
|
69
|
+
released.set(m.clearance_id, e.seq);
|
|
70
|
+
}
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const c of resourceChanges(lines, bodies)) {
|
|
73
|
+
const at = ts.get(c.seq) ?? 0;
|
|
74
|
+
const covered = [...granted].some(([id, g]) => g.resource === c.resource &&
|
|
75
|
+
g.seq < c.seq &&
|
|
76
|
+
g.expires > at &&
|
|
77
|
+
!((released.get(id) ?? Number.POSITIVE_INFINITY) < c.seq));
|
|
78
|
+
if (!covered)
|
|
79
|
+
out.push({
|
|
80
|
+
code: "NO_CLEARANCE",
|
|
81
|
+
source: "clearance",
|
|
82
|
+
severity: "caution",
|
|
83
|
+
ref: { seq: c.seq, resource: c.resource },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
89
|
+
const DAY_MS = 86_400_000;
|
|
90
|
+
const isoMs = (v) => typeof v === "string" && ISO.test(v) && Number.isFinite(Date.parse(v)) ? Date.parse(v) : null;
|
|
91
|
+
const listOf = (v, max, ok) => v === undefined
|
|
92
|
+
? []
|
|
93
|
+
: Array.isArray(v) && v.length <= max && v.every((x) => typeof x === "string" && ok(x))
|
|
94
|
+
? v
|
|
95
|
+
: null;
|
|
96
|
+
/** No control characters (newlines too, unless allowed). */
|
|
97
|
+
const plain = (text, newlines = false) => ![...text].some((ch) => (ch < " " && !(newlines && ch === "\n")) || ch === "\u007f");
|
|
98
|
+
/** spec/aviation.md §3: a notice's fields with defaults filled in, or an error message. */
|
|
99
|
+
export function checkNotam(input, now) {
|
|
100
|
+
const { text } = input;
|
|
101
|
+
if (typeof text !== "string" || text.length < 1 || text.length > 1000 || !plain(text, true))
|
|
102
|
+
return { ok: false, error: "text must be 1-1000 characters, no control characters" };
|
|
103
|
+
const from = input.from === undefined ? Date.parse(now) : isoMs(input.from);
|
|
104
|
+
if (from === null)
|
|
105
|
+
return { ok: false, error: "from must be a UTC ISO-8601 time" };
|
|
106
|
+
const until = isoMs(input.until);
|
|
107
|
+
if (until === null)
|
|
108
|
+
return { ok: false, error: "until must be a UTC ISO-8601 time" };
|
|
109
|
+
if (until <= from || until - from > 90 * DAY_MS)
|
|
110
|
+
return { ok: false, error: "until must be after from, at most 90 days after it" };
|
|
111
|
+
const tools = listOf(input.tools, 50, (x) => x.length >= 1 && x.length <= 128 && plain(x));
|
|
112
|
+
if (tools === null)
|
|
113
|
+
return { ok: false, error: "tools must be up to 50 patterns of 1-128 characters" };
|
|
114
|
+
const providers = listOf(input.providers, 20, (x) => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(x));
|
|
115
|
+
if (providers === null)
|
|
116
|
+
return { ok: false, error: "providers must be up to 20 provider ids" };
|
|
117
|
+
const effect = input.effect ?? "inform";
|
|
118
|
+
if (effect !== "inform" && effect !== "avoid")
|
|
119
|
+
return { ok: false, error: "effect must be inform or avoid" };
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
notam: {
|
|
123
|
+
text,
|
|
124
|
+
from: new Date(from).toISOString(),
|
|
125
|
+
until: new Date(until).toISOString(),
|
|
126
|
+
tools,
|
|
127
|
+
providers,
|
|
128
|
+
effect,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** spec/aviation.md §3: the notices in force at `at` for a tenant, by `from` then `id`. */
|
|
133
|
+
export function notamsInForce(notams, at, tenant) {
|
|
134
|
+
const t = Date.parse(at);
|
|
135
|
+
return notams
|
|
136
|
+
.filter((n) => !(n.cancelled_at !== null && Date.parse(n.cancelled_at) <= t) &&
|
|
137
|
+
Date.parse(n.from) <= t &&
|
|
138
|
+
t < Date.parse(n.until) &&
|
|
139
|
+
(n.tenant === null || n.tenant === tenant))
|
|
140
|
+
.sort((a, b) => Date.parse(a.from) - Date.parse(b.from) || cmp(a.id, b.id));
|
|
141
|
+
}
|
|
142
|
+
/** spec/aviation.md §3: the first event each `avoid` notice in force covers. */
|
|
143
|
+
export function notamFindings(lines, notams) {
|
|
144
|
+
const events = parse(lines);
|
|
145
|
+
const tenant = events[0]?.kind === "session.open" ? events[0].meta.tenant : undefined;
|
|
146
|
+
const avoid = notams.filter((n) => n.effect === "avoid");
|
|
147
|
+
const out = [];
|
|
148
|
+
const hit = new Set();
|
|
149
|
+
for (const e of events) {
|
|
150
|
+
let names = [];
|
|
151
|
+
let provider;
|
|
152
|
+
if (e.kind === "tool.call" && e.meta.method === "tools/call" && typeof e.meta.tool === "string")
|
|
153
|
+
names = [`mcp__${String(e.meta.server)}__${e.meta.tool}`, e.meta.tool];
|
|
154
|
+
else if (e.kind === "llm.request" && typeof e.meta.provider === "string")
|
|
155
|
+
provider = e.meta.provider;
|
|
156
|
+
else
|
|
157
|
+
continue;
|
|
158
|
+
for (const n of notamsInForce(avoid, e.ts, typeof tenant === "string" ? tenant : null)) {
|
|
159
|
+
if (hit.has(n.id))
|
|
160
|
+
continue;
|
|
161
|
+
const covers = provider !== undefined
|
|
162
|
+
? n.providers.includes(provider)
|
|
163
|
+
: n.tools.some((p) => names.some((name) => globMatch(p, name)));
|
|
164
|
+
if (!covers)
|
|
165
|
+
continue;
|
|
166
|
+
hit.add(n.id);
|
|
167
|
+
out.push({
|
|
168
|
+
code: "NOTAM_CONFLICT",
|
|
169
|
+
source: "notam",
|
|
170
|
+
severity: "caution",
|
|
171
|
+
ref: { seq: e.seq, notam: n.id },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------- §9 the commit point
|
|
178
|
+
/** spec/aviation.md §9: the seq of the session's first `commit_point` SDK event, or null. */
|
|
179
|
+
export function commitPointSeq(lines) {
|
|
180
|
+
for (const e of parse(lines))
|
|
181
|
+
if (e.kind === "sdk.event" &&
|
|
182
|
+
e.meta.type === "commit_point" &&
|
|
183
|
+
e.meta.duplicate_of === undefined)
|
|
184
|
+
return e.seq;
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
/** spec/aviation.md §9: a non-critical finding that comes after the commit point. */
|
|
188
|
+
export function pastCommitPoint(lines, finding) {
|
|
189
|
+
const at = commitPointSeq(lines);
|
|
190
|
+
if (at === null || finding.severity === "warning")
|
|
191
|
+
return false;
|
|
192
|
+
const seq = finding.ref.seq;
|
|
193
|
+
return typeof seq === "number" ? seq > at : true;
|
|
194
|
+
}
|
|
195
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
196
|
+
const isInt = (v) => typeof v === "number" && Number.isSafeInteger(v);
|
|
197
|
+
/** The output-token limits of a model request, in the order they're read and capped. */
|
|
198
|
+
const OUTPUT_LIMITS = [
|
|
199
|
+
["max_tokens"],
|
|
200
|
+
["max_completion_tokens"],
|
|
201
|
+
["max_output_tokens"],
|
|
202
|
+
["generationConfig", "maxOutputTokens"],
|
|
203
|
+
["inferenceConfig", "maxTokens"],
|
|
204
|
+
];
|
|
205
|
+
/** The object holding a limit, and its key. */
|
|
206
|
+
const limitAt = (body, path) => {
|
|
207
|
+
const holder = path.length === 1 ? body : body[path[0]];
|
|
208
|
+
return { holder: isObj(holder) ? holder : undefined, key: path[path.length - 1] };
|
|
209
|
+
};
|
|
210
|
+
/** spec/aviation.md §15: an error message, or null for a valid envelope. */
|
|
211
|
+
export function checkEnvelope(doc) {
|
|
212
|
+
if (!isObj(doc) || doc.v !== 1)
|
|
213
|
+
return "the envelope must be {v: 1, ...}";
|
|
214
|
+
const extra = Object.keys(doc).filter((k) => !["v", "forbidden_tools", "max_output_tokens"].includes(k));
|
|
215
|
+
if (extra.length)
|
|
216
|
+
return `the envelope has an unknown field: ${extra.sort().join(", ")}`;
|
|
217
|
+
const tools = doc.forbidden_tools;
|
|
218
|
+
if (tools !== undefined &&
|
|
219
|
+
!(Array.isArray(tools) &&
|
|
220
|
+
tools.length <= 100 &&
|
|
221
|
+
tools.every((t) => typeof t === "string" && t.length >= 1 && t.length <= 128 && plain(t))))
|
|
222
|
+
return "forbidden_tools must be up to 100 tool patterns of 1-128 characters";
|
|
223
|
+
const max = doc.max_output_tokens;
|
|
224
|
+
if (max !== undefined && !(isInt(max) && max >= 1 && max <= 1_000_000))
|
|
225
|
+
return "max_output_tokens must be an integer 1-1000000";
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/** A tool definition's name: Anthropic's `name`, or Chat Completions' `function.name`. */
|
|
229
|
+
const toolName = (t) => {
|
|
230
|
+
if (!isObj(t))
|
|
231
|
+
return undefined;
|
|
232
|
+
if (typeof t.name === "string")
|
|
233
|
+
return t.name;
|
|
234
|
+
return isObj(t.function) && typeof t.function.name === "string" ? t.function.name : undefined;
|
|
235
|
+
};
|
|
236
|
+
/** spec/aviation.md §15: the request inside the envelope, and what changed; null when nothing did. */
|
|
237
|
+
export function shapeRequest(body, envelope) {
|
|
238
|
+
if (!isObj(body))
|
|
239
|
+
return null;
|
|
240
|
+
const out = structuredClone(body);
|
|
241
|
+
const changes = [];
|
|
242
|
+
const cap = envelope.max_output_tokens;
|
|
243
|
+
if (cap !== undefined)
|
|
244
|
+
for (const path of OUTPUT_LIMITS) {
|
|
245
|
+
const { holder, key } = limitAt(out, path);
|
|
246
|
+
const v = holder?.[key];
|
|
247
|
+
if (holder && isInt(v) && v > cap) {
|
|
248
|
+
holder[key] = cap;
|
|
249
|
+
changes.push({ field: path.join("."), from: v, to: cap });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const forbidden = envelope.forbidden_tools ?? [];
|
|
253
|
+
if (forbidden.length && Array.isArray(out.tools)) {
|
|
254
|
+
const all = out.tools;
|
|
255
|
+
const kept = all.filter((t) => {
|
|
256
|
+
const name = toolName(t);
|
|
257
|
+
if (name === undefined || !forbidden.some((p) => globMatch(p, name)))
|
|
258
|
+
return true;
|
|
259
|
+
changes.push({ removed_tool: name });
|
|
260
|
+
return false;
|
|
261
|
+
});
|
|
262
|
+
if (kept.length !== all.length) {
|
|
263
|
+
if (kept.length)
|
|
264
|
+
out.tools = kept;
|
|
265
|
+
else {
|
|
266
|
+
delete out.tools;
|
|
267
|
+
delete out.tool_choice;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return changes.length ? { body: out, changes } : null;
|
|
272
|
+
}
|
|
273
|
+
/** spec/aviation.md §15: each model request the envelope shaped. */
|
|
274
|
+
export function envelopeFindings(lines) {
|
|
275
|
+
return parse(lines)
|
|
276
|
+
.filter((e) => e.kind === "llm.request" && isObj(e.meta.envelope))
|
|
277
|
+
.map((e) => ({
|
|
278
|
+
code: "ENVELOPE_SHAPED",
|
|
279
|
+
source: "envelope",
|
|
280
|
+
severity: "advisory",
|
|
281
|
+
ref: { seq: e.seq },
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
const encoder = new TextEncoder();
|
|
285
|
+
/** Estimated tokens: the canonical JSON's bytes over 4, rounded up. */
|
|
286
|
+
const estimate = (v) => Math.ceil(encoder.encode(canonical(v)).length / 4);
|
|
287
|
+
/** spec/aviation.md §17: the load sheet of a model request. Pure; an estimate. */
|
|
288
|
+
export function loadSheet(body, options = {}) {
|
|
289
|
+
const b = isObj(body) ? body : {};
|
|
290
|
+
const msgs = Array.isArray(b.messages) ? b.messages : [];
|
|
291
|
+
const isSystem = (m) => isObj(m) && (m.role === "system" || m.role === "developer");
|
|
292
|
+
let system = 0;
|
|
293
|
+
for (const k of ["system", "instructions", "systemInstruction"])
|
|
294
|
+
if (b[k] !== undefined)
|
|
295
|
+
system += estimate(b[k]);
|
|
296
|
+
for (const m of msgs)
|
|
297
|
+
if (isSystem(m))
|
|
298
|
+
system += estimate(m);
|
|
299
|
+
const toolList = Array.isArray(b.tools)
|
|
300
|
+
? b.tools
|
|
301
|
+
: isObj(b.toolConfig) && Array.isArray(b.toolConfig.tools)
|
|
302
|
+
? b.toolConfig.tools
|
|
303
|
+
: null;
|
|
304
|
+
const tools = toolList ? estimate(toolList) : 0;
|
|
305
|
+
const rest = msgs.filter((m) => !isSystem(m));
|
|
306
|
+
let messages = rest.length ? estimate(rest) : 0;
|
|
307
|
+
for (const k of ["input", "contents"])
|
|
308
|
+
if (b[k] !== undefined)
|
|
309
|
+
messages += estimate(b[k]);
|
|
310
|
+
const total = system + tools + messages;
|
|
311
|
+
let maxOut = null;
|
|
312
|
+
for (const path of OUTPUT_LIMITS) {
|
|
313
|
+
const { holder, key } = limitAt(b, path);
|
|
314
|
+
const v = holder?.[key];
|
|
315
|
+
if (isInt(v)) {
|
|
316
|
+
maxOut = v;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const model = options.model ?? (typeof b.model === "string" ? b.model : null);
|
|
321
|
+
const price = model && options.prices ? priceFor(model, options.prices.table)?.price : undefined;
|
|
322
|
+
const window = price?.context_window ?? null;
|
|
323
|
+
const pct = window ? Math.floor((total * 100) / window) : null;
|
|
324
|
+
const expected = price
|
|
325
|
+
? callMicroUsd({ input: total, output: maxOut ?? 0, cache_read: 0, cache_write: 0 }, price)
|
|
326
|
+
: null;
|
|
327
|
+
const budget = options.budgetMicroUsd ?? null;
|
|
328
|
+
const reasons = [];
|
|
329
|
+
if (window && total + (maxOut ?? 0) > window)
|
|
330
|
+
reasons.push("context");
|
|
331
|
+
if (expected !== null && budget !== null && expected > budget)
|
|
332
|
+
reasons.push("budget");
|
|
333
|
+
const count = toolList ? toolList.length : 0;
|
|
334
|
+
return {
|
|
335
|
+
v: 1,
|
|
336
|
+
model,
|
|
337
|
+
parts: { system, tools, messages },
|
|
338
|
+
total_tokens: total,
|
|
339
|
+
max_output_tokens: maxOut,
|
|
340
|
+
context_window: window,
|
|
341
|
+
context_pct: pct,
|
|
342
|
+
tools: count,
|
|
343
|
+
expected_micro_usd: expected,
|
|
344
|
+
budget_micro_usd: budget,
|
|
345
|
+
overweight: reasons.length > 0,
|
|
346
|
+
reasons,
|
|
347
|
+
risk: reasons.length > 0 || (pct !== null && pct >= 90)
|
|
348
|
+
? "high"
|
|
349
|
+
: (pct !== null && pct >= 70) || count > 32
|
|
350
|
+
? "medium"
|
|
351
|
+
: "low",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ---------------------------------------------------------------- §19 OpSpecs and classification
|
|
355
|
+
export const LEVELS = ["public", "restricted", "confidential", "secret"];
|
|
356
|
+
export const isLevel = (v) => LEVELS.includes(v);
|
|
357
|
+
/** spec/aviation.md §19: an error message, or null for valid OpSpecs. */
|
|
358
|
+
export function checkOpspecs(doc) {
|
|
359
|
+
if (!isObj(doc) || doc.v !== 1)
|
|
360
|
+
return "the OpSpecs must be {v: 1, ...}";
|
|
361
|
+
const known = ["v", "default_classification", "default", "environments", "providers", "tools"];
|
|
362
|
+
const extra = Object.keys(doc).filter((k) => !known.includes(k));
|
|
363
|
+
if (extra.length)
|
|
364
|
+
return `the OpSpecs have an unknown field: ${extra.sort().join(", ")}`;
|
|
365
|
+
for (const k of ["default_classification", "default"])
|
|
366
|
+
if (doc[k] !== undefined && !isLevel(doc[k]))
|
|
367
|
+
return `${k} must be one of ${LEVELS.join(", ")}`;
|
|
368
|
+
const names = [
|
|
369
|
+
["environments", (k) => /^[a-z][a-z0-9-]{0,31}$/.test(k)],
|
|
370
|
+
["providers", (k) => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(k)],
|
|
371
|
+
["tools", (k) => k.length >= 1 && k.length <= 128 && plain(k)],
|
|
372
|
+
];
|
|
373
|
+
for (const [k, ok] of names) {
|
|
374
|
+
const m = doc[k];
|
|
375
|
+
if (m === undefined)
|
|
376
|
+
continue;
|
|
377
|
+
if (!isObj(m) || Object.keys(m).length > 1000)
|
|
378
|
+
return `${k} must map at most 1000 names to levels`;
|
|
379
|
+
for (const [name, level] of Object.entries(m))
|
|
380
|
+
if (!ok(name) || !isLevel(level))
|
|
381
|
+
return `${k}: "${name}" must map a valid name to a level`;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/** spec/aviation.md §19: null when approved at this classification, else why not. */
|
|
386
|
+
export function opspecsCheck(specs, classification, item) {
|
|
387
|
+
const def = specs.default ?? "secret";
|
|
388
|
+
let reason;
|
|
389
|
+
let max;
|
|
390
|
+
if ("environment" in item) {
|
|
391
|
+
reason = "environment";
|
|
392
|
+
max = specs.environments?.[item.environment] ?? def;
|
|
393
|
+
}
|
|
394
|
+
else if ("provider" in item) {
|
|
395
|
+
reason = "provider";
|
|
396
|
+
max = specs.providers?.[item.provider] ?? def;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
reason = "tool";
|
|
400
|
+
const hit = Object.entries(specs.tools ?? {}).find(([p]) => item.tool.some((n) => globMatch(p, n)));
|
|
401
|
+
max = hit ? hit[1] : def;
|
|
402
|
+
}
|
|
403
|
+
return LEVELS.indexOf(classification) > LEVELS.indexOf(max)
|
|
404
|
+
? { reason, classification, max }
|
|
405
|
+
: null;
|
|
406
|
+
}
|
|
407
|
+
/** The model requests and tool calls still open just before `at` (a seq). */
|
|
408
|
+
function openAt(events, at) {
|
|
409
|
+
const done = new Set();
|
|
410
|
+
for (const e of events) {
|
|
411
|
+
if (e.seq >= at)
|
|
412
|
+
break;
|
|
413
|
+
if ((e.kind === "llm.response" || e.kind === "llm.incomplete") && isInt(e.meta.request_seq))
|
|
414
|
+
done.add(`r${e.meta.request_seq}`);
|
|
415
|
+
if (e.kind === "tool.result" && isInt(e.meta.call_seq))
|
|
416
|
+
done.add(`t${e.meta.call_seq}`);
|
|
417
|
+
}
|
|
418
|
+
const out = [];
|
|
419
|
+
for (const e of events) {
|
|
420
|
+
if (e.seq >= at)
|
|
421
|
+
break;
|
|
422
|
+
if (e.kind === "llm.request" && !done.has(`r${e.seq}`))
|
|
423
|
+
out.push({
|
|
424
|
+
seq: e.seq,
|
|
425
|
+
kind: e.kind,
|
|
426
|
+
at: e.ts,
|
|
427
|
+
...(typeof e.meta.provider === "string" ? { provider: e.meta.provider } : {}),
|
|
428
|
+
});
|
|
429
|
+
if (e.kind === "tool.call" && !done.has(`t${e.seq}`))
|
|
430
|
+
out.push({
|
|
431
|
+
seq: e.seq,
|
|
432
|
+
kind: e.kind,
|
|
433
|
+
at: e.ts,
|
|
434
|
+
...(typeof e.meta.tool === "string" ? { tool: e.meta.tool } : {}),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
/** spec/aviation.md §4: the session's last known state, packaged when it goes silent. */
|
|
440
|
+
export function locator(lines) {
|
|
441
|
+
const events = parse(lines);
|
|
442
|
+
const open = events[0]?.kind === "session.open" ? events[0] : undefined;
|
|
443
|
+
const lost = events.filter((e) => e.kind === "session.lost_contact").at(-1);
|
|
444
|
+
const at = lost ? lost.seq : Number.POSITIVE_INFINITY;
|
|
445
|
+
const findings = [];
|
|
446
|
+
for (const e of events)
|
|
447
|
+
if (e.kind === "finding" && typeof e.meta.code === "string" && !findings.includes(e.meta.code))
|
|
448
|
+
findings.push(e.meta.code);
|
|
449
|
+
const span = authorityTimeline(lines)
|
|
450
|
+
.filter((s) => s.from_seq <= at)
|
|
451
|
+
.at(-1);
|
|
452
|
+
return {
|
|
453
|
+
session_id: JSON.parse(lines[0] ?? "{}").session_id ?? null,
|
|
454
|
+
label: typeof open?.meta.label === "string" ? open.meta.label : null,
|
|
455
|
+
lost_contact: lost
|
|
456
|
+
? {
|
|
457
|
+
seq: lost.seq,
|
|
458
|
+
at: lost.ts,
|
|
459
|
+
last_seen: lost.meta.last_seen ?? null,
|
|
460
|
+
silent_ms: lost.meta.silent_ms ?? null,
|
|
461
|
+
}
|
|
462
|
+
: null,
|
|
463
|
+
open_actions: openAt(events, at),
|
|
464
|
+
last_events: events
|
|
465
|
+
.filter((e) => e.seq <= at)
|
|
466
|
+
.slice(-5)
|
|
467
|
+
.map((e) => ({ seq: e.seq, kind: e.kind, ts: e.ts })),
|
|
468
|
+
findings,
|
|
469
|
+
controls: span ? { mode: span.mode, by: span.by } : null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** spec/aviation.md §4: each loss of contact while an action was open. */
|
|
473
|
+
export function locatorFindings(lines) {
|
|
474
|
+
const events = parse(lines);
|
|
475
|
+
const out = [];
|
|
476
|
+
for (const e of events) {
|
|
477
|
+
if (e.kind !== "session.lost_contact")
|
|
478
|
+
continue;
|
|
479
|
+
const open = openAt(events, e.seq);
|
|
480
|
+
if (open.length)
|
|
481
|
+
out.push({
|
|
482
|
+
code: "EMERGENCY_LOCATOR",
|
|
483
|
+
source: "locator",
|
|
484
|
+
severity: "warning",
|
|
485
|
+
ref: { seq: e.seq },
|
|
486
|
+
figures: { open_actions: open.length },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
/** spec/aviation.md §5: where an opening request stands at `at`. */
|
|
492
|
+
export function reasoningStatus(request, at) {
|
|
493
|
+
if (request.decision === "reject")
|
|
494
|
+
return "rejected";
|
|
495
|
+
if (request.decision !== "approve")
|
|
496
|
+
return "pending";
|
|
497
|
+
return request.expires_at !== null && Date.parse(at) < Date.parse(request.expires_at)
|
|
498
|
+
? "open"
|
|
499
|
+
: "expired";
|
|
500
|
+
}
|
|
501
|
+
/** spec/aviation.md §5: whether two deciders are one person: the same login (iss + sub), or the
|
|
502
|
+
* same name or email (case and surrounding spaces ignored). */
|
|
503
|
+
export function samePerson(a, b) {
|
|
504
|
+
if (typeof a.iss === "string" && typeof a.sub === "string" && a.iss === b.iss && a.sub === b.sub)
|
|
505
|
+
return true;
|
|
506
|
+
const names = (p) => [p.name, p.email]
|
|
507
|
+
.filter((v) => typeof v === "string" && v.trim() !== "")
|
|
508
|
+
.map((v) => v.trim().toLowerCase());
|
|
509
|
+
const theirs = names(b);
|
|
510
|
+
return names(a).some((n) => theirs.includes(n));
|
|
511
|
+
}
|
|
512
|
+
/** spec/aviation.md §5: an actions page with the reasoning text taken out. */
|
|
513
|
+
export function protectReasoning(page) {
|
|
514
|
+
return {
|
|
515
|
+
...page,
|
|
516
|
+
actions: page.actions.map((a) => ({
|
|
517
|
+
...a,
|
|
518
|
+
reasoning: { kind: a.reasoning.kind, protected: true },
|
|
519
|
+
})),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
// ---------------------------------------------------------------- §6, §10 reports from the agent and from people
|
|
523
|
+
export const SAFETY_CATEGORIES = ["near_miss", "hazard", "procedure", "tooling", "other"];
|
|
524
|
+
/** spec/aviation.md §6: a person's safety report, checked: the fields, or an error message. */
|
|
525
|
+
export function checkSafetyReport(input) {
|
|
526
|
+
const category = input.category ?? "other";
|
|
527
|
+
if (!SAFETY_CATEGORIES.includes(category))
|
|
528
|
+
return { ok: false, error: `category must be one of ${SAFETY_CATEGORIES.join(", ")}` };
|
|
529
|
+
const { description } = input;
|
|
530
|
+
if (typeof description !== "string" ||
|
|
531
|
+
description.length < 1 ||
|
|
532
|
+
description.length > 4000 ||
|
|
533
|
+
!plain(description, true))
|
|
534
|
+
return { ok: false, error: "description must be 1-4000 characters, no control characters" };
|
|
535
|
+
return {
|
|
536
|
+
ok: true,
|
|
537
|
+
report: { category: category, description },
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** spec/aviation.md §6: report counts by category, only those at least `k` tenants reported. */
|
|
541
|
+
export function safetyReportSummary(reports, k = 3) {
|
|
542
|
+
const kk = Math.max(3, Math.trunc(k));
|
|
543
|
+
const by = new Map();
|
|
544
|
+
for (const r of reports) {
|
|
545
|
+
const g = by.get(r.category) ?? { reports: 0, tenants: new Set() };
|
|
546
|
+
g.reports++;
|
|
547
|
+
g.tenants.add(r.tenant);
|
|
548
|
+
by.set(r.category, g);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
k: kk,
|
|
552
|
+
categories: [...by.entries()]
|
|
553
|
+
.filter(([, g]) => g.tenants.size >= kk)
|
|
554
|
+
.sort(([a], [b]) => cmp(a, b))
|
|
555
|
+
.map(([category, g]) => ({ category, reports: g.reports, tenants: g.tenants.size })),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** spec/aviation.md §6, §10: the agent's own safety reports, maydays and pan-pans. */
|
|
559
|
+
export function agentReportFindings(lines) {
|
|
560
|
+
const out = [];
|
|
561
|
+
for (const e of parse(lines)) {
|
|
562
|
+
if (e.kind !== "sdk.event" || e.meta.duplicate_of !== undefined)
|
|
563
|
+
continue;
|
|
564
|
+
const type = e.meta.type;
|
|
565
|
+
const ref = { seq: e.seq, non_punitive: true };
|
|
566
|
+
const kind = typeof e.meta.name === "string" ? { detail: e.meta.name } : {};
|
|
567
|
+
if (type === "safety_report")
|
|
568
|
+
out.push({ code: "SAFETY_REPORT", source: "agent", severity: "advisory", ref });
|
|
569
|
+
else if (type === "mayday")
|
|
570
|
+
out.push({ code: "MAYDAY", source: "agent", severity: "warning", ref, ...kind });
|
|
571
|
+
else if (type === "pan_pan")
|
|
572
|
+
out.push({ code: "PAN_PAN", source: "agent", severity: "caution", ref, ...kind });
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
/** spec/aviation.md §8: the procedure for a fault code, or the generic one. */
|
|
577
|
+
export function procedureFor(code) {
|
|
578
|
+
const fault = faultOf(code);
|
|
579
|
+
const steps = QRH.procedures[code];
|
|
580
|
+
return {
|
|
581
|
+
code,
|
|
582
|
+
fault: fault.fault,
|
|
583
|
+
title: fault.en,
|
|
584
|
+
title_ar: fault.ar,
|
|
585
|
+
steps: [...(steps ?? QRH.generic)],
|
|
586
|
+
source: steps ? "qrh" : "generic",
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const HAZARD_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
590
|
+
const FINDING_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
591
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
592
|
+
const isDate = (v) => typeof v === "string" &&
|
|
593
|
+
DATE.test(v) &&
|
|
594
|
+
!Number.isNaN(Date.parse(`${v}T00:00:00Z`)) &&
|
|
595
|
+
new Date(`${v}T00:00:00Z`).toISOString().slice(0, 10) === v;
|
|
596
|
+
const text = (v, max) => typeof v === "string" && v.length >= 1 && v.length <= max && plain(v);
|
|
597
|
+
/** spec/aviation.md §7: an error message, or null for a valid hazard register. */
|
|
598
|
+
export function checkHazardRegister(doc) {
|
|
599
|
+
if (!isObj(doc) || doc.v !== 1 || doc.type !== "blackbox.hazard-register")
|
|
600
|
+
return "the register must be {v: 1, type: blackbox.hazard-register, hazards}";
|
|
601
|
+
const hazards = doc.hazards;
|
|
602
|
+
if (!Array.isArray(hazards) || hazards.length > 500)
|
|
603
|
+
return "hazards must be a list of at most 500";
|
|
604
|
+
const seen = new Set();
|
|
605
|
+
for (const [i, h] of hazards.entries()) {
|
|
606
|
+
const at = `hazards[${i}]`;
|
|
607
|
+
if (!isObj(h))
|
|
608
|
+
return `${at} must be an object`;
|
|
609
|
+
if (typeof h.id !== "string" || !HAZARD_ID.test(h.id) || seen.has(h.id))
|
|
610
|
+
return `${at}.id must be a unique id of 1-64 of a-z 0-9 . _ -`;
|
|
611
|
+
seen.add(h.id);
|
|
612
|
+
if (!text(h.title, 200))
|
|
613
|
+
return `${at}.title must be 1-200 characters`;
|
|
614
|
+
for (const k of ["likelihood", "severity"])
|
|
615
|
+
if (!isInt(h[k]) || h[k] < 1 || h[k] > 5)
|
|
616
|
+
return `${at}.${k} must be an integer 1-5`;
|
|
617
|
+
if (!Array.isArray(h.codes) ||
|
|
618
|
+
h.codes.length > 50 ||
|
|
619
|
+
!h.codes.every((c) => typeof c === "string" && FINDING_CODE.test(c)))
|
|
620
|
+
return `${at}.codes must be up to 50 finding codes`;
|
|
621
|
+
if (!Array.isArray(h.mitigations) ||
|
|
622
|
+
h.mitigations.length > 20 ||
|
|
623
|
+
!h.mitigations.every((m) => text(m, 500)))
|
|
624
|
+
return `${at}.mitigations must be up to 20 texts of 1-500 characters`;
|
|
625
|
+
if (!text(h.owner, 128))
|
|
626
|
+
return `${at}.owner must be 1-128 characters`;
|
|
627
|
+
if (!isDate(h.review_by))
|
|
628
|
+
return `${at}.review_by must be a date (YYYY-MM-DD)`;
|
|
629
|
+
const extra = Object.keys(h).filter((k) => ![
|
|
630
|
+
"id",
|
|
631
|
+
"title",
|
|
632
|
+
"likelihood",
|
|
633
|
+
"severity",
|
|
634
|
+
"codes",
|
|
635
|
+
"mitigations",
|
|
636
|
+
"owner",
|
|
637
|
+
"review_by",
|
|
638
|
+
].includes(k));
|
|
639
|
+
if (extra.length)
|
|
640
|
+
return `${at} has an unknown field: ${extra.sort().join(", ")}`;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
/** spec/aviation.md §7: each hazard's risk, and the sessions that show it, highest risk first. */
|
|
645
|
+
export function hazardStatus(register, summaries, today) {
|
|
646
|
+
const hazards = register.hazards.map((h) => {
|
|
647
|
+
let sessions = 0;
|
|
648
|
+
let findings = 0;
|
|
649
|
+
let last = null;
|
|
650
|
+
for (const s of summaries) {
|
|
651
|
+
const n = h.codes.reduce((sum, c) => sum + (s.finding_codes[c] ?? 0), 0);
|
|
652
|
+
if (n === 0)
|
|
653
|
+
continue;
|
|
654
|
+
sessions++;
|
|
655
|
+
findings += n;
|
|
656
|
+
if (last === null || Date.parse(s.started_at) > Date.parse(last))
|
|
657
|
+
last = s.started_at;
|
|
658
|
+
}
|
|
659
|
+
const risk = h.likelihood * h.severity;
|
|
660
|
+
return {
|
|
661
|
+
...h,
|
|
662
|
+
risk,
|
|
663
|
+
band: risk <= 4 ? "low" : risk <= 12 ? "medium" : "high",
|
|
664
|
+
review_overdue: today > h.review_by,
|
|
665
|
+
sessions,
|
|
666
|
+
findings,
|
|
667
|
+
last_seen: last,
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
return {
|
|
671
|
+
v: 1,
|
|
672
|
+
type: "blackbox.hazard-status",
|
|
673
|
+
register_hash: docHash(register),
|
|
674
|
+
hazards: hazards.sort((a, b) => b.risk - a.risk || cmp(a.id, b.id)),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// ---------------------------------------------------------------- §11 type certificates
|
|
678
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
679
|
+
/** spec/aviation.md §11: a configuration's id, the same as a version's (spec/fleet.md §9). */
|
|
680
|
+
export function configurationId(prompt, tools, model) {
|
|
681
|
+
return `ver_${createHash("sha256")
|
|
682
|
+
.update(canonical([prompt, tools, model]))
|
|
683
|
+
.digest("hex")
|
|
684
|
+
.slice(0, 12)}`;
|
|
685
|
+
}
|
|
686
|
+
/** spec/aviation.md §11: a type certificate (unsigned), or an error message. */
|
|
687
|
+
export function typeCertificate(input, tenant, issuedAt) {
|
|
688
|
+
const { label, model, prompt, tools } = input;
|
|
689
|
+
if (!text(label, 200))
|
|
690
|
+
return { ok: false, error: "label must be 1-200 characters" };
|
|
691
|
+
if (!text(model, 200))
|
|
692
|
+
return { ok: false, error: "model must be 1-200 characters" };
|
|
693
|
+
for (const [k, v] of [
|
|
694
|
+
["prompt", prompt],
|
|
695
|
+
["tools", tools],
|
|
696
|
+
])
|
|
697
|
+
if (v !== null && !(typeof v === "string" && SHA256.test(v)))
|
|
698
|
+
return { ok: false, error: `${k} must be sha256: and 64 hex, or null` };
|
|
699
|
+
if (prompt === null && tools === null)
|
|
700
|
+
return { ok: false, error: "prompt and tools can't both be null" };
|
|
701
|
+
const configuration = configurationId(prompt, tools, model);
|
|
702
|
+
const id = `tc_${createHash("sha256")
|
|
703
|
+
.update(`${tenant ?? ""}\n${label}\n${configuration}`)
|
|
704
|
+
.digest("hex")
|
|
705
|
+
.slice(0, 16)}`;
|
|
706
|
+
return {
|
|
707
|
+
ok: true,
|
|
708
|
+
certificate: {
|
|
709
|
+
v: 1,
|
|
710
|
+
type: "blackbox.type-certificate",
|
|
711
|
+
id,
|
|
712
|
+
configuration,
|
|
713
|
+
label,
|
|
714
|
+
model,
|
|
715
|
+
prompt: prompt,
|
|
716
|
+
tools: tools,
|
|
717
|
+
tenant,
|
|
718
|
+
issued_at: issuedAt,
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/** spec/aviation.md §11: each configuration a labelled session ran that no certificate covers. */
|
|
723
|
+
export function unapprovedModifications(lines, certificates) {
|
|
724
|
+
const events = parse(lines);
|
|
725
|
+
const label = events[0]?.kind === "session.open" ? events[0].meta.label : undefined;
|
|
726
|
+
if (typeof label !== "string")
|
|
727
|
+
return [];
|
|
728
|
+
const covered = new Set(certificates.filter((c) => c.label === label).map((c) => c.configuration));
|
|
729
|
+
const models = new Map();
|
|
730
|
+
for (const e of events)
|
|
731
|
+
if ((e.kind === "llm.response" || e.kind === "llm.incomplete") && isInt(e.meta.request_seq))
|
|
732
|
+
if (!models.has(e.meta.request_seq))
|
|
733
|
+
models.set(e.meta.request_seq, typeof e.meta.model === "string" ? e.meta.model : null);
|
|
734
|
+
const out = [];
|
|
735
|
+
const seen = new Set();
|
|
736
|
+
for (const e of events) {
|
|
737
|
+
if (e.kind !== "llm.request" || !models.has(e.seq))
|
|
738
|
+
continue;
|
|
739
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
740
|
+
const prompt = str(e.meta.system_sha256);
|
|
741
|
+
const tools = str(e.meta.tools_sha256);
|
|
742
|
+
if (prompt === null && tools === null)
|
|
743
|
+
continue;
|
|
744
|
+
const id = configurationId(prompt, tools, models.get(e.seq) ?? null);
|
|
745
|
+
if (seen.has(id))
|
|
746
|
+
continue;
|
|
747
|
+
seen.add(id);
|
|
748
|
+
if (!covered.has(id))
|
|
749
|
+
out.push({
|
|
750
|
+
code: "UNAPPROVED_MODIFICATION",
|
|
751
|
+
source: "type_certificate",
|
|
752
|
+
severity: "warning",
|
|
753
|
+
ref: { seq: e.seq, configuration: id },
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
/** spec/aviation.md §14: the operator's certificate (unsigned) over the facts. */
|
|
759
|
+
export function operatorCertificate(facts, options) {
|
|
760
|
+
const reasons = [];
|
|
761
|
+
if (!facts.audit_chain_ok)
|
|
762
|
+
reasons.push("audit_chain_broken");
|
|
763
|
+
if (!facts.anchoring.enabled)
|
|
764
|
+
reasons.push("anchoring_off");
|
|
765
|
+
if (facts.anchoring.dead > 0)
|
|
766
|
+
reasons.push("anchoring_dead");
|
|
767
|
+
if (facts.incidents_open > 0)
|
|
768
|
+
reasons.push("incidents_open");
|
|
769
|
+
if ((facts.checks_overdue ?? 0) > 0)
|
|
770
|
+
reasons.push("checks_overdue");
|
|
771
|
+
const days = options.days ?? 30;
|
|
772
|
+
return {
|
|
773
|
+
v: 1,
|
|
774
|
+
type: "blackbox.operator-certificate",
|
|
775
|
+
operator: options.operator,
|
|
776
|
+
issued_at: options.issuedAt,
|
|
777
|
+
expires_at: new Date(Date.parse(options.issuedAt) + days * DAY_MS).toISOString(),
|
|
778
|
+
controls: facts,
|
|
779
|
+
verdict: reasons.length ? "not_certified" : "certified",
|
|
780
|
+
reasons,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/** spec/aviation.md §14: an operator certificate checked offline, like an accreditation. */
|
|
784
|
+
export function checkOperatorCertificate(cert, options) {
|
|
785
|
+
if (!isObj(cert) ||
|
|
786
|
+
cert.v !== 1 ||
|
|
787
|
+
cert.type !== "blackbox.operator-certificate" ||
|
|
788
|
+
isoMs(cert.issued_at) === null ||
|
|
789
|
+
isoMs(cert.expires_at) === null)
|
|
790
|
+
return { ok: false, reason: "malformed" };
|
|
791
|
+
const signed = verifyCertificate(cert);
|
|
792
|
+
if (!signed.ok)
|
|
793
|
+
return { ok: false, reason: "unsigned" };
|
|
794
|
+
if (options.issuers?.length && !options.issuers.includes(signed.by))
|
|
795
|
+
return { ok: false, reason: "wrong_issuer" };
|
|
796
|
+
const now = Date.parse(options.now);
|
|
797
|
+
if (now < Date.parse(cert.issued_at))
|
|
798
|
+
return { ok: false, reason: "not_yet" };
|
|
799
|
+
if (now >= Date.parse(cert.expires_at))
|
|
800
|
+
return { ok: false, reason: "expired" };
|
|
801
|
+
if (cert.verdict !== "certified")
|
|
802
|
+
return { ok: false, reason: "not_certified" };
|
|
803
|
+
return { ok: true };
|
|
804
|
+
}
|
|
805
|
+
/** spec/aviation.md §12: an error message, or null for a valid checks file. */
|
|
806
|
+
export function checkChecks(doc) {
|
|
807
|
+
if (!isObj(doc) || doc.v !== 1 || !Array.isArray(doc.checks) || doc.checks.length > 200)
|
|
808
|
+
return "the checks file must be {v: 1, checks: [...]} with at most 200 checks";
|
|
809
|
+
const seen = new Set();
|
|
810
|
+
for (const [i, c] of doc.checks.entries()) {
|
|
811
|
+
const at = `checks[${i}]`;
|
|
812
|
+
if (!isObj(c))
|
|
813
|
+
return `${at} must be an object`;
|
|
814
|
+
if (typeof c.id !== "string" || !HAZARD_ID.test(c.id) || seen.has(c.id))
|
|
815
|
+
return `${at}.id must be a unique id of 1-64 of a-z 0-9 . _ -`;
|
|
816
|
+
seen.add(c.id);
|
|
817
|
+
if (!text(c.label, 200))
|
|
818
|
+
return `${at}.label must be 1-200 characters`;
|
|
819
|
+
if (c.kind !== "drill" && c.kind !== "scenario")
|
|
820
|
+
return `${at}.kind must be drill or scenario`;
|
|
821
|
+
if (!isInt(c.every_days) || c.every_days < 1 || c.every_days > 365)
|
|
822
|
+
return `${at}.every_days must be an integer 1-365`;
|
|
823
|
+
if (c.grace_days !== undefined &&
|
|
824
|
+
!(isInt(c.grace_days) && c.grace_days >= 0 && c.grace_days <= 90))
|
|
825
|
+
return `${at}.grace_days must be an integer 0-90`;
|
|
826
|
+
const extra = Object.keys(c).filter((k) => !["id", "label", "kind", "every_days", "grace_days"].includes(k));
|
|
827
|
+
if (extra.length)
|
|
828
|
+
return `${at} has an unknown field: ${extra.sort().join(", ")}`;
|
|
829
|
+
}
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
/** spec/aviation.md §12: each check's last run, due date and status at `now`. */
|
|
833
|
+
export function checkStatus(checks, done, now) {
|
|
834
|
+
const t = Date.parse(now);
|
|
835
|
+
return checks.map((c) => {
|
|
836
|
+
const grace = c.grace_days ?? 0;
|
|
837
|
+
let last = null;
|
|
838
|
+
for (const d of done)
|
|
839
|
+
if (d.label === c.label &&
|
|
840
|
+
d.kind === c.kind &&
|
|
841
|
+
(last === null || Date.parse(d.at) > Date.parse(last)))
|
|
842
|
+
last = d.at;
|
|
843
|
+
if (last === null)
|
|
844
|
+
return { ...c, grace_days: grace, last_done: null, due_at: null, status: "overdue" };
|
|
845
|
+
const due = Date.parse(last) + c.every_days * DAY_MS;
|
|
846
|
+
return {
|
|
847
|
+
...c,
|
|
848
|
+
grace_days: grace,
|
|
849
|
+
last_done: last,
|
|
850
|
+
due_at: new Date(due).toISOString(),
|
|
851
|
+
status: t < due ? "current" : t < due + grace * DAY_MS ? "due" : "overdue",
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
/** spec/aviation.md §12: an overdue check of the session's label, flagged at pre-flight. */
|
|
856
|
+
export function checkFindings(lines, statuses) {
|
|
857
|
+
const first = lines[0] ? JSON.parse(lines[0]) : undefined;
|
|
858
|
+
const label = first?.kind === "session.open" ? first.meta.label : undefined;
|
|
859
|
+
if (typeof label !== "string")
|
|
860
|
+
return [];
|
|
861
|
+
return statuses
|
|
862
|
+
.filter((c) => c.label === label && c.status === "overdue")
|
|
863
|
+
.map((c) => ({
|
|
864
|
+
code: "CHECK_OVERDUE",
|
|
865
|
+
source: "checks",
|
|
866
|
+
severity: "advisory",
|
|
867
|
+
ref: { seq: 0, check: c.id },
|
|
868
|
+
}));
|
|
869
|
+
}
|
|
870
|
+
/** spec/aviation.md §13: whether each airworthy rating is still current. */
|
|
871
|
+
export function ratingCurrency(ratings, items, options) {
|
|
872
|
+
const t = Date.parse(options.now);
|
|
873
|
+
return ratings
|
|
874
|
+
.filter((r) => r.verdict === "airworthy")
|
|
875
|
+
.map((r) => {
|
|
876
|
+
let last = null;
|
|
877
|
+
for (const i of items)
|
|
878
|
+
if (i.summary.closed &&
|
|
879
|
+
(i.summary.label ?? "(no label)") === r.label &&
|
|
880
|
+
i.models.includes(r.model) &&
|
|
881
|
+
(last === null || Date.parse(i.summary.started_at) > Date.parse(last)))
|
|
882
|
+
last = i.summary.started_at;
|
|
883
|
+
return {
|
|
884
|
+
label: r.label,
|
|
885
|
+
model: r.model,
|
|
886
|
+
last_flown: last,
|
|
887
|
+
current: last !== null && t - Date.parse(last) <= options.days * DAY_MS,
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/** spec/aviation.md §13: each model the session uses whose rating for its label has lapsed. */
|
|
892
|
+
export function ratingLapsed(lines, prices, currency, severity = "advisory") {
|
|
893
|
+
const first = lines[0] ? JSON.parse(lines[0]) : undefined;
|
|
894
|
+
if (first?.kind !== "session.open")
|
|
895
|
+
return [];
|
|
896
|
+
const label = typeof first.meta.label === "string" ? first.meta.label : "(no label)";
|
|
897
|
+
const lapsed = new Set(currency.filter((c) => c.label === label && !c.current).map((c) => c.model));
|
|
898
|
+
const out = [];
|
|
899
|
+
const seen = new Set();
|
|
900
|
+
for (const c of sessionCost(lines, prices).calls) {
|
|
901
|
+
if (!c.model || seen.has(c.model))
|
|
902
|
+
continue;
|
|
903
|
+
seen.add(c.model);
|
|
904
|
+
if (lapsed.has(c.model))
|
|
905
|
+
out.push({
|
|
906
|
+
code: "RATING_LAPSED",
|
|
907
|
+
source: "type_rating",
|
|
908
|
+
severity,
|
|
909
|
+
ref: { seq: c.seq, model: c.model },
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
const NOT_PEOPLE = new Set(["timeout", "unattended", "classifier"]);
|
|
915
|
+
/** Who decided: pseudonymous where it can be. */
|
|
916
|
+
function approverOf(meta) {
|
|
917
|
+
const p = isObj(meta.person) ? meta.person : null;
|
|
918
|
+
if (p && typeof p.iss === "string" && typeof p.sub === "string")
|
|
919
|
+
return `${p.iss}#${p.sub}`;
|
|
920
|
+
if (p && typeof p.name === "string" && p.name.trim())
|
|
921
|
+
return `name:${p.name.trim().toLowerCase()}`;
|
|
922
|
+
return typeof meta.by === "string" ? `role:${meta.by}` : null;
|
|
923
|
+
}
|
|
924
|
+
/** A session's people decisions: {seq, approver, decision, ms (or null)}. */
|
|
925
|
+
function decisionsOf(lines) {
|
|
926
|
+
const asked = new Map();
|
|
927
|
+
const out = [];
|
|
928
|
+
for (const e of parse(lines)) {
|
|
929
|
+
const m = e.meta;
|
|
930
|
+
const policy = isObj(m.policy) ? m.policy : null;
|
|
931
|
+
if (e.kind === "tool.call" && policy && typeof policy.approval_id === "string")
|
|
932
|
+
asked.set(policy.approval_id, Date.parse(e.ts));
|
|
933
|
+
if (e.kind === "control" &&
|
|
934
|
+
(m.action === "approval_request" || m.action === "permission_request") &&
|
|
935
|
+
typeof m.approval_id === "string")
|
|
936
|
+
asked.set(m.approval_id, Date.parse(e.ts));
|
|
937
|
+
if (e.kind !== "control" || m.action !== "approval")
|
|
938
|
+
continue;
|
|
939
|
+
if (m.decision !== "approve" && m.decision !== "reject")
|
|
940
|
+
continue;
|
|
941
|
+
if (typeof m.by === "string" && NOT_PEOPLE.has(m.by))
|
|
942
|
+
continue;
|
|
943
|
+
const who = approverOf(m);
|
|
944
|
+
if (who === null)
|
|
945
|
+
continue;
|
|
946
|
+
const from = typeof m.approval_id === "string" ? asked.get(m.approval_id) : undefined;
|
|
947
|
+
out.push({
|
|
948
|
+
seq: e.seq,
|
|
949
|
+
approver: who,
|
|
950
|
+
decision: m.decision,
|
|
951
|
+
ms: from === undefined ? null : Math.max(0, Date.parse(e.ts) - from),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
/** spec/aviation.md §18: each approver's decisions, speed and whether they rubber-stamp. */
|
|
957
|
+
export function approverStats(records, options = {}) {
|
|
958
|
+
const fastMs = options.fastMs ?? 2000;
|
|
959
|
+
const min = options.minDecisions ?? 10;
|
|
960
|
+
const stampPpm = options.rubberStampPpm ?? 800_000;
|
|
961
|
+
const by = new Map();
|
|
962
|
+
for (const lines of records)
|
|
963
|
+
for (const d of decisionsOf(lines)) {
|
|
964
|
+
const g = by.get(d.approver) ?? { approved: 0, rejected: 0, ms: [], fast: 0 };
|
|
965
|
+
if (d.decision === "approve")
|
|
966
|
+
g.approved++;
|
|
967
|
+
else
|
|
968
|
+
g.rejected++;
|
|
969
|
+
if (d.ms !== null) {
|
|
970
|
+
g.ms.push(d.ms);
|
|
971
|
+
if (d.ms < fastMs)
|
|
972
|
+
g.fast++;
|
|
973
|
+
}
|
|
974
|
+
by.set(d.approver, g);
|
|
975
|
+
}
|
|
976
|
+
const approvers = [...by.entries()]
|
|
977
|
+
.sort(([a], [b]) => cmp(a, b))
|
|
978
|
+
.map(([approver, g]) => {
|
|
979
|
+
const decisions = g.approved + g.rejected;
|
|
980
|
+
const ms = [...g.ms].sort((a, b) => a - b);
|
|
981
|
+
const approvePpm = Math.floor((g.approved * 1_000_000) / decisions);
|
|
982
|
+
const fastPpm = Math.floor((g.fast * 1_000_000) / decisions);
|
|
983
|
+
return {
|
|
984
|
+
approver,
|
|
985
|
+
decisions,
|
|
986
|
+
approved: g.approved,
|
|
987
|
+
rejected: g.rejected,
|
|
988
|
+
approve_ppm: approvePpm,
|
|
989
|
+
median_ms: ms.length ? ms[Math.floor((ms.length - 1) / 2)] : null,
|
|
990
|
+
fast: g.fast,
|
|
991
|
+
fast_ppm: fastPpm,
|
|
992
|
+
rubber_stamp: decisions >= min && approvePpm >= 950_000 && fastPpm >= stampPpm,
|
|
993
|
+
};
|
|
994
|
+
});
|
|
995
|
+
return { approvers };
|
|
996
|
+
}
|
|
997
|
+
/** spec/aviation.md §18: once per rubber-stamping approver who decided in this session. */
|
|
998
|
+
export function approverFatigueFindings(lines, stats) {
|
|
999
|
+
const tired = new Set(stats.approvers.filter((a) => a.rubber_stamp).map((a) => a.approver));
|
|
1000
|
+
const out = [];
|
|
1001
|
+
const seen = new Set();
|
|
1002
|
+
for (const d of decisionsOf(lines)) {
|
|
1003
|
+
if (!tired.has(d.approver) || seen.has(d.approver))
|
|
1004
|
+
continue;
|
|
1005
|
+
seen.add(d.approver);
|
|
1006
|
+
out.push({
|
|
1007
|
+
code: "APPROVER_FATIGUE",
|
|
1008
|
+
source: "approvals",
|
|
1009
|
+
severity: "caution",
|
|
1010
|
+
ref: { seq: d.seq, approver: d.approver },
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|