attenu-guard 0.5.0 → 0.7.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.
@@ -18,14 +18,23 @@
18
18
  * const bundle = exportBundle(guard.auditLog(), signer);
19
19
  * const report = verifyBundle(bundle, signer);
20
20
  *
21
+ * `report.failures` is the human-readable list — its strings are a published
22
+ * contract, other implementations parse them — and `report.failure_details` is
23
+ * its machine-readable twin: one entry per string, same order, same count,
24
+ * `{reason, seq, node, call_id, detail}`. It exists so a conformance suite can
25
+ * assert WHICH check failed and WHERE, not merely that something did. The
26
+ * bundle-level interop vectors under `test/fixtures/vectors/bundles/` are scored
27
+ * against exactly that shape.
28
+ *
21
29
  * No engine state is consulted — the bundle is the whole input, which is the
22
30
  * point. This is byte-compatible with the Python library's
23
31
  * `attenu_guard.evidence`.
24
32
  */
25
- import { canonicalBytes, compareCodePoints, parseJson, toPlain, } from "./canonical.js";
33
+ import { canonicalBytes, compareCodePoints, parseJson, pyNumber, toPlain, } from "./canonical.js";
26
34
  import { createHash } from "node:crypto";
27
35
  import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, GENESIS } from "./audit.js";
28
36
  import { Authority } from "./authority.js";
37
+ import { describe as describeCeiling } from "./ceilings.js";
29
38
  import { CAPTURES, BODY_STATES, BodyState, Capture } from "./reasons.js";
30
39
  import { PARAMS_HASH_REASONS } from "./params.js";
31
40
  /**
@@ -217,36 +226,124 @@ function orNull(value) {
217
226
  const plain = toPlain(value);
218
227
  return plain === undefined ? null : plain;
219
228
  }
229
+ /**
230
+ * The verifier's failure list, kept in two shapes that cannot drift apart.
231
+ *
232
+ * `messages` is the string list `verifyBundle` has always returned as
233
+ * `failures`; those exact strings are a published contract, so they are never
234
+ * reworded here. `details` is the structured twin of each one, appended in the
235
+ * same call. Every failure in this module goes through `add`, so a new check
236
+ * cannot add a message without its twin — `test/bundle-vectors.test.ts` greps
237
+ * this file for a direct append to a failure list and fails on one, and asserts
238
+ * the two lists stay in step at every site.
239
+ */
240
+ class FailureLog {
241
+ messages = [];
242
+ details = [];
243
+ add(reason, detail, position = {}) {
244
+ const { seq = null, node = null, callId = null } = position;
245
+ this.messages.push(detail);
246
+ this.details.push({ reason, seq, node, call_id: callId, detail });
247
+ }
248
+ extend(other) {
249
+ this.messages.push(...other.messages);
250
+ this.details.push(...other.details);
251
+ }
252
+ get length() {
253
+ return this.messages.length;
254
+ }
255
+ }
220
256
  /**
221
257
  * `node -> Authority` and `node -> parent`, reconstructed from `root` and
222
258
  * `spawn` events alone. No engine state.
223
259
  */
