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