attenu-guard 0.3.0 → 0.4.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/CHANGELOG.md +62 -0
- package/README.md +8 -2
- package/dist/cjs/adapters/langgraph.d.ts +27 -3
- package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
- package/dist/cjs/adapters/langgraph.js +290 -11
- package/dist/cjs/adapters/langgraph.js.map +1 -1
- package/dist/cjs/audit.d.ts +38 -1
- package/dist/cjs/audit.d.ts.map +1 -1
- package/dist/cjs/audit.js +69 -8
- package/dist/cjs/audit.js.map +1 -1
- package/dist/cjs/canonical.d.ts +19 -0
- package/dist/cjs/canonical.d.ts.map +1 -1
- package/dist/cjs/canonical.js +60 -2
- package/dist/cjs/canonical.js.map +1 -1
- package/dist/cjs/ceilings.d.ts.map +1 -1
- package/dist/cjs/ceilings.js +18 -0
- package/dist/cjs/ceilings.js.map +1 -1
- package/dist/cjs/chain.d.ts +32 -0
- package/dist/cjs/chain.d.ts.map +1 -1
- package/dist/cjs/chain.js +0 -0
- package/dist/cjs/chain.js.map +1 -1
- package/dist/cjs/cli.d.ts.map +1 -1
- package/dist/cjs/cli.js +4 -0
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/evidence.d.ts +41 -2
- package/dist/cjs/evidence.d.ts.map +1 -1
- package/dist/cjs/evidence.js +571 -13
- package/dist/cjs/evidence.js.map +1 -1
- package/dist/cjs/guard.d.ts +163 -9
- package/dist/cjs/guard.d.ts.map +1 -1
- package/dist/cjs/guard.js +426 -26
- package/dist/cjs/guard.js.map +1 -1
- package/dist/cjs/index.d.ts +12 -9
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +24 -5
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/params.d.ts +52 -0
- package/dist/cjs/params.d.ts.map +1 -0
- package/dist/cjs/params.js +97 -0
- package/dist/cjs/params.js.map +1 -0
- package/dist/cjs/reasons.d.ts +89 -1
- package/dist/cjs/reasons.d.ts.map +1 -1
- package/dist/cjs/reasons.js +99 -3
- package/dist/cjs/reasons.js.map +1 -1
- package/dist/cjs/version.d.ts +10 -0
- package/dist/cjs/version.d.ts.map +1 -0
- package/dist/cjs/version.js +13 -0
- package/dist/cjs/version.js.map +1 -0
- package/dist/cjs/wire.d.ts.map +1 -1
- package/dist/cjs/wire.js +7 -0
- package/dist/cjs/wire.js.map +1 -1
- package/dist/esm/adapters/langgraph.d.ts +27 -3
- package/dist/esm/adapters/langgraph.d.ts.map +1 -1
- package/dist/esm/adapters/langgraph.js +290 -11
- package/dist/esm/adapters/langgraph.js.map +1 -1
- package/dist/esm/audit.d.ts +38 -1
- package/dist/esm/audit.d.ts.map +1 -1
- package/dist/esm/audit.js +68 -8
- package/dist/esm/audit.js.map +1 -1
- package/dist/esm/canonical.d.ts +19 -0
- package/dist/esm/canonical.d.ts.map +1 -1
- package/dist/esm/canonical.js +58 -1
- package/dist/esm/canonical.js.map +1 -1
- package/dist/esm/ceilings.d.ts.map +1 -1
- package/dist/esm/ceilings.js +19 -1
- package/dist/esm/ceilings.js.map +1 -1
- package/dist/esm/chain.d.ts +32 -0
- package/dist/esm/chain.d.ts.map +1 -1
- package/dist/esm/chain.js +0 -0
- package/dist/esm/chain.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +4 -0
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/evidence.d.ts +41 -2
- package/dist/esm/evidence.d.ts.map +1 -1
- package/dist/esm/evidence.js +570 -12
- package/dist/esm/evidence.js.map +1 -1
- package/dist/esm/guard.d.ts +163 -9
- package/dist/esm/guard.d.ts.map +1 -1
- package/dist/esm/guard.js +393 -27
- package/dist/esm/guard.js.map +1 -1
- package/dist/esm/index.d.ts +12 -9
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +8 -6
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/params.d.ts +52 -0
- package/dist/esm/params.d.ts.map +1 -0
- package/dist/esm/params.js +92 -0
- package/dist/esm/params.js.map +1 -0
- package/dist/esm/reasons.d.ts +89 -1
- package/dist/esm/reasons.d.ts.map +1 -1
- package/dist/esm/reasons.js +97 -2
- package/dist/esm/reasons.js.map +1 -1
- package/dist/esm/version.d.ts +10 -0
- package/dist/esm/version.d.ts.map +1 -0
- package/dist/esm/version.js +10 -0
- package/dist/esm/version.js.map +1 -0
- package/dist/esm/wire.d.ts.map +1 -1
- package/dist/esm/wire.js +8 -1
- package/dist/esm/wire.js.map +1 -1
- package/package.json +1 -1
package/dist/cjs/evidence.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* `attenu_guard.evidence`.
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
-
exports.EvidenceLeakError = exports.LEDGER_FIELDS = void 0;
|
|
27
|
+
exports.SUPPORTED_BUNDLE_VERSIONS = exports.EvidenceLeakError = exports.LEDGER_FIELDS = void 0;
|
|
28
28
|
exports.redactionReport = redactionReport;
|
|
29
29
|
exports.anchorFor = anchorFor;
|
|
30
30
|
exports.exportBundle = exportBundle;
|
|
@@ -36,6 +36,8 @@ const canonical_js_1 = require("./canonical.js");
|
|
|
36
36
|
const node_crypto_1 = require("node:crypto");
|
|
37
37
|
const audit_js_1 = require("./audit.js");
|
|
38
38
|
const authority_js_1 = require("./authority.js");
|
|
39
|
+
const reasons_js_1 = require("./reasons.js");
|
|
40
|
+
const params_js_1 = require("./params.js");
|
|
39
41
|
/**
|
|
40
42
|
* The COMPLETE set of top-level ledger field names the library emits. Custody
|
|
41
43
|
* guarantee: an exported bundle may carry ONLY these — an unknown field is
|
|
@@ -71,6 +73,19 @@ exports.LEDGER_FIELDS = new Set([
|
|
|
71
73
|
"strikes",
|
|
72
74
|
"mode",
|
|
73
75
|
"disposition",
|
|
76
|
+
// 0.9.0 execution binding (schemaVersion=2 chains): every field named in the spec.
|
|
77
|
+
"call_id",
|
|
78
|
+
"capture",
|
|
79
|
+
"adapter",
|
|
80
|
+
"authorized_params_hash",
|
|
81
|
+
"params_hash_reason",
|
|
82
|
+
"params_salt",
|
|
83
|
+
"body_state",
|
|
84
|
+
"error_code",
|
|
85
|
+
"invoked_params_hash",
|
|
86
|
+
"duration_ms",
|
|
87
|
+
"receipt",
|
|
88
|
+
"pending_at_kill",
|
|
74
89
|
]);
|
|
75
90
|
/**
|
|
76
91
|
* Thrown by `exportBundle({strict: true})` when a bundle would carry a field or
|
|
@@ -84,6 +99,25 @@ class EvidenceLeakError extends Error {
|
|
|
84
99
|
}
|
|
85
100
|
}
|
|
86
101
|
exports.EvidenceLeakError = EvidenceLeakError;
|
|
102
|
+
/**
|
|
103
|
+
* Bundle schema versions this build knows how to verify. 2 (0.9.0): execution binding — callId,
|
|
104
|
+
* capture/adapter, outcome events, params commitments. v1 bundles verify exactly as before;
|
|
105
|
+
* `executionBinding` reports `{status: "not applicable"}` for them (docs/execution-binding spec
|
|
106
|
+
* section 9).
|
|
107
|
+
*/
|
|
108
|
+
exports.SUPPORTED_BUNDLE_VERSIONS = new Set([1, 2]);
|
|
109
|
+
/**
|
|
110
|
+
* The chain's declared schema version, read off the `root` entry (falls back to `SCHEMA_VERSION`
|
|
111
|
+
* for an empty/rootless list — the historical default).
|
|
112
|
+
*/
|
|
113
|
+
function bundleVersion(entries) {
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
if ((0, canonical_js_1.toPlain)(e["event"]) === "root" && "v" in e) {
|
|
116
|
+
return (0, canonical_js_1.toPlain)(e["v"]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return audit_js_1.SCHEMA_VERSION;
|
|
120
|
+
}
|
|
87
121
|
function redactTask(t) {
|
|
88
122
|
if (t === undefined || t === null || t === "" || t === 0 || t === false)
|
|
89
123
|
return t;
|
|
@@ -133,7 +167,7 @@ function anchorFor(entries, signer, ts = 0) {
|
|
|
133
167
|
seq = typeof rawSeq === "number" ? rawSeq : entries.length - 1;
|
|
134
168
|
head = last["hash"];
|
|
135
169
|
}
|
|
136
|
-
const body = { v:
|
|
170
|
+
const body = { v: bundleVersion(entries), c14n: "JCS", chain_id: (0, audit_js_1.chainIdOf)(entries), seq, head, ts };
|
|
137
171
|
return { ...body, kid: signer.kid ?? null, sig: signer.sign((0, canonical_js_1.canonicalBytes)(body)).toString("hex") };
|
|
138
172
|
}
|
|
139
173
|
/**
|
|
@@ -175,7 +209,7 @@ function exportBundle(auditLog, signer, options = {}) {
|
|
|
175
209
|
const anchor = anchorFor(entries, signer, options.ts ?? 0);
|
|
176
210
|
anchor.verified = audit_js_1.AuditLog.verifyAnchor(entries, anchor, signer)[0];
|
|
177
211
|
return {
|
|
178
|
-
v:
|
|
212
|
+
v: bundleVersion(entries),
|
|
179
213
|
c14n: "JCS",
|
|
180
214
|
chain_id: (0, audit_js_1.chainIdOf)(entries),
|
|
181
215
|
entries,
|
|
@@ -320,26 +354,536 @@ function denials(bundle) {
|
|
|
320
354
|
}
|
|
321
355
|
return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
|
|
322
356
|
}
|
|
357
|
+
/** Python-style repr for the small set of value types these failure messages carry. */
|
|
358
|
+
function pyRepr(value) {
|
|
359
|
+
if (value === null)
|
|
360
|
+
return "None";
|
|
361
|
+
if (typeof value === "number")
|
|
362
|
+
return String(value);
|
|
363
|
+
if (typeof value === "string")
|
|
364
|
+
return `'${value}'`;
|
|
365
|
+
return JSON.stringify(value);
|
|
366
|
+
}
|
|
367
|
+
// =============================================================================================
|
|
368
|
+
// Execution binding (0.9.0): offline checks over callId/allow/outcome, from the ledger alone —
|
|
369
|
+
// docs/execution-binding spec section 5. schemaVersion=2 chains only; a v1 bundle's
|
|
370
|
+
// executionBinding is `{status: "not applicable"}`.
|
|
371
|
+
// =============================================================================================
|
|
372
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
373
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
323
374
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* the
|
|
328
|
-
*
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
375
|
+
* `true` when `field` is EXPLICITLY present on `e` with a JSON `null` value — distinct from the
|
|
376
|
+
* key being absent entirely. A conditional field (`authorized_params_hash`, `capture`, ...) must
|
|
377
|
+
* be either a valid value or ABSENT; an explicit `null` is neither, and reading `e[field]` alone
|
|
378
|
+
* cannot tell the two apart (both come back as `undefined`/`null`-ish), so every validator checks
|
|
379
|
+
* membership first.
|
|
380
|
+
*/
|
|
381
|
+
function presentButNull(e, field) {
|
|
382
|
+
return field in e && (0, canonical_js_1.toPlain)(e[field]) === null;
|
|
383
|
+
}
|
|
384
|
+
function validCallId(e) {
|
|
385
|
+
if (presentButNull(e, "call_id")) {
|
|
386
|
+
return "call_id is explicitly null (must be a valid call_id or absent)";
|
|
387
|
+
}
|
|
388
|
+
const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
|
|
389
|
+
if (typeof cid !== "string" || !HEX32.test(cid)) {
|
|
390
|
+
return `call_id missing or malformed (${pyRepr(cid ?? null)})`;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
function validHashField(e, field) {
|
|
395
|
+
if (presentButNull(e, field)) {
|
|
396
|
+
return `${field} is explicitly null (must be a valid hash or absent)`;
|
|
397
|
+
}
|
|
398
|
+
const v = (0, canonical_js_1.toPlain)(e[field]);
|
|
399
|
+
if (v === undefined || v === null)
|
|
400
|
+
return null;
|
|
401
|
+
if (typeof v !== "string" || !HEX64.test(v)) {
|
|
402
|
+
return `${field} malformed (${pyRepr(v)})`;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
function validParamsHashReason(e, hashField) {
|
|
407
|
+
if (presentButNull(e, "params_hash_reason")) {
|
|
408
|
+
return "params_hash_reason is explicitly null (must be a valid reason or absent)";
|
|
409
|
+
}
|
|
410
|
+
const reason = (0, canonical_js_1.toPlain)(e["params_hash_reason"]);
|
|
411
|
+
if (reason !== null && reason !== undefined && !params_js_1.PARAMS_HASH_REASONS.has(reason)) {
|
|
412
|
+
return `params_hash_reason ${pyRepr(reason)} not a known value`;
|
|
413
|
+
}
|
|
414
|
+
const hash = (0, canonical_js_1.toPlain)(e[hashField]);
|
|
415
|
+
if (reason !== null && reason !== undefined && hash !== null && hash !== undefined) {
|
|
416
|
+
return `params_hash_reason present alongside ${hashField} (illegal conditional field)`;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
function isPlainRecord(v) {
|
|
421
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
422
|
+
}
|
|
423
|
+
function validateAllow(e) {
|
|
424
|
+
let err = validCallId(e);
|
|
425
|
+
if (err)
|
|
426
|
+
return err;
|
|
427
|
+
if (presentButNull(e, "capture")) {
|
|
428
|
+
return "capture is explicitly null (must be a valid Capture value)";
|
|
429
|
+
}
|
|
430
|
+
if (presentButNull(e, "adapter")) {
|
|
431
|
+
return "adapter is explicitly null (must be a valid adapter object)";
|
|
432
|
+
}
|
|
433
|
+
const capture = (0, canonical_js_1.toPlain)(e["capture"]);
|
|
434
|
+
const adapter = (0, canonical_js_1.toPlain)(e["adapter"]);
|
|
435
|
+
// Mandatory on every v2 allow (not merely paired with each other): a bare check() with no
|
|
436
|
+
// wrapper is ITSELF pre_hook_only observation, and Guard.check() supplies that truthfully —
|
|
437
|
+
// there is no honest reason for a v2 allow to lack capture/adapter, so absence is now invalid,
|
|
438
|
+
// not "no claim made" (merge-gate item 4).
|
|
439
|
+
if (capture === null || capture === undefined) {
|
|
440
|
+
return "capture is required on every v2 allow";
|
|
441
|
+
}
|
|
442
|
+
if (!reasons_js_1.CAPTURES.has(capture)) {
|
|
443
|
+
return `capture ${pyRepr(capture)} not a known value`;
|
|
444
|
+
}
|
|
445
|
+
if (adapter === null || adapter === undefined) {
|
|
446
|
+
return "adapter is required alongside capture on every v2 allow";
|
|
447
|
+
}
|
|
448
|
+
if (!isPlainRecord(adapter)) {
|
|
449
|
+
return "adapter must be an object with module/version/hook_path";
|
|
450
|
+
}
|
|
451
|
+
for (const k of ["module", "version", "hook_path"]) {
|
|
452
|
+
const v = adapter[k];
|
|
453
|
+
if (typeof v !== "string" || !v) {
|
|
454
|
+
return `adapter[${JSON.stringify(k)}] must be a non-empty string`;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
err = validHashField(e, "authorized_params_hash");
|
|
458
|
+
if (err)
|
|
459
|
+
return err;
|
|
460
|
+
return validParamsHashReason(e, "authorized_params_hash");
|
|
461
|
+
}
|
|
462
|
+
/** Fields that only ever belong on an `allow` entry — illegal on a `deny`, on any schema version. */
|
|
463
|
+
const ALLOW_ONLY_FIELDS = ["capture", "adapter", "authorized_params_hash", "params_hash_reason"];
|
|
464
|
+
function validateDeny(e) {
|
|
465
|
+
const err = validCallId(e);
|
|
466
|
+
if (err)
|
|
467
|
+
return err;
|
|
468
|
+
const leaked = ALLOW_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
469
|
+
if (leaked.length > 0) {
|
|
470
|
+
return `deny carries allow-only field(s) ${JSON.stringify(leaked)}`;
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
function validateOutcome(e) {
|
|
475
|
+
const err0 = validCallId(e);
|
|
476
|
+
if (err0)
|
|
477
|
+
return err0;
|
|
478
|
+
if (presentButNull(e, "body_state"))
|
|
479
|
+
return "body_state is explicitly null";
|
|
480
|
+
const bodyState = (0, canonical_js_1.toPlain)(e["body_state"]);
|
|
481
|
+
if (typeof bodyState !== "string" || !reasons_js_1.BODY_STATES.has(bodyState)) {
|
|
482
|
+
return `body_state ${pyRepr(bodyState ?? null)} not a known value`;
|
|
483
|
+
}
|
|
484
|
+
if (presentButNull(e, "error_code")) {
|
|
485
|
+
return "error_code is explicitly null (must be a non-empty string or absent)";
|
|
486
|
+
}
|
|
487
|
+
const errorCode = (0, canonical_js_1.toPlain)(e["error_code"]);
|
|
488
|
+
if (bodyState === reasons_js_1.BodyState.RAISED) {
|
|
489
|
+
if (typeof errorCode !== "string" || !errorCode) {
|
|
490
|
+
return "error_code required when body_state == raised";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else if (errorCode !== null && errorCode !== undefined) {
|
|
494
|
+
return "error_code present but body_state != raised (illegal conditional field)";
|
|
495
|
+
}
|
|
496
|
+
if (presentButNull(e, "duration_ms"))
|
|
497
|
+
return "duration_ms is explicitly null";
|
|
498
|
+
const duration = (0, canonical_js_1.toPlain)(e["duration_ms"]);
|
|
499
|
+
if (typeof duration !== "number" || !Number.isInteger(duration) || duration < 0) {
|
|
500
|
+
return `duration_ms invalid (${pyRepr(duration ?? null)})`;
|
|
501
|
+
}
|
|
502
|
+
const err1 = validHashField(e, "invoked_params_hash");
|
|
503
|
+
if (err1)
|
|
504
|
+
return err1;
|
|
505
|
+
const err2 = validParamsHashReason(e, "invoked_params_hash");
|
|
506
|
+
if (err2)
|
|
507
|
+
return err2;
|
|
508
|
+
if (presentButNull(e, "receipt")) {
|
|
509
|
+
return "receipt is explicitly null (must be a valid receipt or absent)";
|
|
510
|
+
}
|
|
511
|
+
const receipt = (0, canonical_js_1.toPlain)(e["receipt"]);
|
|
512
|
+
if (receipt !== null && receipt !== undefined) {
|
|
513
|
+
if (!isPlainRecord(receipt))
|
|
514
|
+
return "receipt must be an object with type/ref/digest";
|
|
515
|
+
for (const k of ["type", "ref"]) {
|
|
516
|
+
const v = receipt[k];
|
|
517
|
+
if (typeof v !== "string" || !v) {
|
|
518
|
+
return `receipt[${JSON.stringify(k)}] must be a non-empty string`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const digest = receipt["digest"];
|
|
522
|
+
if (typeof digest !== "string" || !HEX64.test(digest)) {
|
|
523
|
+
return "receipt['digest'] must be a lowercase-hex SHA-256 digest (64 hex characters)";
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* v2 root only: `params_salt` is MANDATORY (spec section 4 — the whole chain's argument
|
|
530
|
+
* commitments are computed against it) and must be 32 lowercase hex characters (16 raw bytes).
|
|
531
|
+
*/
|
|
532
|
+
function validateRoot(e) {
|
|
533
|
+
if (presentButNull(e, "params_salt"))
|
|
534
|
+
return "params_salt is explicitly null";
|
|
535
|
+
const salt = (0, canonical_js_1.toPlain)(e["params_salt"]);
|
|
536
|
+
if (typeof salt !== "string" || !/^[0-9a-f]{32}$/.test(salt)) {
|
|
537
|
+
return `params_salt missing or malformed on the v2 root entry (${pyRepr(salt ?? null)})`;
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
/** v2 kill only: `pending_at_kill`, when present, must be a list of call_id-shaped strings. */
|
|
542
|
+
function validateKill(e) {
|
|
543
|
+
if (presentButNull(e, "pending_at_kill")) {
|
|
544
|
+
return "pending_at_kill is explicitly null (must be a list or absent)";
|
|
545
|
+
}
|
|
546
|
+
const pending = (0, canonical_js_1.toPlain)(e["pending_at_kill"]);
|
|
547
|
+
if (pending === null || pending === undefined)
|
|
548
|
+
return null;
|
|
549
|
+
if (!Array.isArray(pending) || pending.some((c) => typeof c !== "string" || !HEX32.test(c))) {
|
|
550
|
+
return `pending_at_kill must be a list of call_id-shaped strings (${pyRepr(pending)})`;
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* `complete | partial | none`, from how many calls carry both hashes — computed over EVERY valid
|
|
556
|
+
* allow (spec section 5: "how many calls carry both hashes"), not only calls that already have
|
|
557
|
+
* an outcome: a call still pending necessarily lacks `invoked_params_hash` and so correctly
|
|
558
|
+
* counts against coverage, not merely outside the sample.
|
|
559
|
+
*/
|
|
560
|
+
function paramsCoverage(allows, outcomes, invalidAllowIds) {
|
|
561
|
+
let total = 0;
|
|
562
|
+
let both = 0;
|
|
563
|
+
for (const [cid, allowE] of allows) {
|
|
564
|
+
if (invalidAllowIds.has(cid))
|
|
565
|
+
continue;
|
|
566
|
+
total += 1;
|
|
567
|
+
const oc = outcomes.get(cid);
|
|
568
|
+
if ((0, canonical_js_1.toPlain)(allowE["authorized_params_hash"]) && oc !== undefined && (0, canonical_js_1.toPlain)(oc["invoked_params_hash"])) {
|
|
569
|
+
both += 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (total === 0 || both === 0)
|
|
573
|
+
return "none";
|
|
574
|
+
return both === total ? "complete" : "partial";
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Every field the library ever writes only under `schemaVersion: 2` (spec sections 1-7). A
|
|
578
|
+
* `schemaVersion: 1` chain must carry NONE of them — including `call_id`: v1 never allocates one.
|
|
332
579
|
*/
|
|
333
|
-
|
|
580
|
+
const V2_ONLY_FIELDS = [
|
|
581
|
+
"call_id",
|
|
582
|
+
"capture",
|
|
583
|
+
"adapter",
|
|
584
|
+
"authorized_params_hash",
|
|
585
|
+
"params_hash_reason",
|
|
586
|
+
"params_salt",
|
|
587
|
+
"body_state",
|
|
588
|
+
"error_code",
|
|
589
|
+
"invoked_params_hash",
|
|
590
|
+
"duration_ms",
|
|
591
|
+
"receipt",
|
|
592
|
+
"pending_at_kill",
|
|
593
|
+
];
|
|
594
|
+
/**
|
|
595
|
+
* Every v2-only field found on any entry of a `schemaVersion: 1` bundle — mixed-version data,
|
|
596
|
+
* invalid regardless of which field it is (merge-gate item 4/(c)).
|
|
597
|
+
*/
|
|
598
|
+
function v2FieldLeaksOnV1(entries) {
|
|
599
|
+
const failures = [];
|
|
600
|
+
for (const e of entries) {
|
|
601
|
+
const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
602
|
+
if (leaked.length > 0) {
|
|
603
|
+
failures.push(`v2_field_on_v1: seq=${pyRepr((0, canonical_js_1.toPlain)(e["seq"]))} event=${pyRepr((0, canonical_js_1.toPlain)(e["event"]))} ` +
|
|
604
|
+
`carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return failures;
|
|
608
|
+
}
|
|
609
|
+
function executionBinding(entries, bundleV) {
|
|
610
|
+
if (bundleV === 1) {
|
|
611
|
+
const leaked = v2FieldLeaksOnV1(entries);
|
|
612
|
+
return leaked.length > 0 ? { status: "not applicable", failures: leaked } : { status: "not applicable" };
|
|
613
|
+
}
|
|
614
|
+
if (bundleV !== 2)
|
|
615
|
+
return { status: "not applicable" };
|
|
616
|
+
const failures = [];
|
|
617
|
+
const seenCallIds = new Map(); // callId -> [event, node, seq]
|
|
618
|
+
const allows = new Map();
|
|
619
|
+
const outcomes = new Map();
|
|
620
|
+
const invalidAllowIds = new Set();
|
|
621
|
+
const nodes = new Set();
|
|
622
|
+
const finalizedNodes = new Set();
|
|
623
|
+
const revokedNodes = new Set();
|
|
624
|
+
for (const e of entries) {
|
|
625
|
+
const ev = (0, canonical_js_1.toPlain)(e["event"]);
|
|
626
|
+
const node = (0, canonical_js_1.toPlain)(e["node"]);
|
|
627
|
+
const seqForEvent = (0, canonical_js_1.toPlain)(e["seq"]);
|
|
628
|
+
if (ev === "root") {
|
|
629
|
+
if (node !== null)
|
|
630
|
+
nodes.add(node);
|
|
631
|
+
const err = validateRoot(e);
|
|
632
|
+
if (err)
|
|
633
|
+
failures.push(`invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`);
|
|
634
|
+
}
|
|
635
|
+
else if (ev === "spawn") {
|
|
636
|
+
if (node !== null)
|
|
637
|
+
nodes.add(node);
|
|
638
|
+
}
|
|
639
|
+
else if (ev === "done") {
|
|
640
|
+
if (node !== null)
|
|
641
|
+
finalizedNodes.add(node);
|
|
642
|
+
}
|
|
643
|
+
else if (ev === "kill") {
|
|
644
|
+
for (const r of (0, canonical_js_1.toPlain)(e["revoked"]) ?? [])
|
|
645
|
+
revokedNodes.add(r);
|
|
646
|
+
const err = validateKill(e);
|
|
647
|
+
if (err)
|
|
648
|
+
failures.push(`invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`);
|
|
649
|
+
}
|
|
650
|
+
if (ev === "allow" || ev === "deny") {
|
|
651
|
+
const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
|
|
652
|
+
const seq = (0, canonical_js_1.toPlain)(e["seq"]);
|
|
653
|
+
if (cid !== null && cid !== undefined) {
|
|
654
|
+
const prior = seenCallIds.get(cid);
|
|
655
|
+
if (prior !== undefined) {
|
|
656
|
+
failures.push(`duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
|
|
657
|
+
`${pyRepr(prior[2])} (${prior[0]})`);
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
seenCallIds.set(cid, [ev, node, seq]);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
|
|
664
|
+
if (err) {
|
|
665
|
+
failures.push(`invalid_${ev}: ${err} (seq ${pyRepr(seq)})`);
|
|
666
|
+
if (ev === "allow" && cid !== null && cid !== undefined)
|
|
667
|
+
invalidAllowIds.add(cid);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (ev === "allow" && cid !== null && cid !== undefined)
|
|
671
|
+
allows.set(cid, e);
|
|
672
|
+
}
|
|
673
|
+
else if (ev === "outcome") {
|
|
674
|
+
const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
|
|
675
|
+
const seq = (0, canonical_js_1.toPlain)(e["seq"]);
|
|
676
|
+
const err = validateOutcome(e);
|
|
677
|
+
if (err) {
|
|
678
|
+
failures.push(`invalid_outcome: ${err} (seq ${pyRepr(seq)})`);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (cid !== null && outcomes.has(cid)) {
|
|
682
|
+
failures.push(`duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
|
|
683
|
+
`${pyRepr((0, canonical_js_1.toPlain)(outcomes.get(cid)["seq"]))})`);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (cid !== null)
|
|
687
|
+
outcomes.set(cid, e);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Bind each outcome to its allow: outcome_without_allow / cross_ref / outcome_before_allow /
|
|
691
|
+
// params_mismatch. `boundOk`: callIds whose outcome exists AND passed identity+order binding
|
|
692
|
+
// (node match, seq after the allow) — spec's "observed (an outcome exists, bound correctly)".
|
|
693
|
+
// Failing params_mismatch does NOT itself un-bind a call: the call plainly WAS observed, only
|
|
694
|
+
// its recorded content disagrees with what was authorized (spec: "parameter equality is
|
|
695
|
+
// established only for calls where both hashes are present; elsewhere only identity and order
|
|
696
|
+
// binding was checked" — params_mismatch is that separate concern).
|
|
697
|
+
const boundOk = new Set();
|
|
698
|
+
for (const [cid, oc] of outcomes) {
|
|
699
|
+
const allowE = allows.get(cid);
|
|
700
|
+
if (allowE === undefined) {
|
|
701
|
+
failures.push(`outcome_without_allow: call_id ${cid} at seq ${pyRepr((0, canonical_js_1.toPlain)(oc["seq"]))} has no allow in this chain`);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const nodeOk = (0, canonical_js_1.toPlain)(allowE["node"]) === (0, canonical_js_1.toPlain)(oc["node"]);
|
|
705
|
+
if (!nodeOk) {
|
|
706
|
+
failures.push(`cross_ref: call_id ${cid} allow on node ${pyRepr((0, canonical_js_1.toPlain)(allowE["node"]))} but ` +
|
|
707
|
+
`outcome on node ${pyRepr((0, canonical_js_1.toPlain)(oc["node"]))}`);
|
|
708
|
+
}
|
|
709
|
+
const ocSeq = (0, canonical_js_1.toPlain)(oc["seq"]);
|
|
710
|
+
const allowSeq = (0, canonical_js_1.toPlain)(allowE["seq"]);
|
|
711
|
+
const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
|
|
712
|
+
if (!orderOk) {
|
|
713
|
+
failures.push(`outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
|
|
714
|
+
`after allow seq ${pyRepr(allowSeq ?? null)}`);
|
|
715
|
+
}
|
|
716
|
+
const ah = (0, canonical_js_1.toPlain)(allowE["authorized_params_hash"]);
|
|
717
|
+
const ih = (0, canonical_js_1.toPlain)(oc["invoked_params_hash"]);
|
|
718
|
+
if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
|
|
719
|
+
failures.push(`params_mismatch: call_id ${cid} authorized_params_hash ${ah} != invoked_params_hash ${ih}`);
|
|
720
|
+
}
|
|
721
|
+
if (nodeOk && orderOk)
|
|
722
|
+
boundOk.add(cid);
|
|
723
|
+
}
|
|
724
|
+
// Per-call observation + per-node pending, from valid allows only.
|
|
725
|
+
const perCall = {};
|
|
726
|
+
const nodePending = new Map();
|
|
727
|
+
for (const [cid, allowE] of allows) {
|
|
728
|
+
if (invalidAllowIds.has(cid))
|
|
729
|
+
continue;
|
|
730
|
+
// Spec order matters: "observed" (an outcome exists, BOUND CORRECTLY) is checked FIRST —
|
|
731
|
+
// not merely "a callId-matching outcome exists somewhere", which a cross_ref'd or
|
|
732
|
+
// misordered outcome would satisfy despite being wrong. Only once no correctly-bound outcome
|
|
733
|
+
// exists does capture decide unobserved (none was promised) vs unaccounted (one was, and
|
|
734
|
+
// none arrived correctly).
|
|
735
|
+
if (boundOk.has(cid)) {
|
|
736
|
+
perCall[cid] = "observed";
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const capture = (0, canonical_js_1.toPlain)(allowE["capture"]);
|
|
740
|
+
if (capture === null || capture === undefined || capture === reasons_js_1.Capture.PRE_HOOK_ONLY) {
|
|
741
|
+
perCall[cid] = "unobserved";
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
perCall[cid] = "unaccounted";
|
|
745
|
+
const node = (0, canonical_js_1.toPlain)(allowE["node"]);
|
|
746
|
+
const list = nodePending.get(node) ?? [];
|
|
747
|
+
list.push(cid);
|
|
748
|
+
nodePending.set(node, list);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Per-node lifecycle. "revoked" (clean kill, nothing pending) is not one of the spec's three
|
|
752
|
+
// named states (finalized/in_progress/revoked_with_pending) — it names the gap those three
|
|
753
|
+
// leave for a cleanly-killed node, distinct from revoked_with_pending, and never escalates the
|
|
754
|
+
// aggregate (mirrors the Python reference implementation's report).
|
|
755
|
+
const lifecycle = {};
|
|
756
|
+
for (const n of nodes) {
|
|
757
|
+
if (finalizedNodes.has(n)) {
|
|
758
|
+
lifecycle[n] = "finalized";
|
|
759
|
+
}
|
|
760
|
+
else if (revokedNodes.has(n)) {
|
|
761
|
+
lifecycle[n] = (nodePending.get(n)?.length ?? 0) > 0 ? "revoked_with_pending" : "revoked";
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
lifecycle[n] = "in_progress";
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Aggregate: clean < incomplete < failed — never downgrade once escalated.
|
|
768
|
+
const order = { clean: 0, incomplete: 1, failed: 2 };
|
|
769
|
+
let aggregate = "clean";
|
|
770
|
+
const escalate = (level) => {
|
|
771
|
+
if (order[level] > order[aggregate])
|
|
772
|
+
aggregate = level;
|
|
773
|
+
};
|
|
774
|
+
if (failures.length > 0) {
|
|
775
|
+
// Any binding failure or invalid record is a genuine inconsistency, not a benign gap — worse
|
|
776
|
+
// than "incomplete", which the spec reserves for gaps that are no producer fault.
|
|
777
|
+
escalate("failed");
|
|
778
|
+
}
|
|
779
|
+
for (const [n, state] of Object.entries(lifecycle)) {
|
|
780
|
+
if (state === "finalized" && (nodePending.get(n)?.length ?? 0) > 0) {
|
|
781
|
+
escalate("failed"); // an unaccounted call in a finalized node (spec section 5)
|
|
782
|
+
}
|
|
783
|
+
else if (state === "in_progress" || state === "revoked_with_pending") {
|
|
784
|
+
escalate("incomplete");
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (Object.values(perCall).some((s) => s === "unobserved"))
|
|
788
|
+
escalate("incomplete");
|
|
789
|
+
return {
|
|
790
|
+
aggregate,
|
|
791
|
+
params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
|
|
792
|
+
per_call: perCall,
|
|
793
|
+
per_node_lifecycle: lifecycle,
|
|
794
|
+
failures,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function verifyBundle(bundle, signer = null, options = {}) {
|
|
334
798
|
const entries = bundle.entries ?? [];
|
|
335
799
|
const anchor = (bundle.anchor ?? {});
|
|
800
|
+
const anchorPresent = Object.keys(anchor).length > 0;
|
|
336
801
|
const checks = {
|
|
337
802
|
integrity: false,
|
|
338
803
|
monotonicity: false,
|
|
339
804
|
containment: false,
|
|
340
805
|
anchor: "not checked",
|
|
806
|
+
version: false,
|
|
807
|
+
chain_id: false,
|
|
808
|
+
root: false,
|
|
809
|
+
expected_anchor: "not checked",
|
|
341
810
|
};
|
|
342
811
|
const failures = [];
|
|
812
|
+
// (0) version: the bundle must declare a schema version this build understands, and — when
|
|
813
|
+
// an anchor is present — the anchor must be anchoring THAT version, not a different one.
|
|
814
|
+
const bundleV = (0, canonical_js_1.toPlain)(bundle.v);
|
|
815
|
+
let versionOk = typeof bundleV === "number" && exports.SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
|
|
816
|
+
if (!versionOk) {
|
|
817
|
+
const supported = Array.from(exports.SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
|
|
818
|
+
failures.push(`unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
|
|
819
|
+
}
|
|
820
|
+
const anchorV = (0, canonical_js_1.toPlain)(anchor["v"]);
|
|
821
|
+
if (anchorPresent && anchorV !== bundleV) {
|
|
822
|
+
versionOk = false;
|
|
823
|
+
failures.push(`anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
|
|
824
|
+
}
|
|
825
|
+
// (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
|
|
826
|
+
// sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
|
|
827
|
+
const rootEvents = entries.filter((e) => (0, canonical_js_1.toPlain)(e["event"]) === "root");
|
|
828
|
+
checks.root = rootEvents.length === 1;
|
|
829
|
+
if (!checks.root) {
|
|
830
|
+
failures.push(`missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
|
|
831
|
+
}
|
|
832
|
+
const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
|
|
833
|
+
// 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
|
|
834
|
+
// entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
|
|
835
|
+
if (rootEntry !== undefined && (0, canonical_js_1.toPlain)(rootEntry["v"]) !== bundleV) {
|
|
836
|
+
versionOk = false;
|
|
837
|
+
failures.push(`root_version_mismatch: root v=${pyRepr((0, canonical_js_1.toPlain)(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`);
|
|
838
|
+
}
|
|
839
|
+
const mixed = Array.from(new Set(entries.map((e) => (0, canonical_js_1.toPlain)(e["v"])).filter((v) => v !== bundleV))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
|
|
840
|
+
if (mixed.length > 0) {
|
|
841
|
+
versionOk = false;
|
|
842
|
+
failures.push(`mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`);
|
|
843
|
+
}
|
|
844
|
+
checks.version = versionOk;
|
|
845
|
+
// (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
|
|
846
|
+
// computed head, never against its own (possibly forged) enclosed anchor.
|
|
847
|
+
const { expectedAnchor = null, expectedHead = null } = options;
|
|
848
|
+
if (expectedAnchor !== null || expectedHead !== null) {
|
|
849
|
+
const actualSeq = entries.length > 0 ? entries.length - 1 : -1;
|
|
850
|
+
const actualHead = entries.length > 0 ? entries[entries.length - 1]["hash"] : audit_js_1.GENESIS;
|
|
851
|
+
let expectedOk = true;
|
|
852
|
+
if (expectedHead !== null) {
|
|
853
|
+
const [expSeq, expHash] = expectedHead;
|
|
854
|
+
if (actualSeq !== expSeq || actualHead !== expHash) {
|
|
855
|
+
expectedOk = false;
|
|
856
|
+
failures.push(`expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
|
|
857
|
+
`independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (expectedAnchor !== null) {
|
|
861
|
+
const ea = expectedAnchor;
|
|
862
|
+
if ((0, canonical_js_1.toPlain)(ea["seq"]) !== actualSeq ||
|
|
863
|
+
(0, canonical_js_1.toPlain)(ea["head"]) !== actualHead ||
|
|
864
|
+
(0, canonical_js_1.toPlain)(ea["chain_id"]) !== (0, canonical_js_1.toPlain)(bundle.chain_id) ||
|
|
865
|
+
(0, canonical_js_1.toPlain)(ea["v"]) !== bundleV) {
|
|
866
|
+
expectedOk = false;
|
|
867
|
+
failures.push("expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
|
|
868
|
+
"the independently retained expected anchor");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
checks.expected_anchor = expectedOk ? "verified" : "FAILED";
|
|
872
|
+
}
|
|
873
|
+
// (0b) chain identity: the bundle, every entry, and — when an anchor is present — the anchor
|
|
874
|
+
// must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
|
|
875
|
+
// for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
|
|
876
|
+
const bundleChainId = orNull(bundle.chain_id);
|
|
877
|
+
const entriesOk = entries.every((e) => orNull(e["chain_id"]) === bundleChainId);
|
|
878
|
+
if (!entriesOk) {
|
|
879
|
+
failures.push(`chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`);
|
|
880
|
+
}
|
|
881
|
+
const anchorChainId = orNull(anchor["chain_id"]);
|
|
882
|
+
const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
|
|
883
|
+
if (!anchorChainOk) {
|
|
884
|
+
failures.push(`chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
|
|
885
|
+
}
|
|
886
|
+
checks.chain_id = entriesOk && anchorChainOk;
|
|
343
887
|
// (1) integrity: the hash chain, plus the signed anchor when a key is given.
|
|
344
888
|
const [okChain, err] = audit_js_1.AuditLog.verify(entries);
|
|
345
889
|
if (!okChain)
|
|
@@ -394,7 +938,19 @@ function verifyBundle(bundle, signer = null) {
|
|
|
394
938
|
}
|
|
395
939
|
}
|
|
396
940
|
checks.containment = contained;
|
|
397
|
-
const
|
|
941
|
+
const eb = versionOk ? executionBinding(entries, bundleV) : { status: "not applicable" };
|
|
942
|
+
if (eb.failures !== undefined)
|
|
943
|
+
failures.push(...eb.failures);
|
|
944
|
+
// "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
|
|
945
|
+
// ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
|
|
946
|
+
// either already lands its own entry in `failures`, which the `ok` computation still gates on.
|
|
947
|
+
const ok = checks.integrity &&
|
|
948
|
+
checks.monotonicity &&
|
|
949
|
+
checks.containment &&
|
|
950
|
+
checks.version &&
|
|
951
|
+
checks.chain_id &&
|
|
952
|
+
checks.root &&
|
|
953
|
+
failures.length === 0;
|
|
398
954
|
return {
|
|
399
955
|
ok,
|
|
400
956
|
checks,
|
|
@@ -402,6 +958,8 @@ function verifyBundle(bundle, signer = null) {
|
|
|
402
958
|
nodes: auth.size,
|
|
403
959
|
actions_checked: actions,
|
|
404
960
|
chain_id: orNull(bundle.chain_id),
|
|
961
|
+
execution_binding: eb,
|
|
962
|
+
verified_against: expectedAnchor !== null || expectedHead !== null ? "expected_anchor" : "bundle_anchor",
|
|
405
963
|
};
|
|
406
964
|
}
|
|
407
965
|
/** Read a bundle from JSON text, keeping every number's original literal. */
|