260
+ /**
261
+ * Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
262
+ *
263
+ * Called only once `Authority.isNarrowerThan` has already returned false, and it walks the
264
+ * dimensions in the ORDER that relation compares them — scopes, then ceilings by key, then ttl
265
+ * — so the message names the dimension that actually failed. Every dimension the relation can
266
+ * fail on has a branch here:
267
+ *
268
+ * scopes a scope the parent does not cover (wildcard-aware);
269
+ * ceilings a key the parent bounds and the child does not (child unbounded there, so MORE
270
+ * powerful), or one the child bounds more loosely than the parent;
271
+ * ttl a child that never expires under a parent that does, or one that outlives it.
272
+ *
273
+ * Reports the FIRST failing dimension: one message per unsound delegation. Byte-identical to
274
+ * the Python `evidence._monotonicity_detail`, since these strings are a published contract that
275
+ * both implementations are scored against.
276
+ */
277
+ function monotonicityDetail(child, parent) {
278
+ // Unchanged since 0.1.0, byte for byte. A scope failure always leaves this list non-empty:
279
+ // a scope literally present in the parent's set is covered by it, so anything the parent
280
+ // does not cover is also absent from that set.
281
+ if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
282
+ const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
283
+ return (`child scopes [${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] ` +
284
+ `not held by parent`);
285
+ }
286
+ const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
287
+ const parentKeys = parent.ceilings.map((c) => String(c.key)).sort(compareCodePoints);
288
+ for (const key of parentKeys) {
289
+ const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
290
+ const childCeiling = childByKey.get(key);
291
+ if (childCeiling === undefined) {
292
+ return `ceiling ${key} unbounded, parent holds ${describeCeiling(parentCeiling)}`;
293
+ }
294
+ if (!parentCeiling.subsumes(childCeiling)) {
295
+ return (`ceiling ${describeCeiling(childCeiling)} looser than parent ` +
296
+ `${describeCeiling(parentCeiling)}`);
297
+ }
298
+ }
299
+ if (parent.ttl !== null) {
300
+ if (child.ttl === null)
301
+ return `ttl unbounded, parent ${pyNumber(parent.ttl)}`;
302
+ if (child.ttl > parent.ttl) {
303
+ return `ttl ${pyNumber(child.ttl)} > parent ${pyNumber(parent.ttl)}`;
304
+ }
305
+ }
306
+ // Only reachable if a future dimension is added to `isNarrowerThan` without a branch here;
307
+ // it exists so that such a dimension cannot fail SILENTLY.
308
+ return "child not narrower than parent";
309
+ }
224
310
  function nodeAuthorities(entries) {
225
311
  const auth = new Map();
226
312
  const parent = new Map();
227
- const failures = [];
313
+ const failures = new FailureLog();
314
+ const definedBy = new Map();
228
315
  for (const e of entries) {
229
316
  const ev = toPlain(e["event"]);
230
317
  const node = toPlain(e["node"]);
231
318
  if (ev === "root") {
319
+ definedBy.set(node, e);
232
320
  try {
233
321
  auth.set(node, Authority.fromWire(e["authority"] ?? null));
234
322
  }
235
323
  catch (exc) {
236
- failures.push(`root ${node}: unreadable authority (${exc.message})`);
324
+ // One of the two historical messages that name a node before their colon rather than a
325
+ // reason token, so the reason is stated here instead of parsed out of the string.
326
+ failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
327
+ seq: orNull(e["seq"]),
328
+ node: orNull(e["node"]),
329
+ });
237
330
  }
238
331
  }
239
332
  else if (ev === "spawn") {
333
+ definedBy.set(node, e);
240
334
  parent.set(node, toPlain(e["parent"]) ?? null);
241
335
  try {
242
336
  auth.set(node, Authority.fromWire(e["granted"] ?? null));
243
337
  }
244
338
  catch (exc) {
245
- failures.push(`spawn ${node}: unreadable granted (${exc.message})`);
339
+ failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
340
+ seq: orNull(e["seq"]),
341
+ node: orNull(e["node"]),
342
+ });
246
343
  }
247
344
  }
248
345
  }
249
- return { auth, parent, failures };
346
+ return { auth, parent, failures, definedBy };
250
347
  }
251
348
  /**
252
349
  * A view of the chain from the bundle: each node with its agent, task,
@@ -585,24 +682,31 @@ const V2_ONLY_FIELDS = [
585
682
  * invalid regardless of which field it is (merge-gate item 4/(c)).
586
683
  */
