attenu-guard 0.3.1 → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cjs/adapters/langgraph.d.ts +27 -3
  3. package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
  4. package/dist/cjs/adapters/langgraph.js +290 -11
  5. package/dist/cjs/adapters/langgraph.js.map +1 -1
  6. package/dist/cjs/audit.d.ts +38 -1
  7. package/dist/cjs/audit.d.ts.map +1 -1
  8. package/dist/cjs/audit.js +52 -8
  9. package/dist/cjs/audit.js.map +1 -1
  10. package/dist/cjs/chain.d.ts +32 -0
  11. package/dist/cjs/chain.d.ts.map +1 -1
  12. package/dist/cjs/chain.js +0 -0
  13. package/dist/cjs/chain.js.map +1 -1
  14. package/dist/cjs/evidence.d.ts +38 -3
  15. package/dist/cjs/evidence.d.ts.map +1 -1
  16. package/dist/cjs/evidence.js +523 -13
  17. package/dist/cjs/evidence.js.map +1 -1
  18. package/dist/cjs/guard.d.ts +163 -9
  19. package/dist/cjs/guard.d.ts.map +1 -1
  20. package/dist/cjs/guard.js +426 -26
  21. package/dist/cjs/guard.js.map +1 -1
  22. package/dist/cjs/index.d.ts +10 -7
  23. package/dist/cjs/index.d.ts.map +1 -1
  24. package/dist/cjs/index.js +21 -5
  25. package/dist/cjs/index.js.map +1 -1
  26. package/dist/cjs/params.d.ts +52 -0
  27. package/dist/cjs/params.d.ts.map +1 -0
  28. package/dist/cjs/params.js +97 -0
  29. package/dist/cjs/params.js.map +1 -0
  30. package/dist/cjs/reasons.d.ts +89 -1
  31. package/dist/cjs/reasons.d.ts.map +1 -1
  32. package/dist/cjs/reasons.js +99 -3
  33. package/dist/cjs/reasons.js.map +1 -1
  34. package/dist/cjs/version.d.ts +10 -0
  35. package/dist/cjs/version.d.ts.map +1 -0
  36. package/dist/cjs/version.js +13 -0
  37. package/dist/cjs/version.js.map +1 -0
  38. package/dist/esm/adapters/langgraph.d.ts +27 -3
  39. package/dist/esm/adapters/langgraph.d.ts.map +1 -1
  40. package/dist/esm/adapters/langgraph.js +290 -11
  41. package/dist/esm/adapters/langgraph.js.map +1 -1
  42. package/dist/esm/audit.d.ts +38 -1
  43. package/dist/esm/audit.d.ts.map +1 -1
  44. package/dist/esm/audit.js +51 -8
  45. package/dist/esm/audit.js.map +1 -1
  46. package/dist/esm/chain.d.ts +32 -0
  47. package/dist/esm/chain.d.ts.map +1 -1
  48. package/dist/esm/chain.js +0 -0
  49. package/dist/esm/chain.js.map +1 -1
  50. package/dist/esm/evidence.d.ts +38 -3
  51. package/dist/esm/evidence.d.ts.map +1 -1
  52. package/dist/esm/evidence.js +523 -13
  53. package/dist/esm/evidence.js.map +1 -1
  54. package/dist/esm/guard.d.ts +163 -9
  55. package/dist/esm/guard.d.ts.map +1 -1
  56. package/dist/esm/guard.js +393 -27
  57. package/dist/esm/guard.js.map +1 -1
  58. package/dist/esm/index.d.ts +10 -7
  59. package/dist/esm/index.d.ts.map +1 -1
  60. package/dist/esm/index.js +6 -4
  61. package/dist/esm/index.js.map +1 -1
  62. package/dist/esm/params.d.ts +52 -0
  63. package/dist/esm/params.d.ts.map +1 -0
  64. package/dist/esm/params.js +92 -0
  65. package/dist/esm/params.js.map +1 -0
  66. package/dist/esm/reasons.d.ts +89 -1
  67. package/dist/esm/reasons.d.ts.map +1 -1
  68. package/dist/esm/reasons.js +97 -2
  69. package/dist/esm/reasons.js.map +1 -1
  70. package/dist/esm/version.d.ts +10 -0
  71. package/dist/esm/version.d.ts.map +1 -0
  72. package/dist/esm/version.js +10 -0
  73. package/dist/esm/version.js.map +1 -0
  74. package/package.json +1 -1
