@zanii/blackbox 0.3.0 → 0.5.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 +31 -1
- package/dist/a2a/index.d.ts +27 -0
- package/dist/a2a/index.js +104 -1
- package/dist/agents/index.d.ts +8 -0
- package/dist/analysis/accuracy.d.ts +24 -0
- package/dist/analysis/accuracy.js +45 -0
- package/dist/analysis/credential.d.ts +101 -0
- package/dist/analysis/credential.js +142 -0
- package/dist/analysis/faults.js +115 -0
- package/dist/analysis/grounding.d.ts +122 -0
- package/dist/analysis/grounding.js +445 -0
- package/dist/analysis/hallucination.d.ts +32 -0
- package/dist/analysis/hallucination.js +357 -0
- package/dist/analysis/index.d.ts +23 -0
- package/dist/analysis/index.js +93 -0
- package/dist/analysis/memory.d.ts +8 -0
- package/dist/analysis/memory.js +35 -8
- package/dist/analysis/reference.d.ts +49 -0
- package/dist/analysis/reference.js +164 -0
- package/dist/analysis/taxonomy.js +1 -0
- package/dist/approvals/index.d.ts +23 -0
- package/dist/approvals/index.js +48 -0
- package/dist/archive/parquet.d.ts +2 -0
- package/dist/archive/parquet.js +185 -0
- package/dist/badge/index.d.ts +16 -0
- package/dist/badge/index.js +48 -0
- package/dist/bom/index.js +20 -0
- package/dist/cli.js +114 -10
- package/dist/compliance/art12.js +36 -9
- package/dist/compliance/index.d.ts +36 -2
- package/dist/compliance/index.js +78 -11
- package/dist/compliance/zanii.d.ts +29 -0
- package/dist/compliance/zanii.js +84 -0
- package/dist/constitution/index.d.ts +57 -0
- package/dist/constitution/index.js +131 -0
- package/dist/cv/index.d.ts +39 -0
- package/dist/cv/index.js +108 -0
- package/dist/disclosure/index.d.ts +31 -0
- package/dist/disclosure/index.js +113 -0
- package/dist/encryption/index.d.ts +9 -0
- package/dist/encryption/index.js +31 -0
- package/dist/evidence/index.d.ts +60 -0
- package/dist/evidence/index.js +151 -0
- package/dist/federation/index.d.ts +35 -0
- package/dist/federation/index.js +102 -0
- package/dist/finance/index.d.ts +126 -0
- package/dist/finance/index.js +320 -0
- package/dist/fleet/index.js +9 -0
- package/dist/gov/index.d.ts +108 -0
- package/dist/gov/index.js +225 -0
- package/dist/health/index.d.ts +120 -0
- package/dist/health/index.js +233 -0
- package/dist/index.d.ts +29 -7
- package/dist/index.js +28 -6
- package/dist/memory/index.d.ts +36 -0
- package/dist/memory/index.js +85 -0
- package/dist/occurrence/index.d.ts +11 -0
- package/dist/occurrence/index.js +18 -0
- package/dist/otlp/index.js +28 -1
- package/dist/packs/index.js +44 -4
- package/dist/policy/delta.js +7 -1
- package/dist/policy/index.d.ts +23 -6
- package/dist/policy/index.js +151 -8
- package/dist/policy/zanii.d.ts +31 -0
- package/dist/policy/zanii.js +87 -0
- package/dist/pq/index.d.ts +23 -0
- package/dist/pq/index.js +104 -0
- package/dist/search/index.d.ts +23 -0
- package/dist/search/index.js +69 -0
- package/dist/session/index.d.ts +106 -1
- package/dist/session/index.js +163 -11
- package/dist/sla/index.d.ts +61 -0
- package/dist/sla/index.js +197 -0
- package/dist/succession/index.d.ts +50 -0
- package/dist/succession/index.js +123 -0
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.js +46 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walls/index.d.ts +31 -0
- package/dist/walls/index.js +119 -0
- package/package.json +1 -1
package/dist/session/index.js
CHANGED
|
@@ -6,7 +6,19 @@ import { request as httpRequest } from "node:http";
|
|
|
6
6
|
import { request as httpsRequest } from "node:https";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { argsHash } from "../approvals/index.js";
|
|
9
10
|
import { attest, isReadOnly } from "../attest/index.js";
|
|
11
|
+
import { buildPayment, ftaPayload, screeningPayload, } from "../finance/index.js";
|
|
12
|
+
import { decisionPayload } from "../gov/index.js";
|
|
13
|
+
import { accessPayload, checkEpisodeTag, recommendationPayload, verifyHealthSignature, } from "../health/index.js";
|
|
14
|
+
import { memoryEntry } from "../memory/index.js";
|
|
15
|
+
/** spec/preflight.md §1 (L3): with an `egress` item, the SDK checks it here, on the agent's host. */
|
|
16
|
+
async function withEgress(p) {
|
|
17
|
+
if (![...(p.require ?? []), ...(p.optional ?? [])].includes("egress"))
|
|
18
|
+
return p;
|
|
19
|
+
const { url, open } = await checkEgress({ timeoutMs: 3000 });
|
|
20
|
+
return { ...p, egress: { url, open } };
|
|
21
|
+
}
|
|
10
22
|
/** spec/data.md §6: a direct HTTPS request to `url` (default https://example.com). Never throws. */
|
|
11
23
|
export async function checkEgress(options = {}) {
|
|
12
24
|
const url = options.url ?? "https://example.com";
|
|
@@ -71,7 +83,9 @@ export async function session(options) {
|
|
|
71
83
|
...(o.replay ? { replay: o.replay } : {}),
|
|
72
84
|
...(o.tenant ? { tenant: o.tenant } : {}),
|
|
73
85
|
...(o.authority ? { authority: o.authority } : {}),
|
|
74
|
-
...(o.
|
|
86
|
+
...(o.environment ? { environment: o.environment } : {}),
|
|
87
|
+
...(o.holdStreams !== undefined ? { hold_streams: o.holdStreams } : {}),
|
|
88
|
+
...(o.preflight ? { preflight: await withEgress(o.preflight) } : {}),
|
|
75
89
|
...(o.drill ? { drill: o.drill } : {}),
|
|
76
90
|
sdk: true, // this SDK will report the session's model calls (L2.3.2)
|
|
77
91
|
});
|
|
@@ -103,6 +117,10 @@ export class BlackboxSession {
|
|
|
103
117
|
timers = [];
|
|
104
118
|
closed = false;
|
|
105
119
|
landings = 0;
|
|
120
|
+
/** spec/approvals.md §2: approved tools with arguments, until their call is reported. */
|
|
121
|
+
approved = new Map();
|
|
122
|
+
/** spec/agents.md §4: the memory chain's tip. */
|
|
123
|
+
memoryLast = null;
|
|
106
124
|
/** A session that records nothing. `reason`, when opening failed, goes to `stats.lastError` (audit K1). */
|
|
107
125
|
static disabled(options, reason) {
|
|
108
126
|
const s = new BlackboxSession(options, "", "", false);
|
|
@@ -208,8 +226,82 @@ export class BlackboxSession {
|
|
|
208
226
|
step(name, data) {
|
|
209
227
|
this.event("step", name, data);
|
|
210
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* spec/gov.md §2: a decision about a person, under the rulebook `manifestHash`, the person named
|
|
231
|
+
* only by their subject tag. Returns the factors' nonce (keep it to disclose them in a dispute).
|
|
232
|
+
* Throws on a decision that can't be lawful evidence: no rulebook, a bad kind, no tag.
|
|
233
|
+
*/
|
|
234
|
+
decision(kind, o) {
|
|
235
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(o.subjectTag))
|
|
236
|
+
throw new Error("subjectTag must be a subject tag (subjectTag(did, authority))");
|
|
237
|
+
const { payload, nonce } = decisionPayload({
|
|
238
|
+
kind,
|
|
239
|
+
manifestHash: o.manifestHash,
|
|
240
|
+
outcome: o.outcome,
|
|
241
|
+
ts: new Date().toISOString(),
|
|
242
|
+
...(o.factors !== undefined ? { factors: o.factors } : {}),
|
|
243
|
+
...(o.appealBy !== undefined ? { appealBy: o.appealBy } : {}),
|
|
244
|
+
});
|
|
245
|
+
this.event("decision", kind, { decision: payload, subject_tag: o.subjectTag });
|
|
246
|
+
return { nonce };
|
|
247
|
+
}
|
|
248
|
+
/** spec/health.md §2: who accessed a patient's record (an episode tag, never the seed), and why. */
|
|
249
|
+
healthAccess(o) {
|
|
250
|
+
const tag = checkEpisodeTag(o.episodeTag);
|
|
251
|
+
const { payload, nonce } = accessPayload({ ...o, ts: new Date().toISOString() });
|
|
252
|
+
this.event("health.access", "access", { payload, episode_tag: tag });
|
|
253
|
+
return { nonce };
|
|
254
|
+
}
|
|
255
|
+
/** spec/health.md §2: a model's recommendation under a protocol. `hash` is what the clinician signs. */
|
|
256
|
+
recommendation(o) {
|
|
257
|
+
const tag = checkEpisodeTag(o.episodeTag);
|
|
258
|
+
const { payload, nonce, hash } = recommendationPayload({ ...o, ts: new Date().toISOString() });
|
|
259
|
+
this.event("health.recommendation", "recommendation", { payload, episode_tag: tag });
|
|
260
|
+
return { nonce, hash };
|
|
261
|
+
}
|
|
262
|
+
/** spec/health.md §2: a clinician's signed confirmation (clinicianConfirmation). Throws on a bad signature. */
|
|
263
|
+
clinicianConfirmation(episodeTag, payload) {
|
|
264
|
+
this.signedHealth(episodeTag, payload, "confirmation");
|
|
265
|
+
}
|
|
266
|
+
/** spec/health.md §2: an emergency access, signed by the clinician (breakGlass). Loud on purpose. */
|
|
267
|
+
breakGlass(episodeTag, payload) {
|
|
268
|
+
this.signedHealth(episodeTag, payload, "break_glass");
|
|
269
|
+
}
|
|
270
|
+
signedHealth(episodeTag, payload, kind) {
|
|
271
|
+
const tag = checkEpisodeTag(episodeTag);
|
|
272
|
+
if (payload.kind !== kind || !verifyHealthSignature(payload, tag))
|
|
273
|
+
throw new Error(`not a ${kind} signed by the clinician it names, for this episode`);
|
|
274
|
+
this.event(`health.${kind}`, kind, { payload, episode_tag: tag });
|
|
275
|
+
}
|
|
276
|
+
/** spec/finance.md §2: a payment, exact (minor units), with its settlement reference. Throws on a bad amount. */
|
|
277
|
+
payment(input) {
|
|
278
|
+
this.event("payment", input.rail, buildPayment(input));
|
|
279
|
+
}
|
|
280
|
+
/** spec/finance.md §3: a counterparty screening (screenCounterparty), before paying them. */
|
|
281
|
+
screening(result) {
|
|
282
|
+
this.event("kya.screening", result.did, screeningPayload(result, new Date().toISOString(), this.id));
|
|
283
|
+
}
|
|
284
|
+
/** spec/finance.md §4: a tax filing the agent prepared. It never files; a Tax Agent does. */
|
|
285
|
+
ftaPrepared(o) {
|
|
286
|
+
this.event("fta.prepared", o.filingType, ftaPayload({ ...o, action: "prepared", by: o.by ?? this.id }));
|
|
287
|
+
}
|
|
288
|
+
/** spec/finance.md §4: the prepared filing handed to a licensed Tax Agent. */
|
|
289
|
+
ftaHandoff(o) {
|
|
290
|
+
this.event("fta.handoff", o.filingType, ftaPayload({ ...o, action: "handoff", by: o.by ?? this.id }));
|
|
291
|
+
}
|
|
211
292
|
toolCall(name, args) {
|
|
212
|
-
|
|
293
|
+
// spec/approvals.md §2: the first call after an approval names it, with what it ran
|
|
294
|
+
const approvalId = this.approved.get(name);
|
|
295
|
+
if (approvalId !== undefined)
|
|
296
|
+
this.approved.delete(name);
|
|
297
|
+
this.event("tool.call", name, args === undefined && approvalId === undefined
|
|
298
|
+
? undefined
|
|
299
|
+
: {
|
|
300
|
+
...(args !== undefined ? { args } : {}),
|
|
301
|
+
...(approvalId !== undefined
|
|
302
|
+
? { approval_id: approvalId, args_sha256: argsHash(args ?? null) }
|
|
303
|
+
: {}),
|
|
304
|
+
});
|
|
213
305
|
}
|
|
214
306
|
toolResult(name, result) {
|
|
215
307
|
this.event("tool.result", name, result);
|
|
@@ -277,6 +369,30 @@ export class BlackboxSession {
|
|
|
277
369
|
contextChange(change) {
|
|
278
370
|
this.event("context.change", change.kind, { ...change });
|
|
279
371
|
}
|
|
372
|
+
/** spec/findings.md §11 (H3): pin the documents the agent read, by hash; the content never leaves.
|
|
373
|
+
* Each doc is `{id, uri?, version?}` with its `content` (hashed here) or its `sha256`. */
|
|
374
|
+
retrieved(docs) {
|
|
375
|
+
const out = [];
|
|
376
|
+
for (const d of docs.slice(0, 100)) {
|
|
377
|
+
if (typeof d?.id !== "string" || d.id.length < 1)
|
|
378
|
+
continue;
|
|
379
|
+
const bytes = typeof d.content === "string" ? Buffer.from(d.content) : d.content;
|
|
380
|
+
const sha256 = bytes
|
|
381
|
+
? `sha256:${createHash("sha256").update(bytes).digest("hex")}`
|
|
382
|
+
: d.sha256;
|
|
383
|
+
if (typeof sha256 !== "string" || !/^sha256:[0-9a-f]{64}$/.test(sha256))
|
|
384
|
+
continue;
|
|
385
|
+
out.push({
|
|
386
|
+
id: d.id.slice(0, 256),
|
|
387
|
+
...(typeof d.uri === "string" ? { uri: d.uri.slice(0, 2048) } : {}),
|
|
388
|
+
...(typeof d.version === "string" ? { version: d.version.slice(0, 128) } : {}),
|
|
389
|
+
sha256,
|
|
390
|
+
...(bytes ? { bytes: bytes.length } : {}),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (out.length)
|
|
394
|
+
this.event("retrieved", undefined, { docs: out });
|
|
395
|
+
}
|
|
280
396
|
/** Stage 2 R3: the result of one of the customer's own checks (tests, a schema, a policy, a
|
|
281
397
|
* partial goal), mid-run or at the end. A pass then a fail is VERIFY_REGRESSION; a success whose last
|
|
282
398
|
* check failed is FALSE_SUCCESS (spec/findings.md §6). */
|
|
@@ -338,8 +454,22 @@ export class BlackboxSession {
|
|
|
338
454
|
this.event("screen", undefined, data, { attachment: { contentType: type, bytes: image } });
|
|
339
455
|
}
|
|
340
456
|
/** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
|
|
341
|
-
|
|
342
|
-
|
|
457
|
+
/** spec/agents.md §4: with `content`, the write also carries Zanii's memory entry (a salted
|
|
458
|
+
* commitment, chained after `prev`, else this session's last entry). Keep the salt it returns. */
|
|
459
|
+
memoryWrite(memoryId, summary, opts) {
|
|
460
|
+
const data = { memory_id: memoryId, ...(summary ? { summary } : {}) };
|
|
461
|
+
let made;
|
|
462
|
+
if (opts) {
|
|
463
|
+
made = memoryEntry({
|
|
464
|
+
...opts,
|
|
465
|
+
ts: new Date().toISOString(),
|
|
466
|
+
prev: opts.prev ?? this.memoryLast,
|
|
467
|
+
});
|
|
468
|
+
this.memoryLast = made.payload;
|
|
469
|
+
data.entry = made.payload;
|
|
470
|
+
}
|
|
471
|
+
this.event("memory.write", undefined, data);
|
|
472
|
+
return made && { entry: made.payload, salt: made.salt };
|
|
343
473
|
}
|
|
344
474
|
memoryRevoke(memoryId, reason) {
|
|
345
475
|
this.event("memory.revoke", undefined, { memory_id: memoryId, ...(reason ? { reason } : {}) });
|
|
@@ -392,21 +522,43 @@ export class BlackboxSession {
|
|
|
392
522
|
}
|
|
393
523
|
return null;
|
|
394
524
|
}
|
|
525
|
+
/** spec/findings.md §13.1: what in an answer (the latest, or the one at `seq`) would hold it,
|
|
526
|
+
* for an app that streams the answer by itself: `{seq, risks, hold}`. `hold` says whether the
|
|
527
|
+
* server would hold it. `null` when there's no answer yet or the gateway can't be asked. */
|
|
528
|
+
async checkAnswer(seq) {
|
|
529
|
+
if (!this.enabled)
|
|
530
|
+
return null;
|
|
531
|
+
const at = seq === undefined ? "latest" : String(seq);
|
|
532
|
+
try {
|
|
533
|
+
const r = await send("GET", this.options.url, `/v1/sessions/${this.id}/answers/${at}/risk`, this.token);
|
|
534
|
+
if (r.status === 200)
|
|
535
|
+
return r.json;
|
|
536
|
+
if (r.status !== 404)
|
|
537
|
+
this.fail("rejected", `checking the answer: the gateway answered ${r.status}`);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
this.fail("network", `checking the answer: ${message(error)}`);
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
395
544
|
/** spec/approvals.md §2: asks a second person before running one of the agent's own risky tools,
|
|
396
545
|
* and waits (polling) for the answer. Never throws. Only `"approved"` means go ahead; anything
|
|
397
546
|
* else (`rejected`, `timeout`, or `error` when the gateway can't be asked) means don't. */
|
|
398
547
|
async requestApproval(tool, options = {}) {
|
|
399
|
-
|
|
548
|
+
const { status, id } = await this.ask("approvals", { tool, ...(options.args ? { args: options.args } : {}) }, options);
|
|
549
|
+
if (status === "approved" && options.args && id)
|
|
550
|
+
this.approved.set(tool, id);
|
|
551
|
+
return status;
|
|
400
552
|
}
|
|
401
553
|
/** N2 (idea C2): asks a person to let this session use a gateway tool its policy denies (the
|
|
402
554
|
* denial's `requires.tool`, e.g. `mcp__files__delete`). A yes is a standing grant for the
|
|
403
555
|
* session: retry the call. Answers like `requestApproval`. Never throws. */
|
|
404
556
|
async requestPermission(tool, options = {}) {
|
|
405
|
-
return this.ask("permissions", { tool }, options);
|
|
557
|
+
return (await this.ask("permissions", { tool }, options)).status;
|
|
406
558
|
}
|
|
407
559
|
async ask(route, body, options) {
|
|
408
560
|
if (!this.enabled)
|
|
409
|
-
return "error";
|
|
561
|
+
return { status: "error" };
|
|
410
562
|
try {
|
|
411
563
|
const r = await post(this.options.url, `/v1/sessions/${this.id}/${route}`, this.token, {
|
|
412
564
|
...body,
|
|
@@ -415,21 +567,21 @@ export class BlackboxSession {
|
|
|
415
567
|
const id = r.json.approval_id;
|
|
416
568
|
if (r.status !== 201 || typeof id !== "string") {
|
|
417
569
|
this.fail("rejected", `asking for an approval: the gateway answered ${r.status}`);
|
|
418
|
-
return "error";
|
|
570
|
+
return { status: "error" };
|
|
419
571
|
}
|
|
420
572
|
const deadline = Date.now() + (options.timeoutMs ?? 310_000);
|
|
421
573
|
while (Date.now() < deadline) {
|
|
422
574
|
const s = await send("GET", this.options.url, `/v1/sessions/${this.id}/approvals/${id}`, this.token);
|
|
423
575
|
const status = s.json.status;
|
|
424
576
|
if (status === "approved" || status === "rejected" || status === "timeout")
|
|
425
|
-
return status;
|
|
577
|
+
return { status, id };
|
|
426
578
|
await sleep(options.pollMs ?? 1_000);
|
|
427
579
|
}
|
|
428
|
-
return "timeout";
|
|
580
|
+
return { status: "timeout", id };
|
|
429
581
|
}
|
|
430
582
|
catch (error) {
|
|
431
583
|
this.fail("network", `asking for an approval: ${message(error)}`);
|
|
432
|
-
return "error";
|
|
584
|
+
return { status: "error" };
|
|
433
585
|
}
|
|
434
586
|
}
|
|
435
587
|
/** Audit S8: `await using s = await session(…)` closes the session when the scope ends. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type Obj = Record<string, unknown>;
|
|
2
|
+
type Commitments = {
|
|
3
|
+
min_actions?: number;
|
|
4
|
+
max_actions?: number;
|
|
5
|
+
max_gap_ms?: number;
|
|
6
|
+
};
|
|
7
|
+
/** Zanii's SLA terms. `commitments` holds at least one of min_actions, max_actions, max_gap_ms. */
|
|
8
|
+
export declare function buildSlaBody(o: {
|
|
9
|
+
provider: string;
|
|
10
|
+
client: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
window: {
|
|
13
|
+
start: string;
|
|
14
|
+
end: string;
|
|
15
|
+
};
|
|
16
|
+
commitments: Commitments;
|
|
17
|
+
price?: {
|
|
18
|
+
amount: string;
|
|
19
|
+
currency: string;
|
|
20
|
+
};
|
|
21
|
+
createdAt: string;
|
|
22
|
+
ref?: string;
|
|
23
|
+
}): Obj;
|
|
24
|
+
/** One party signs the terms. Collect the provider's and the client's. */
|
|
25
|
+
export declare const signSla: (body: Obj, did: string, privateKey: Uint8Array) => {
|
|
26
|
+
did: string;
|
|
27
|
+
sig: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const assembleSla: (body: Obj, signatures: Array<{
|
|
30
|
+
did: string;
|
|
31
|
+
sig: string;
|
|
32
|
+
}>) => {
|
|
33
|
+
body: Obj;
|
|
34
|
+
signatures: {
|
|
35
|
+
did: string;
|
|
36
|
+
sig: string;
|
|
37
|
+
}[];
|
|
38
|
+
};
|
|
39
|
+
export declare const slaHash: (sla: Obj) => string;
|
|
40
|
+
/** Zanii's check: sane terms, and BOTH provider and client signed exactly these. */
|
|
41
|
+
export declare function verifySla(sla: unknown): {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
reasons: string[];
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Zanii's verdict over the provider's consecutive receipt chain for the window. Breaches:
|
|
47
|
+
* INVALID_RECEIPT, INCOMPLETE_RECORD (a seam: something recorded was left out), TOO_FEW_ACTIONS,
|
|
48
|
+
* TOO_MANY_ACTIONS, MAX_GAP_EXCEEDED. `asOf` (default the window's end) caps the trailing gap.
|
|
49
|
+
*/
|
|
50
|
+
export declare function assessCompliance(sla: Obj, receipts: readonly unknown[], asOf?: string): {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
breaches: {
|
|
53
|
+
kind: string;
|
|
54
|
+
detail: string;
|
|
55
|
+
}[];
|
|
56
|
+
metrics: {
|
|
57
|
+
actions: number;
|
|
58
|
+
max_gap_ms: number | null;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Service agreements proven from receipts (spec/anchoring.md §14): Zanii's SLA, co-signed terms
|
|
2
|
+
// whose compliance is computed from the provider's consecutive receipt chain, the same verdict for
|
|
3
|
+
// everyone (@zanii/sla). Pure; mirrors sdks/python/src/zanii_blackbox/sla.py; pinned by
|
|
4
|
+
// spec/vectors/sla.json.
|
|
5
|
+
import { canonicalBytes, jcsHash, publicKeyFromDid, receiptHash, scopeCovers, verifyReceipt, } from "@zanii/core";
|
|
6
|
+
import { parseMoney } from "../finance/index.js";
|
|
7
|
+
import { ed25519Sign, ed25519Verify } from "../transparency/index.js";
|
|
8
|
+
const ms = (ts) => {
|
|
9
|
+
if (typeof ts !== "string")
|
|
10
|
+
return null;
|
|
11
|
+
const t = Date.parse(ts);
|
|
12
|
+
return Number.isNaN(t) ? null : t;
|
|
13
|
+
};
|
|
14
|
+
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
15
|
+
/** Zanii's SLA terms. `commitments` holds at least one of min_actions, max_actions, max_gap_ms. */
|
|
16
|
+
export function buildSlaBody(o) {
|
|
17
|
+
if (!o.provider || !o.client)
|
|
18
|
+
throw new Error("provider and client are required");
|
|
19
|
+
if (o.provider === o.client)
|
|
20
|
+
throw new Error("provider and client must differ");
|
|
21
|
+
if (!o.scope)
|
|
22
|
+
throw new Error("scope is required (the target pattern the service receipts match)");
|
|
23
|
+
const start = ms(o.window.start);
|
|
24
|
+
const end = ms(o.window.end);
|
|
25
|
+
if (start === null || end === null)
|
|
26
|
+
throw new Error("window start/end must be ISO timestamps");
|
|
27
|
+
if (start >= end)
|
|
28
|
+
throw new Error("window start must be before end");
|
|
29
|
+
if (ms(o.createdAt) === null)
|
|
30
|
+
throw new Error("created_at must be an ISO timestamp");
|
|
31
|
+
const c = { ...o.commitments };
|
|
32
|
+
for (const [k, v] of Object.entries(c))
|
|
33
|
+
if (v !== undefined && v !== null && !isCount(v))
|
|
34
|
+
throw new Error(`commitment ${k} must be a non-negative integer`);
|
|
35
|
+
if (c.min_actions == null && c.max_actions == null && c.max_gap_ms == null)
|
|
36
|
+
throw new Error("an SLA with no commitments commits to nothing");
|
|
37
|
+
if (c.min_actions != null && c.max_actions != null && c.min_actions > c.max_actions)
|
|
38
|
+
throw new Error("min_actions must not exceed max_actions");
|
|
39
|
+
return {
|
|
40
|
+
v: 1,
|
|
41
|
+
type: "sla",
|
|
42
|
+
provider: o.provider,
|
|
43
|
+
client: o.client,
|
|
44
|
+
scope: o.scope,
|
|
45
|
+
window: { start: o.window.start, end: o.window.end },
|
|
46
|
+
commitments: c,
|
|
47
|
+
created_at: o.createdAt,
|
|
48
|
+
...(o.price ? { price: parseMoney(o.price.amount, o.price.currency) } : {}),
|
|
49
|
+
...(o.ref !== undefined ? { ref: o.ref } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** One party signs the terms. Collect the provider's and the client's. */
|
|
53
|
+
export const signSla = (body, did, privateKey) => ({
|
|
54
|
+
did,
|
|
55
|
+
sig: `ed25519:${Buffer.from(ed25519Sign(privateKey, canonicalBytes(body))).toString("hex")}`,
|
|
56
|
+
});
|
|
57
|
+
export const assembleSla = (body, signatures) => ({
|
|
58
|
+
body,
|
|
59
|
+
signatures,
|
|
60
|
+
});
|
|
61
|
+
export const slaHash = (sla) => jcsHash(sla);
|
|
62
|
+
const sigValid = (body, did, sig) => {
|
|
63
|
+
const pub = publicKeyFromDid(did);
|
|
64
|
+
const m = typeof sig === "string" ? /^ed25519:([0-9a-f]{128})$/.exec(sig) : null;
|
|
65
|
+
return Boolean(pub && m && ed25519Verify(pub, canonicalBytes(body), Buffer.from(m[1], "hex")));
|
|
66
|
+
};
|
|
67
|
+
/** Zanii's check: sane terms, and BOTH provider and client signed exactly these. */
|
|
68
|
+
export function verifySla(sla) {
|
|
69
|
+
const reasons = [];
|
|
70
|
+
const e = (sla ?? {});
|
|
71
|
+
const b = e.body ?? {};
|
|
72
|
+
if (b.v !== 1 || b.type !== "sla")
|
|
73
|
+
reasons.push("not an sla (v1)");
|
|
74
|
+
const { provider, client } = b;
|
|
75
|
+
if (!provider || !client || provider === client)
|
|
76
|
+
reasons.push("missing/invalid parties");
|
|
77
|
+
if (!b.scope)
|
|
78
|
+
reasons.push("missing scope");
|
|
79
|
+
const w = (b.window ?? {});
|
|
80
|
+
const start = ms(w.start);
|
|
81
|
+
const end = ms(w.end);
|
|
82
|
+
if (start === null || end === null || start >= end)
|
|
83
|
+
reasons.push("invalid window");
|
|
84
|
+
const c = (b.commitments ?? {});
|
|
85
|
+
if (c.min_actions == null && c.max_actions == null && c.max_gap_ms == null)
|
|
86
|
+
reasons.push("no commitments");
|
|
87
|
+
if (reasons.length === 0)
|
|
88
|
+
for (const [party, role] of [
|
|
89
|
+
[provider, "provider"],
|
|
90
|
+
[client, "client"],
|
|
91
|
+
]) {
|
|
92
|
+
const s = (e.signatures ?? []).find((x) => x.did === party);
|
|
93
|
+
if (!s)
|
|
94
|
+
reasons.push(`missing signature from ${role}`);
|
|
95
|
+
else if (!sigValid(b, String(s.did), s.sig))
|
|
96
|
+
reasons.push(`invalid signature from ${role}`);
|
|
97
|
+
}
|
|
98
|
+
return { ok: reasons.length === 0, reasons };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Zanii's verdict over the provider's consecutive receipt chain for the window. Breaches:
|
|
102
|
+
* INVALID_RECEIPT, INCOMPLETE_RECORD (a seam: something recorded was left out), TOO_FEW_ACTIONS,
|
|
103
|
+
* TOO_MANY_ACTIONS, MAX_GAP_EXCEEDED. `asOf` (default the window's end) caps the trailing gap.
|
|
104
|
+
*/
|
|
105
|
+
export function assessCompliance(sla, receipts, asOf) {
|
|
106
|
+
const agreement = verifySla(sla);
|
|
107
|
+
if (!agreement.ok)
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
breaches: agreement.reasons.map((r) => ({ kind: "INVALID_RECEIPT", detail: `sla: ${r}` })),
|
|
111
|
+
metrics: { actions: 0, max_gap_ms: null },
|
|
112
|
+
};
|
|
113
|
+
const b = sla.body;
|
|
114
|
+
const breaches = [];
|
|
115
|
+
const winStart = ms(b.window.start);
|
|
116
|
+
const winEnd = ms(b.window.end);
|
|
117
|
+
if (asOf !== undefined && ms(asOf) === null)
|
|
118
|
+
throw new Error("as_of must be an ISO timestamp");
|
|
119
|
+
const asOfMs = Math.min(asOf !== undefined ? ms(asOf) : winEnd, winEnd);
|
|
120
|
+
// a stable sort, as Python's sorted
|
|
121
|
+
const sorted = receipts
|
|
122
|
+
.map((r, i) => ({ r, i, t: ms(r.ts) ?? 0 }))
|
|
123
|
+
.sort((x, y) => x.t - y.t || x.i - y.i)
|
|
124
|
+
.map((x) => x.r);
|
|
125
|
+
for (const r of sorted) {
|
|
126
|
+
let check;
|
|
127
|
+
try {
|
|
128
|
+
check = verifyReceipt(r);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
check = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
132
|
+
}
|
|
133
|
+
if (!check.ok)
|
|
134
|
+
breaches.push({
|
|
135
|
+
kind: "INVALID_RECEIPT",
|
|
136
|
+
detail: check.error || "invalid receipt",
|
|
137
|
+
receipt: receiptHash(r),
|
|
138
|
+
});
|
|
139
|
+
else if (r.agent_id !== b.provider)
|
|
140
|
+
breaches.push({
|
|
141
|
+
kind: "INVALID_RECEIPT",
|
|
142
|
+
detail: `receipt by ${r.agent_id}, not the provider`,
|
|
143
|
+
receipt: receiptHash(r),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (let i = 1; i < sorted.length; i++)
|
|
147
|
+
if (sorted[i].prev !== receiptHash(sorted[i - 1]))
|
|
148
|
+
breaches.push({
|
|
149
|
+
kind: "INCOMPLETE_RECORD",
|
|
150
|
+
detail: `chain break between receipts #${i - 1} and #${i} — the slice is not the provider's consecutive record`,
|
|
151
|
+
receipt: receiptHash(sorted[i]),
|
|
152
|
+
});
|
|
153
|
+
const matching = sorted.filter((r) => {
|
|
154
|
+
const t = ms(r.ts);
|
|
155
|
+
return (t !== null && winStart <= t && t <= winEnd && scopeCovers(String(b.scope), r.target || ""));
|
|
156
|
+
});
|
|
157
|
+
const c = b.commitments;
|
|
158
|
+
if (c.min_actions != null && matching.length < c.min_actions)
|
|
159
|
+
breaches.push({
|
|
160
|
+
kind: "TOO_FEW_ACTIONS",
|
|
161
|
+
detail: `${matching.length} matching actions < committed minimum ${c.min_actions}`,
|
|
162
|
+
});
|
|
163
|
+
if (c.max_actions != null && matching.length > c.max_actions)
|
|
164
|
+
breaches.push({
|
|
165
|
+
kind: "TOO_MANY_ACTIONS",
|
|
166
|
+
detail: `${matching.length} matching actions > committed maximum ${c.max_actions}`,
|
|
167
|
+
});
|
|
168
|
+
let maxGap = null;
|
|
169
|
+
if (matching.length > 0) {
|
|
170
|
+
const times = matching.map((r) => ms(r.ts));
|
|
171
|
+
const gaps = [[times[0] - winStart, null]];
|
|
172
|
+
for (let i = 1; i < times.length; i++)
|
|
173
|
+
gaps.push([times[i] - times[i - 1], matching[i - 1]]);
|
|
174
|
+
const last = times[times.length - 1];
|
|
175
|
+
if (asOfMs > last)
|
|
176
|
+
gaps.push([asOfMs - last, matching[matching.length - 1]]);
|
|
177
|
+
maxGap = Math.max(...gaps.map(([g]) => g));
|
|
178
|
+
if (c.max_gap_ms != null)
|
|
179
|
+
for (const [g, after] of gaps)
|
|
180
|
+
if (g > c.max_gap_ms)
|
|
181
|
+
breaches.push({
|
|
182
|
+
kind: "MAX_GAP_EXCEEDED",
|
|
183
|
+
detail: `${g}ms of silence > committed max ${c.max_gap_ms}ms`,
|
|
184
|
+
...(after ? { receipt: receiptHash(after) } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else if (c.max_gap_ms != null && asOfMs - winStart > c.max_gap_ms)
|
|
188
|
+
breaches.push({
|
|
189
|
+
kind: "MAX_GAP_EXCEEDED",
|
|
190
|
+
detail: `no matching actions for ${asOfMs - winStart}ms > committed max ${c.max_gap_ms}ms`,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
ok: breaches.length === 0,
|
|
194
|
+
breaches,
|
|
195
|
+
metrics: { actions: matching.length, max_gap_ms: maxGap },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type SuccessionReason = "rotation" | "compromise" | "retirement";
|
|
2
|
+
export interface SuccessionBody {
|
|
3
|
+
v: 1;
|
|
4
|
+
type: "succession";
|
|
5
|
+
predecessor: string;
|
|
6
|
+
successor: string;
|
|
7
|
+
owner: string;
|
|
8
|
+
reason: SuccessionReason;
|
|
9
|
+
ts: string;
|
|
10
|
+
compromised_at?: string;
|
|
11
|
+
note?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Succession {
|
|
14
|
+
body: SuccessionBody;
|
|
15
|
+
signatures: Array<{
|
|
16
|
+
did: string;
|
|
17
|
+
sig: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
export declare function buildSuccessionBody(input: {
|
|
21
|
+
predecessor: string;
|
|
22
|
+
successor: string;
|
|
23
|
+
owner: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
ts: string;
|
|
26
|
+
compromised_at?: string;
|
|
27
|
+
note?: string;
|
|
28
|
+
}): SuccessionBody;
|
|
29
|
+
/** Signs as the owner (required) or the predecessor (optional: a planned handover). */
|
|
30
|
+
export declare const signSuccession: (body: SuccessionBody, did: string, seed: Uint8Array) => {
|
|
31
|
+
did: string;
|
|
32
|
+
sig: string;
|
|
33
|
+
};
|
|
34
|
+
export declare const assembleSuccession: (body: SuccessionBody, signatures: Succession["signatures"]) => Succession;
|
|
35
|
+
export declare const successionHash: (s: Succession) => string;
|
|
36
|
+
/** Structure sane and the owner signed; `cosigned` when the predecessor countersigned too. */
|
|
37
|
+
export declare function verifySuccession(s: unknown): {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
reasons: string[];
|
|
40
|
+
cosigned: boolean;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Walks the successions back from `did`: every link verifies, links connect, one owner throughout
|
|
44
|
+
* (a change of owner is a sale, not a succession). `lineage` is oldest first.
|
|
45
|
+
*/
|
|
46
|
+
export declare function verifyLineage(successions: unknown[], did: string): {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
reasons: string[];
|
|
49
|
+
lineage: string[];
|
|
50
|
+
};
|