587
684
  function v2FieldLeaksOnV1(entries) {
588
- const failures = [];
685
+ const failures = new FailureLog();
589
686
  for (const e of entries) {
590
687
  const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
591
688
  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`);
689
+ failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${pyRepr(toPlain(e["seq"]))} event=${pyRepr(toPlain(e["event"]))} ` +
690
+ `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
594
691
  }
595
692
  }
596
693
  return failures;
597
694
  }
695
+ /**
696
+ * `[the execution_binding report, its failures]`. The report's own `failures` key keeps its
697
+ * historical list-of-strings shape — the structured twins ride alongside it rather than inside
698
+ * it, so this sub-report's published shape is unchanged.
699
+ */
598
700
  function executionBinding(entries, bundleV) {
599
701
  if (bundleV === 1) {
600
702
  const leaked = v2FieldLeaksOnV1(entries);
601
- return leaked.length > 0 ? { status: "not applicable", failures: leaked } : { status: "not applicable" };
703
+ return leaked.length > 0
704
+ ? [{ status: "not applicable", failures: leaked.messages }, leaked]
705
+ : [{ status: "not applicable" }, new FailureLog()];
602
706
  }
603
707
  if (bundleV !== 2)
604
- return { status: "not applicable" };
605
- const failures = [];
708
+ return [{ status: "not applicable" }, new FailureLog()];
709
+ const failures = new FailureLog();
606
710
  const seenCallIds = new Map(); // callId -> [event, node, seq]
607
711
  const allows = new Map();
608
712
  const outcomes = new Map();
@@ -618,8 +722,12 @@ function executionBinding(entries, bundleV) {
618
722
  if (node !== null)
619
723
  nodes.add(node);
620
724
  const err = validateRoot(e);
621
- if (err)
622
- failures.push(`invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`);
725
+ if (err) {
726
+ failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
727
+ seq: orNull(e["seq"]),
728
+ node: orNull(e["node"]),
729
+ });
730
+ }
623
731
  }
624
732
  else if (ev === "spawn") {
625
733
  if (node !== null)
@@ -633,8 +741,12 @@ function executionBinding(entries, bundleV) {
633
741
  for (const r of toPlain(e["revoked"]) ?? [])
634
742
  revokedNodes.add(r);
635
743
  const err = validateKill(e);
636
- if (err)
637
- failures.push(`invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`);
744
+ if (err) {
745
+ failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
746
+ seq: orNull(e["seq"]),
747
+ node: orNull(e["node"]),
748
+ });
749
+ }
638
750
  }
639
751
  if (ev === "allow" || ev === "deny") {
640
752
  const cid = toPlain(e["call_id"]);
@@ -642,8 +754,10 @@ function executionBinding(entries, bundleV) {
642
754
  if (cid !== null && cid !== undefined) {
643
755
  const prior = seenCallIds.get(cid);
644
756
  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]})`);
757
+ // Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
758
+ // record, the first one having been legitimate when it was written.
759
+ failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
760
+ `${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
647
761
  }
648
762
  else {
649
763
  seenCallIds.set(cid, [ev, node, seq]);
@@ -651,7 +765,11 @@ function executionBinding(entries, bundleV) {
651
765
  }
652
766
  const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
653
767
  if (err) {
654
- failures.push(`invalid_${ev}: ${err} (seq ${pyRepr(seq)})`);
768
+ failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
769
+ seq: orNull(e["seq"]),
770
+ node: orNull(e["node"]),
771
+ callId: cid ?? null,
772
+ });
655
773
  if (ev === "allow" && cid !== null && cid !== undefined)
656
774
  invalidAllowIds.add(cid);
657
775
  continue;
@@ -664,12 +782,16 @@ function executionBinding(entries, bundleV) {
664
782
  const seq = toPlain(e["seq"]);
665
783
  const err = validateOutcome(e);
666
784
  if (err) {
667
- failures.push(`invalid_outcome: ${err} (seq ${pyRepr(seq)})`);
785
+ failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
786
+ seq: orNull(e["seq"]),
787
+ node: orNull(e["node"]),
788
+ callId: cid ?? null,
789
+ });
668
790
  continue;
669
791
  }
670
792
  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"]))})`);