@@ -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,8 +99,25 @@ class EvidenceLeakError extends Error {
84
99
  }
85
100
  }
86
101
  exports.EvidenceLeakError = EvidenceLeakError;
87
- /** Bundle schema versions this build knows how to verify. */
88
- exports.SUPPORTED_BUNDLE_VERSIONS = new Set([audit_js_1.SCHEMA_VERSION]);
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
+ }
89
121
  function redactTask(t) {
90
122
  if (t === undefined || t === null || t === "" || t === 0 || t === false)
91
123
  return t;
@@ -135,7 +167,7 @@ function anchorFor(entries, signer, ts = 0) {
135
167
  seq = typeof rawSeq === "number" ? rawSeq : entries.length - 1;
136
168
  head = last["hash"];
137
169
  }
138
- const body = { v: audit_js_1.SCHEMA_VERSION, c14n: "JCS", chain_id: (0, audit_js_1.chainIdOf)(entries), seq, head, ts };
170
+ const body = { v: bundleVersion(entries), c14n: "JCS", chain_id: (0, audit_js_1.chainIdOf)(entries), seq, head, ts };
139
171
  return { ...body, kid: signer.kid ?? null, sig: signer.sign((0, canonical_js_1.canonicalBytes)(body)).toString("hex") };
140
172
  }
141
173
  /**
@@ -177,7 +209,7 @@ function exportBundle(auditLog, signer, options = {}) {
177
209
  const anchor = anchorFor(entries, signer, options.ts ?? 0);
178
210
  anchor.verified = audit_js_1.AuditLog.verifyAnchor(entries, anchor, signer)[0];
179
211
  return {
180
- v: audit_js_1.SCHEMA_VERSION,
212
+ v: bundleVersion(entries),
181
213
  c14n: "JCS",
182
214
  chain_id: (0, audit_js_1.chainIdOf)(entries),
183
215
  entries,
@@ -332,17 +364,437 @@ function pyRepr(value) {
332
364
  return `'${value}'`;
333
365
  return JSON.stringify(value);
334
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}$/;
335
374
  /**
336
- * Verify integrity, monotonicity and containment from the bundle alone.
337
- *
338
- * `signer` is the verifier for the bundle's signed anchor a public key, or
339
- * the test signer. Without one the hash chain, monotonicity and containment are
340
- * still checked but the anchor signature is NOT; the report says so
341
- * (`checks.anchor === "not checked"`), and `ok` then means "consistent,
342
- * unverified by key": a consistent full rewrite by someone holding the key
343
- * cannot be excluded without the key.
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).
344
531
  */
345
- function verifyBundle(bundle, signer = null) {
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.
579
+ */
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 = {}) {
346
798
  const entries = bundle.entries ?? [];
347
799
  const anchor = (bundle.anchor ?? {});
348
800
  const anchorPresent = Object.keys(anchor).length > 0;
@@ -353,6 +805,8 @@ function verifyBundle(bundle, signer = null) {
353
805
  anchor: "not checked",
354
806
  version: false,
355
807
  chain_id: false,
808
+ root: false,
809
+ expected_anchor: "not checked",
356
810
  };
357
811
  const failures = [];
358
812
  // (0) version: the bundle must declare a schema version this build understands, and — when
@@ -368,7 +822,54 @@ function verifyBundle(bundle, signer = null) {
368
822
  versionOk = false;
369
823
  failures.push(`anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
370
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
+ }
371
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
+ }
372
873
  // (0b) chain identity: the bundle, every entry, and — when an anchor is present — the anchor
373
874
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
374
875
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
@@ -437,11 +938,18 @@ function verifyBundle(bundle, signer = null) {
437
938
  }
438
939
  }
439
940
  checks.containment = contained;
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.
440
947
  const ok = checks.integrity &&
441
948
  checks.monotonicity &&
442
949
  checks.containment &&
443
950
  checks.version &&
444
951
  checks.chain_id &&
952
+ checks.root &&
445
953
  failures.length === 0;
446
954
  return {
447
955
  ok,
@@ -450,6 +958,8 @@ function verifyBundle(bundle, signer = null) {
450
958
  nodes: auth.size,
451
959
  actions_checked: actions,
452
960
  chain_id: orNull(bundle.chain_id),
961
+ execution_binding: eb,
962
+ verified_against: expectedAnchor !== null || expectedHead !== null ? "expected_anchor" : "bundle_anchor",
453
963
  };
454
964
  }
455
965
  /** Read a bundle from JSON text, keeping every number's original literal. */