793
+ failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
794
+ `${pyRepr(toPlain(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
673
795
  continue;
674
796
  }
675
797
  if (cid !== null)
@@ -683,29 +805,32 @@ function executionBinding(entries, bundleV) {
683
805
  // its recorded content disagrees with what was authorized (spec: "parameter equality is
684
806
  // established only for calls where both hashes are present; elsewhere only identity and order
685
807
  // binding was checked" — params_mismatch is that separate concern).
808
+ // Every failure in this loop is about a PAIR, and is positioned on the `outcome` entry: the
809
+ // allow was a complete, valid record when it was written, and it is the outcome that fails to
810
+ // bind to it (or reports different arguments than were authorized).
686
811
  const boundOk = new Set();
687
812
  for (const [cid, oc] of outcomes) {
688
813
  const allowE = allows.get(cid);
689
814
  if (allowE === undefined) {
690
- failures.push(`outcome_without_allow: call_id ${cid} at seq ${pyRepr(toPlain(oc["seq"]))} has no allow in this chain`);
815
+ failures.add("outcome_without_allow", `outcome_without_allow: call_id ${cid} at seq ${pyRepr(toPlain(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
691
816
  continue;
692
817
  }
693
818
  const nodeOk = toPlain(allowE["node"]) === toPlain(oc["node"]);
694
819
  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"]))}`);
820
+ failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr(toPlain(allowE["node"]))} but ` +
821
+ `outcome on node ${pyRepr(toPlain(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
697
822
  }
698
823
  const ocSeq = toPlain(oc["seq"]);
699
824
  const allowSeq = toPlain(allowE["seq"]);
700
825
  const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
701
826
  if (!orderOk) {
702
- failures.push(`outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
703
- `after allow seq ${pyRepr(allowSeq ?? null)}`);
827
+ failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
828
+ `after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
704
829
  }
705
830
  const ah = toPlain(allowE["authorized_params_hash"]);
706
831
  const ih = toPlain(oc["invoked_params_hash"]);
707
832
  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}`);
833
+ failures.add("params_mismatch", `params_mismatch: call_id ${cid} authorized_params_hash ${ah} != invoked_params_hash ${ih}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
709
834
  }
710
835
  if (nodeOk && orderOk)
711
836
  boundOk.add(cid);
@@ -775,13 +900,52 @@ function executionBinding(entries, bundleV) {
775
900
  }
776
901
  if (Object.values(perCall).some((s) => s === "unobserved"))
777
902
  escalate("incomplete");
778
- return {
779
- aggregate,
780
- params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
781
- per_call: perCall,
782
- per_node_lifecycle: lifecycle,
903
+ return [
904
+ {
905
+ aggregate,
906
+ params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
907
+ per_call: perCall,
908
+ per_node_lifecycle: lifecycle,
909
+ failures: failures.messages,
910
+ },
783
911
  failures,
784
- };
912
+ ];
913
+ }
914
+ /**
915
+ * `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
916
+ *
917
+ * `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
918
+ * module reports; this walk exists so the structured twin of that message can say WHERE, which
919
+ * the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
920
+ * exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
921
+ * consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
922
+ * chain-level.
923
+ */
924
+ function integrityPosition(entries) {
925
+ let prev = GENESIS;
926
+ for (let i = 0; i < entries.length; i++) {
927
+ const e = entries[i];
928
+ const payload = {};
929
+ for (const [k, v] of Object.entries(e)) {
930
+ if (k !== "hash")
931
+ payload[k] = v;
932
+ }
933
+ let broken;
934
+ try {
935
+ broken =
936
+ orNull(e["seq"]) !== i ||
937
+ orNull(payload["prev_hash"]) !== prev ||
938
+ hashEntry(prev, payload) !== orNull(e["hash"]);
939
+ }
940
+ catch {
941
+ // An unhashable payload is itself the break, at this entry.
942
+ return [orNull(e["seq"]), orNull(e["node"])];
943
+ }
944
+ if (broken)
945
+ return [orNull(e["seq"]), orNull(e["node"])];
946
+ prev = orNull(e["hash"]);
947
+ }
948
+ return [null, null];
785
949
  }
786
950
  export function verifyBundle(bundle, signer = null, options = {}) {
787
951
  const entries = bundle.entries ?? [];
@@ -797,38 +961,41 @@ export function verifyBundle(bundle, signer = null, options = {}) {
797
961
  root: false,
798
962
  expected_anchor: "not checked",
799
963
  };
800
- const failures = [];
964
+ const log = new FailureLog();
801
965
  // (0) version: the bundle must declare a schema version this build understands, and — when
802
966
  // an anchor is present — the anchor must be anchoring THAT version, not a different one.
803
967
  const bundleV = toPlain(bundle.v);
804
968
  let versionOk = typeof bundleV === "number" && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
805
969
  if (!versionOk) {
806
970
  const supported = Array.from(SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
807
- failures.push(`unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
971
+ log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
808
972
  }
809
973
  const anchorV = toPlain(anchor["v"]);
810
974
  if (anchorPresent && anchorV !== bundleV) {
811
975
  versionOk = false;
812
- failures.push(`anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
976
+ log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
813
977
  }
814
978
  // (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
815
979
  // sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
816
980
  const rootEvents = entries.filter((e) => toPlain(e["event"]) === "root");
817
981
  checks.root = rootEvents.length === 1;
818
982
  if (!checks.root) {
819
- failures.push(`missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
983
+ log.add("missing_root", `missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
820
984
  }
821
985
  const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
822
986
  // 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
823
987
  // entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
824
988
  if (rootEntry !== undefined && toPlain(rootEntry["v"]) !== bundleV) {
825
989
  versionOk = false;
826
- failures.push(`root_version_mismatch: root v=${pyRepr(toPlain(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`);
990
+ log.add("root_version_mismatch", `root_version_mismatch: root v=${pyRepr(toPlain(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]) });
827
991
  }
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))));
992
+ const mixedEntries = entries.filter((e) => toPlain(e["v"]) !== bundleV);
993
+ const mixed = Array.from(new Set(mixedEntries.map((e) => toPlain(e["v"])))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
829
994
  if (mixed.length > 0) {
830
995
  versionOk = false;
831
- failures.push(`mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`);
996
+ // One aggregate message over every offending entry (unchanged); the twin is positioned on
997
+ // the first of them, which is where a reader looks.
998
+ log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]) });
832
999
  }
833
1000
  checks.version = versionOk;
834
1001
  // (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
@@ -842,7 +1009,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
842
1009
  const [expSeq, expHash] = expectedHead;
843
1010
  if (actualSeq !== expSeq || actualHead !== expHash) {
844
1011
  expectedOk = false;
845
- failures.push(`expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
1012
+ log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
846
1013
  `independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
847
1014
  }
848
1015
  }
@@ -853,7 +1020,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
853
1020
  toPlain(ea["chain_id"]) !== toPlain(bundle.chain_id) ||
854
1021
  toPlain(ea["v"]) !== bundleV) {
855
1022
  expectedOk = false;
856
- failures.push("expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
1023
+ log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
857
1024
  "the independently retained expected anchor");
858
1025
  }
859
1026
  }
@@ -863,32 +1030,40 @@ export function verifyBundle(bundle, signer = null, options = {}) {
863
1030
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
864
1031
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
865
1032
  const bundleChainId = orNull(bundle.chain_id);
866
- const entriesOk = entries.every((e) => orNull(e["chain_id"]) === bundleChainId);
867
- if (!entriesOk) {
868
- failures.push(`chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`);
1033
+ const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
1034
+ const entriesOk = foreign === undefined;
1035
+ if (foreign !== undefined) {
1036
+ log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
1037
+ seq: orNull(foreign["seq"]),
1038
+ node: orNull(foreign["node"]),
1039
+ });
869
1040
  }
870
1041
  const anchorChainId = orNull(anchor["chain_id"]);
871
1042
  const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
872
1043
  if (!anchorChainOk) {
873
- failures.push(`chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
1044
+ log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
874
1045
  }
875
1046
  checks.chain_id = entriesOk && anchorChainOk;
876
1047
  // (1) integrity: the hash chain, plus the signed anchor when a key is given.
877
1048
  const [okChain, err] = AuditLog.verify(entries);
878
- if (!okChain)
879
- failures.push(`integrity: ${err}`);
1049
+ if (!okChain) {
1050
+ const [badSeq, badNode] = integrityPosition(entries);
1051
+ log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
1052
+ }
880
1053
  if (signer !== null) {
881
1054
  const [okAnchor, aerr] = AuditLog.verifyAnchor(entries, anchor, signer);
882
1055
  checks.anchor = okAnchor ? "verified" : "FAILED";
1056
+ // Chain-level by construction: the anchor commits to the head of the WHOLE ledger, so a
1057
+ // consistently re-hashed chain has no single offending entry to point at.
883
1058
  if (!okAnchor)
884
- failures.push(`integrity(anchor): ${aerr}`);
1059
+ log.add("integrity(anchor)", `integrity(anchor): ${aerr}`);
885
1060
  checks.integrity = okChain && okAnchor;
886
1061
  }
887
1062
  else {
888
1063
  checks.integrity = okChain;
889
1064
  }
890
- const { auth, parent, failures: afail } = nodeAuthorities(entries);
891
- failures.push(...afail);
1065
+ const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
1066
+ log.extend(afail);
892
1067
  // (2) monotonicity: every child ⊆ its parent.
893
1068
  let mono = true;
894
1069
  for (const [node, pid] of parent) {
@@ -896,11 +1071,14 @@ export function verifyBundle(bundle, signer = null, options = {}) {
896
1071
  continue;
897
1072
  const child = auth.get(node);
898
1073
  const p = auth.get(pid);
899
- const extra = Array.from(child.scopes).filter((s) => !p.scopes.has(s));
900
- if (!child.isNarrowerThan(p) && extra.length > 0) {
1074
+ // 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
1075
+ // non-wildcard-aware scope difference, which silently accepted a delegation that widened
1076
+ // only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
1077
+ // the parent's — the child was more powerful and the bundle verified clean.
1078
+ if (!child.isNarrowerThan(p)) {
901
1079
  mono = false;
902
- failures.push(`monotonicity: ${node} not ⊆ parent ${pid} (child scopes ` +
903
- `[${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] not held by parent)`);
1080
+ const spawnE = definedBy.get(node);
1081
+ log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (${monotonicityDetail(child, p)})`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
904
1082
  }
905
1083
  }
906
1084
  checks.monotonicity = mono && afail.length === 0;
@@ -917,19 +1095,25 @@ export function verifyBundle(bundle, signer = null, options = {}) {
917
1095
  const a = auth.get(node);
918
1096
  if (a === undefined) {
919
1097
  contained = false;
920
- failures.push(`containment: allow on unknown node ${node}`);
1098
+ log.add("containment", `containment: allow on unknown node ${node}`, {
1099
+ seq: orNull(e["seq"]),
1100
+ node: orNull(e["node"]),
1101
+ callId: orNull(e["call_id"]),
1102
+ });
921
1103
  continue;
922
1104
  }
923
1105
  if (!a.permits(scope, ctx).allowed) {
924
1106
  contained = false;
925
- failures.push(`containment: allow of '${scope}' on ${node} outside its authority ` +
926
- `[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`);
1107
+ log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
1108
+ `[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]) });
927
1109
  }
928
1110
  }
929
1111
  checks.containment = contained;
930
- const eb = versionOk ? executionBinding(entries, bundleV) : { status: "not applicable" };
931
- if (eb.failures !== undefined)
932
- failures.push(...eb.failures);
1112
+ const [eb, ebFailures] = versionOk
1113
+ ? executionBinding(entries, bundleV)
1114
+ : [{ status: "not applicable" }, new FailureLog()];
1115
+ if (eb.failures !== undefined && eb.failures.length > 0)
1116
+ log.extend(ebFailures);
933
1117
  // "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
934
1118
  // ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
935
1119
  // either already lands its own entry in `failures`, which the `ok` computation still gates on.
@@ -939,11 +1123,12 @@ export function verifyBundle(bundle, signer = null, options = {}) {
939
1123
  checks.version &&
940
1124
  checks.chain_id &&
941
1125
  checks.root &&
942
- failures.length === 0;
1126
+ log.length === 0;
943
1127
  return {
944
1128
  ok,
945
1129
  checks,
946
- failures,
1130
+ failures: log.messages,
1131
+ failure_details: log.details,
947
1132
  nodes: auth.size,
948
1133
  actions_checked: actions,
949
1134
  chain_id: orNull(bundle.chain_id),