attenu-guard 0.13.1 → 0.13.2

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 (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/cjs/audit.d.ts +15 -1
  3. package/dist/cjs/audit.d.ts.map +1 -1
  4. package/dist/cjs/audit.js +26 -3
  5. package/dist/cjs/audit.js.map +1 -1
  6. package/dist/cjs/ceilings.d.ts +7 -0
  7. package/dist/cjs/ceilings.d.ts.map +1 -1
  8. package/dist/cjs/ceilings.js +1 -0
  9. package/dist/cjs/ceilings.js.map +1 -1
  10. package/dist/cjs/cli.d.ts.map +1 -1
  11. package/dist/cjs/cli.js +11 -4
  12. package/dist/cjs/cli.js.map +1 -1
  13. package/dist/cjs/evidence.d.ts +35 -9
  14. package/dist/cjs/evidence.d.ts.map +1 -1
  15. package/dist/cjs/evidence.js +332 -38
  16. package/dist/cjs/evidence.js.map +1 -1
  17. package/dist/cjs/index.d.ts +1 -1
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +3 -1
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/version.d.ts +1 -1
  22. package/dist/cjs/version.js +1 -1
  23. package/dist/esm/audit.d.ts +15 -1
  24. package/dist/esm/audit.d.ts.map +1 -1
  25. package/dist/esm/audit.js +26 -4
  26. package/dist/esm/audit.js.map +1 -1
  27. package/dist/esm/ceilings.d.ts +7 -0
  28. package/dist/esm/ceilings.d.ts.map +1 -1
  29. package/dist/esm/ceilings.js +1 -1
  30. package/dist/esm/ceilings.js.map +1 -1
  31. package/dist/esm/cli.d.ts.map +1 -1
  32. package/dist/esm/cli.js +12 -5
  33. package/dist/esm/cli.js.map +1 -1
  34. package/dist/esm/evidence.d.ts +35 -9
  35. package/dist/esm/evidence.d.ts.map +1 -1
  36. package/dist/esm/evidence.js +334 -40
  37. package/dist/esm/evidence.js.map +1 -1
  38. package/dist/esm/index.d.ts +1 -1
  39. package/dist/esm/index.d.ts.map +1 -1
  40. package/dist/esm/index.js +1 -1
  41. package/dist/esm/index.js.map +1 -1
  42. package/dist/esm/version.d.ts +1 -1
  43. package/dist/esm/version.js +1 -1
  44. package/package.json +1 -1
@@ -32,10 +32,10 @@
32
32
  */
33
33
  import { canonicalBytes, compareCodePoints, intOr, integral, parseJson, pyNumber, RawNumber, sortedStrings, toPlain, } from "./canonical.js";
34
34
  import { createHash } from "node:crypto";
35
- import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, GENESIS } from "./audit.js";
35
+ import { AuditLog, SCHEMA_VERSION, chainIdOf, hashEntry, isLedgerObject, GENESIS, } from "./audit.js";
36
36
  import { Authority } from "./authority.js";
37
- import { describeInFinding, sameType, wireKeyOf } from "./ceilings.js";
38
- import { pyRepr, pyStr, pyStrRepr, shown } from "./display.js";
37
+ import { describeInFinding, jsonKind, sameType, wireKeyOf } from "./ceilings.js";
38
+ import { escaped, pyRepr, pyStr, pyStrRepr, shown } from "./display.js";
39
39
  import { CAPTURES, BODY_STATES, POLICIES, BodyState, Capture } from "./reasons.js";
40
40
  import { PARAMS_HASH_REASONS } from "./params.js";
41
41
  import { Ed25519Signer, Ed25519Verifier } from "./wire.js";
@@ -100,6 +100,26 @@ export const LEDGER_FIELDS = new Set([
100
100
  "receipt",
101
101
  "pending_at_kill",
102
102
  ]);
103
+ /**
104
+ * The COMPLETE set of ledger event names, closed: the `event` enum of
105
+ * `schema/agent-audit.schema.json` (in the Python distribution). Every check in `verifyBundle`
106
+ * reads an entry by its event, so an entry whose event none of them reads was skipped by all of
107
+ * them: a `done` renamed `frobnicate` verified, and an `allow` renamed left containment without a
108
+ * word. `verifyBundle` reports such an entry as `unknown_ledger_event`. The Python implementation's
109
+ * `evidence.LEDGER_EVENTS`, name for name.
110
+ */
111
+ export const LEDGER_EVENTS = new Set([
112
+ "root",
113
+ "spawn",
114
+ "spawn_denied",
115
+ "allow",
116
+ "deny",
117
+ "kill",
118
+ "done",
119
+ "outcome",
120
+ ]);
121
+ /** `outcome` is v2-only (0.9.0, execution binding): a schemaVersion 1 chain has the other seven. */
122
+ export const LEDGER_EVENTS_V1 = new Set([...LEDGER_EVENTS].filter((event) => event !== "outcome"));
103
123
  /**
104
124
  * Thrown by `exportBundle({strict: true})` when a bundle would carry a field or
105
125
  * context key outside the allow-list — potential customer data the custody
@@ -146,27 +166,60 @@ function redactTask(t) {
146
166
  export function redactionReport(entries, contextAllowlist) {
147
167
  const allow = contextAllowlist === undefined || contextAllowlist === null ? null : new Set(contextAllowlist);
148
168
  const violations = [];
149
- entries.forEach((e, i) => {
169
+ // What it cannot read is a violation, never a throw, and never read by projection (a string's
170
+ // characters as fields). The Python implementation's same rule and shapes.
171
+ if (!Array.isArray(entries)) {
172
+ return { ok: false, violations: [{ event_index: null, event: null, entries: jsonKind(entries) }] };
173
+ }
174
+ for (let i = 0; i < entries.length; i++) {
175
+ const e = entries[i];
176
+ if (!isLedgerObject(e)) {
177
+ violations.push({ event_index: i, event: null, entry: jsonKind(e) });
178
+ continue;
179
+ }
150
180
  for (const f of Object.keys(e)) {
151
181
  if (!LEDGER_FIELDS.has(f)) {
152
- violations.push({ event_index: i, event: toPlain(e["event"]), field: f });
182
+ violations.push({ event_index: i, event: orNull(e["event"]), field: f });
153
183
  }
154
184
  }
155
185
  if (allow !== null) {
156
- const ctx = (e["context"] ?? {});
157
- if (ctx !== null && typeof ctx === "object" && !Array.isArray(ctx)) {
158
- for (const k of Object.keys(ctx)) {
159
- if (!allow.has(k)) {
160
- violations.push({ event_index: i, event: toPlain(e["event"]), context_key: k });
161
- }
186
+ // A context Python counts as false (null, "", 0, false, [] or {}) is no context, as
187
+ // `verifyBundle` reads it; any other context that is not an object is a violation.
188
+ const raw = e["context"];
189
+ if (pyFalsy(toPlain(raw)))
190
+ continue;
191
+ if (!isLedgerObject(raw)) {
192
+ violations.push({ event_index: i, event: orNull(e["event"]), context: jsonKind(raw) });
193
+ continue;
194
+ }
195
+ for (const k of Object.keys(raw)) {
196
+ if (!allow.has(k)) {
197
+ violations.push({ event_index: i, event: orNull(e["event"]), context_key: k });
162
198
  }
163
199
  }
164
200
  }
165
- });
201
+ }
166
202
  return { ok: violations.length === 0, violations };
167
203
  }
204
+ /**
205
+ * `entries` as the functions that SIGN them take them: an array of JSON objects. These produce
206
+ * evidence rather than read it, so anything else is refused by name, with a TypeError, before
207
+ * anything is signed over it. Before, a TypeError in the runtime's own words, or a signature over
208
+ * whatever was read. The Python implementation's `export_bundle` takes an `AuditLog`, so it has no
209
+ * such input to refuse.
210
+ */
211
+ function signable(entries) {
212
+ if (!Array.isArray(entries))
213
+ throw new TypeError(`entries is ${jsonKind(entries)}, not an array`);
214
+ for (let i = 0; i < entries.length; i++) {
215
+ if (!isLedgerObject(entries[i]))
216
+ throw new TypeError(`entries[${i}] is ${jsonKind(entries[i])}, not an object`);
217
+ }
218
+ return entries;
219
+ }
168
220
  /** A signed commitment to the head of `entries` — mirrors `AuditLog.anchor`. */
169
- export function anchorFor(entries, signer, ts = 0) {
221
+ export function anchorFor(given, signer, ts = 0) {
222
+ const entries = signable(given);
170
223
  let seq;
171
224
  let head;
172
225
  if (entries.length === 0) {
@@ -204,7 +257,7 @@ export function exportBundle(auditLog, signer, options = {}) {
204
257
  throw new Error("sign envelopes over the redacted ledger: export with redact_task=True first, then " +
205
258
  "sign_envelope over the exported entries");
206
259
  }
207
- const source = auditLog instanceof AuditLog ? auditLog.entries : auditLog;
260
+ const source = signable(auditLog instanceof AuditLog ? auditLog.entries : auditLog);
208
261
  const entries = source.map((e) => ({ ...e }));
209
262
  if (options.redactTask) {
210
263
  for (const e of entries) {
@@ -353,6 +406,45 @@ function monotonicityDetail(child, parent) {
353
406
  // it exists so that such a dimension cannot fail SILENTLY.
354
407
  return "child not narrower than parent";
355
408
  }
409
+ /**
410
+ * Why `value`, a root's `authority` or a spawn's `granted`, cannot be read as an authority by the
411
+ * type of one of its members, or null. The object itself is an object; `scopes` and `constraints`,
412
+ * when present, arrays, and a scope a string; `ttl`, when present and not null, a finite number.
413
+ * `Authority.fromWire` reports the rest, as before: a member it does not read, a scope outside the
414
+ * grammar, a constraint of the wrong shape.
415
+ *
416
+ * `fromWire` read a `ttl` that was not a number as unbounded, so a root `ttl: "x"` verified, and a
417
+ * null `scopes` or `constraints` as empty; the Python implementation raised on the first and on
418
+ * the null ones. The Python implementation's `_authority_wire_error`, message for message.
419
+ */
420
+ function authorityWireError(value, member) {
421
+ if (!isLedgerObject(value))
422
+ return `${member} is ${jsonKind(value)}, not an object`;
423
+ if (Object.keys(value).some((k) => k !== "scopes" && k !== "constraints" && k !== "ttl")) {
424
+ return null; // fromWire names the members it does not read
425
+ }
426
+ if (Object.hasOwn(value, "scopes")) {
427
+ const scopes = value["scopes"];
428
+ if (!Array.isArray(scopes))
429
+ return `scopes is ${jsonKind(scopes)}, not an array`;
430
+ for (const scope of scopes) {
431
+ if (typeof scope !== "string")
432
+ return `a scope is ${jsonKind(scope)}, not a string`;
433
+ }
434
+ }
435
+ if (Object.hasOwn(value, "constraints") && !Array.isArray(value["constraints"])) {
436
+ return `constraints is ${jsonKind(value["constraints"])}, not an array`;
437
+ }
438
+ const ttl = value["ttl"];
439
+ if (ttl !== undefined && ttl !== null) {
440
+ const plain = toPlain(ttl);
441
+ if (typeof plain !== "number")
442
+ return `ttl is ${jsonKind(ttl)}, not a number`;
443
+ if (!Number.isFinite(plain))
444
+ return "ttl is not a finite number";
445
+ }
446
+ return null;
447
+ }
356
448
  /**
357
449
  * `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
358
450
  * order. No engine state.
@@ -391,6 +483,9 @@ function nodeAuthorities(entries) {
391
483
  definedBy.set(node, e);
392
484
  if (ev === "root") {
393
485
  try {
486
+ const wrong = authorityWireError(e["authority"] ?? null, "authority");
487
+ if (wrong !== null)
488
+ throw new Error(wrong);
394
489
  auth.set(node, Authority.fromWire(e["authority"] ?? null));
395
490
  }
396
491
  catch (exc) {
@@ -400,6 +495,9 @@ function nodeAuthorities(entries) {
400
495
  else {
401
496
  parent.set(node, orNull(e["parent"]));
402
497
  try {
498
+ const wrong = authorityWireError(e["granted"] ?? null, "granted");
499
+ if (wrong !== null)
500
+ throw new Error(wrong);
403
501
  auth.set(node, Authority.fromWire(e["granted"] ?? null));
404
502
  }
405
503
  catch (exc) {
@@ -429,7 +527,10 @@ function noteRevoked(entry, revokedAt) {
429
527
  * renders. Derived from the ledger alone.
430
528
  */
431
529
  export function delegationGraph(bundle) {
432
- const entries = bundle.entries ?? [];
530
+ // A view, not a verdict: it shows what it can read and throws on nothing. A bundle, or entries,
531
+ // it cannot read is an empty graph, and an entry that is not a JSON object names no node;
532
+ // `verifyBundle` is what reports either.
533
+ const entries = readableEntries(bundle);
433
534
  const { auth, parent } = nodeAuthorities(entries);
434
535
  const meta = {};
435
536
  for (const e of entries) {
@@ -459,9 +560,14 @@ export function delegationGraph(bundle) {
459
560
  }
460
561
  else if (ev === "deny" && Object.hasOwn(meta, n)) {
461
562
  meta[n].denies += 1;
462
- // A deny without a disposition is named by its reason.
463
- const d = (toPlain(e["disposition"]) ?? toPlain(e["reason"]) ?? "unstated");
464
- meta[n].denials_by_disposition[d] = (meta[n].denials_by_disposition[d] ?? 0) + 1;
563
+ // A deny without a disposition is named by its reason, read as Python reads
564
+ // `disposition or reason`: an empty disposition (null, "", 0, false, [] or {}) names none.
565
+ // A key is a string; a value of another kind is filed by its printed form.
566
+ const disposition = toPlain(e["disposition"]);
567
+ const reason = toPlain(e["reason"]);
568
+ const picked = !pyFalsy(disposition) ? e["disposition"] : !pyFalsy(reason) ? e["reason"] : "unstated";
569
+ const d = typeof toPlain(picked) === "string" ? toPlain(picked) : shown(picked);
570
+ setOwn(meta[n].denials_by_disposition, d, (Object.hasOwn(meta[n].denials_by_disposition, d) ? meta[n].denials_by_disposition[d] : 0) + 1);
465
571
  }
466
572
  else if (ev === "done" && Object.hasOwn(meta, n)) {
467
573
  meta[n].complete = true;
@@ -480,7 +586,7 @@ export function delegationGraph(bundle) {
480
586
  if (typeof p === "string" && p !== "")
481
587
  edges.push({ parent: p, child });
482
588
  }
483
- return { chain_id: orNull(bundle.chain_id), nodes: meta, edges };
589
+ return { chain_id: isLedgerObject(bundle) ? orNull(bundle["chain_id"]) : null, nodes: meta, edges };
484
590
  }
485
591
  /**
486
592
  * Every refusal on the ledger, grouped by (node, tool, scope, disposition, requested) — the rows
@@ -497,12 +603,16 @@ export function delegationGraph(bundle) {
497
603
  * larger event of the two.
498
604
  */
499
605
  export function denials(bundle) {
500
- const entries = bundle.entries ?? [];
606
+ // A view, not a verdict, like `delegationGraph`: a bundle, or entries, it cannot read has no
607
+ // rows, an entry that is not a JSON object is no refusal, and it throws on nothing.
608
+ const entries = readableEntries(bundle);
501
609
  const agentOf = new Map();
502
610
  for (const e of entries) {
503
611
  const ev = toPlain(e["event"]);
504
- if (ev === "root" || ev === "spawn") {
505
- agentOf.set(toPlain(e["node"]), orNull(e["agent"]));
612
+ const node = toPlain(e["node"]);
613
+ // A node id is a string; anything else names no node.
614
+ if ((ev === "root" || ev === "spawn") && typeof node === "string") {
615
+ agentOf.set(node, orNull(e["agent"]));
506
616
  }
507
617
  }
508
618
  const rows = new Map();
@@ -514,19 +624,22 @@ export function denials(bundle) {
514
624
  // what was refused), so it is folded onto the node that asked.
515
625
  const node = ev === "spawn_denied" ? orNull(e["parent"]) : orNull(e["node"]);
516
626
  const requested = ev === "spawn_denied" ? orNull(e["agent"]) : null;
517
- const key = JSON.stringify([
518
- node,
519
- orNull(e["tool"]),
520
- orNull(e["scope"]),
521
- orNull(e["disposition"]),
522
- requested,
627
+ // Grouped by the escaped JSON text of the five values, as the Python implementation groups
628
+ // them, which tells `true` from 1 and 1.0 from 1 as written.
629
+ const asWritten = (v) => (v === undefined ? null : v);
630
+ const key = escaped([
631
+ asWritten(ev === "spawn_denied" ? e["parent"] : e["node"]),
632
+ asWritten(e["tool"]),
633
+ asWritten(e["scope"]),
634
+ asWritten(e["disposition"]),
635
+ ev === "spawn_denied" ? asWritten(e["agent"]) : null,
523
636
  ]);
524
- const seq = toPlain(e["seq"]);
637
+ const seq = orNull(e["seq"]);
525
638
  const existing = rows.get(key);
526
639
  if (existing === undefined) {
527
640
  rows.set(key, {
528
641
  node,
529
- agent: agentOf.get(node) ?? null,
642
+ agent: typeof node === "string" ? (agentOf.get(node) ?? null) : null,
530
643
  tool: orNull(e["tool"]),
531
644
  scope: orNull(e["scope"]),
532
645
  disposition: orNull(e["disposition"]),
@@ -543,7 +656,9 @@ export function denials(bundle) {
543
656
  existing.last_seq = seq;
544
657
  }
545
658
  }
546
- return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
659
+ // In the order each row first occurs. Sorting by `first_seq` said the same on a well-formed
660
+ // ledger, and ordered a seq that is not a number by NaN.
661
+ return Array.from(rows.values());
547
662
  }
548
663
  // `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
549
664
  // messages carry. Both implementations report the same failure bytes, so the distinction between
@@ -630,6 +745,10 @@ export const ENVELOPE_FAILURES = [
630
745
  * implementation, not a second one for envelopes.
631
746
  */
632
747
  export function envelopeSigningInput(envelope) {
748
+ // An envelope that is not a JSON object has no members to sign. The Python implementation raises
749
+ // ValueError with the same words.
750
+ if (!isLedgerObject(envelope))
751
+ throw new Error(`an envelope is ${jsonKind(envelope)}, not an object`);
633
752
  const body = {};
634
753
  for (const [k, v] of Object.entries(envelope))
635
754
  if (k !== "sig")
@@ -643,7 +762,12 @@ export function envelopeSigningInput(envelope) {
643
762
  * envelope finds the entry it covers through `subjectIndex`.
644
763
  */
645
764
  export function stateKey(e, index) {
646
- return String("seq" in e ? orNull(intOr(e["seq"])) : index);
765
+ // An entry that is not a JSON object has no seq member, so it is filed by its index; a seq that
766
+ // is a list or an object by its escaped JSON text, as the Python implementation files it.
767
+ if (!isLedgerObject(e) || !("seq" in e))
768
+ return String(index);
769
+ const seq = orNull(intOr(e["seq"]));
770
+ return seq !== null && typeof seq === "object" ? escaped(seq) : String(seq);
647
771
  }
648
772
  /**
649
773
  * Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
@@ -790,14 +914,20 @@ function compareVersions(a, b) {
790
914
  * verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
791
915
  * defines no subject for.
792
916
  */
793
- export function envelopeSubject(entries, seq) {
917
+ export function envelopeSubject(given, seq) {
918
+ // `entries` that are not an array are refused, and an entry that is not a JSON object is read as
919
+ // one with no members, as the verifier reads it. The Python implementation's ValueError, worded
920
+ // the same.
921
+ if (!Array.isArray(given))
922
+ throw new Error(`entries is ${jsonKind(given)}, not an array`);
923
+ const entries = Array.from(given, (e) => (isLedgerObject(e) ? e : {}));
794
924
  const n = integral(seq);
795
925
  const at = n === null ? undefined : subjectIndex(entries).get(n);
796
926
  if (n === null || at === undefined)
797
927
  throw new Error(`no entry at seq ${seq}`);
798
928
  const entry = entries[at];
799
929
  const event = toPlain(entry["event"]);
800
- if (!ENVELOPE_SUBJECT_MEMBERS.has(event)) {
930
+ if (typeof event !== "string" || !ENVELOPE_SUBJECT_MEMBERS.has(event)) {
801
931
  throw new Error(`envelope v${ENVELOPE_VERSION} defines no subject for event '${event}'`);
802
932
  }
803
933
  const subject = {
@@ -1373,7 +1503,30 @@ function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, r
1373
1503
  * one the witness signed is in it.
1374
1504
  */
1375
1505
  export function verifyEnvelopes(bundle, options = {}) {
1376
- const entries = bundle.entries ?? [];
1506
+ // A bundle it cannot read, or whose `entries` or `envelopes` it cannot (the anchor is not read
1507
+ // here), is reported, `ok` false and status "not checked", one `invalid_bundle` failure per
1508
+ // member, as `verifyBundle` reports it. An entry that is not a JSON object is read as one with
1509
+ // no members, filed by its index. The Python implementation's same report.
1510
+ const unreadable = unreadableBundle(bundle, ["entries", "envelopes"]);
1511
+ if (unreadable.length > 0) {
1512
+ const log = new FailureLog();
1513
+ for (const detail of unreadable)
1514
+ log.add("invalid_bundle", detail);
1515
+ return {
1516
+ ok: false,
1517
+ status: "not checked",
1518
+ count: 0,
1519
+ witness_signed: [],
1520
+ states: {},
1521
+ results: {},
1522
+ witnesses: {},
1523
+ lines: {},
1524
+ failures: log.messages,
1525
+ failure_details: log.details,
1526
+ failure_entries: unreadable.map(() => null),
1527
+ };
1528
+ }
1529
+ const entries = readableEntries(bundle);
1377
1530
  const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
1378
1531
  return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
1379
1532
  }
@@ -1924,9 +2077,16 @@ function executionBinding(entries, bundleV) {
1924
2077
  * re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
1925
2078
  */
1926
2079
  export function integrityBreak(entries) {
2080
+ // `entries` that are not an array are no chain, and no entry is where it breaks.
2081
+ if (!Array.isArray(entries))
2082
+ return null;
1927
2083
  let prev = GENESIS;
1928
2084
  for (let i = 0; i < entries.length; i++) {
1929
2085
  const e = entries[i];
2086
+ // An entry that is not a JSON object carries no member, so the chain breaks at it, as
2087
+ // `AuditLog.verify` reads it.
2088
+ if (!isLedgerObject(e))
2089
+ return i;
1930
2090
  const payload = {};
1931
2091
  for (const [k, v] of Object.entries(e)) {
1932
2092
  if (k !== "hash")
@@ -1949,8 +2109,118 @@ export function integrityBreak(entries) {
1949
2109
  }
1950
2110
  return null;
1951
2111
  }
2112
+ /** What each bundle member this module reads has to be, when it is present and not null. */
2113
+ const BUNDLE_MEMBERS = {
2114
+ entries: ["an array", (v) => Array.isArray(v)],
2115
+ anchor: ["an object", (v) => isLedgerObject(v)],
2116
+ envelopes: ["an array", (v) => Array.isArray(v)],
2117
+ };
2118
+ /**
2119
+ * The `invalid_bundle` messages for a bundle this module cannot read: one when the bundle is not a
2120
+ * JSON object, or one for each member in `members` that is present, not null, and not the type the
2121
+ * format gives it. Empty when there is nothing of the kind. Reading such a member anyway reads it
2122
+ * by projection (a string's characters as entries) or throws. The Python implementation's
2123
+ * `_unreadable_bundle`.
2124
+ */
2125
+ function unreadableBundle(bundle, members = ["entries", "anchor", "envelopes"]) {
2126
+ if (!isLedgerObject(bundle))
2127
+ return [`invalid_bundle: the bundle is ${jsonKind(bundle)}, not an object`];
2128
+ const out = [];
2129
+ for (const member of members) {
2130
+ const [want, ok] = BUNDLE_MEMBERS[member];
2131
+ const value = bundle[member];
2132
+ if (value !== undefined && value !== null && !ok(value)) {
2133
+ out.push(`invalid_bundle: ${member} is ${jsonKind(value)}, not ${want}`);
2134
+ }
2135
+ }
2136
+ return out;
2137
+ }
2138
+ /**
2139
+ * A bundle's entries as the readers that never fail read them: none when the bundle or its
2140
+ * `entries` cannot be read (`unreadableBundle`), and an entry that is not a JSON object read as one
2141
+ * with no members, which names no node, no event and no seq. The Python implementation's
2142
+ * `_readable_entries`.
2143
+ */
2144
+ function readableEntries(bundle) {
2145
+ if (!isLedgerObject(bundle))
2146
+ return [];
2147
+ const entries = bundle["entries"];
2148
+ if (!Array.isArray(entries))
2149
+ return [];
2150
+ return Array.from(entries, (e) => (isLedgerObject(e) ? e : {}));
2151
+ }
2152
+ /**
2153
+ * The report on a bundle this verifier cannot read at all (`unreadableBundle`): one
2154
+ * `invalid_bundle` failure per member it cannot read, each about no single entry; every check
2155
+ * false, and every status `"not checked"`, since none ran. The report has every member a full
2156
+ * verification's has, so a reader of it needs no second shape. The Python implementation's
2157
+ * `_invalid_bundle_report`, member for member.
2158
+ */
2159
+ function invalidBundleReport(bundle, details, verifiedAgainst) {
2160
+ const log = new FailureLog();
2161
+ for (const detail of details)
2162
+ log.add("invalid_bundle", detail);
2163
+ return {
2164
+ ok: false,
2165
+ checks: {
2166
+ integrity: false,
2167
+ monotonicity: false,
2168
+ containment: false,
2169
+ anchor: "not checked",
2170
+ version: false,
2171
+ ledger_fields: false,
2172
+ chain_id: false,
2173
+ root: false,
2174
+ expected_anchor: "not checked",
2175
+ envelopes: "not checked",
2176
+ },
2177
+ failures: log.messages,
2178
+ failure_details: log.details,
2179
+ failure_entries: details.map(() => null),
2180
+ nodes: 0,
2181
+ actions_checked: 0,
2182
+ ungated: 0,
2183
+ chain_id: isLedgerObject(bundle) ? orNull(bundle["chain_id"]) : null,
2184
+ execution_binding: { status: "not applicable" },
2185
+ envelopes: {
2186
+ status: "not checked",
2187
+ count: 0,
2188
+ witness_signed: [],
2189
+ states: {},
2190
+ results: {},
2191
+ witnesses: {},
2192
+ lines: {},
2193
+ failures: [],
2194
+ },
2195
+ verified_against: verifiedAgainst,
2196
+ };
2197
+ }
1952
2198
  export function verifyBundle(bundle, signer = null, options = {}) {
1953
- const entries = bundle.entries ?? [];
2199
+ const verifiedAgainst = (options.expectedAnchor ?? null) !== null || (options.expectedHead ?? null) !== null
2200
+ ? "expected_anchor"
2201
+ : "bundle_anchor";
2202
+ // A bundle is a JSON object, its `entries` and `envelopes` arrays and its `anchor` an object.
2203
+ // Anything else threw out of this function (a TypeError), whose 0.8.0 note says it never throws,
2204
+ // or was read by projection (a string anchor's characters as its members). Such a bundle is
2205
+ // reported alone, as `invalid_bundle`, once per member it cannot read. An absent or null member
2206
+ // is absent. The Python implementation's same rule, message for message.
2207
+ const given = bundle;
2208
+ const unreadable = unreadableBundle(given);
2209
+ if (unreadable.length > 0 || !isLedgerObject(given))
2210
+ return invalidBundleReport(given, unreadable, verifiedAgainst);
2211
+ const rawEntries = (given["entries"] ?? []);
2212
+ // An entry that is not a JSON object is reported once, as `invalid_ledger_entry` in (0b2), and
2213
+ // read everywhere else as an entry with no members: the checks that read an entry's fields skip
2214
+ // it, and the hash chain breaks at it. Each stand-in is a new object, so `failure_entries` finds
2215
+ // its index by identity, as it finds every other entry's. `Array.from` visits a hole as well.
2216
+ const unread = new Set();
2217
+ const entries = Array.from(rawEntries, (e) => {
2218
+ if (isLedgerObject(e))
2219
+ return e;
2220
+ const standIn = {};
2221
+ unread.add(standIn);
2222
+ return standIn;
2223
+ });
1954
2224
  const anchor = (bundle.anchor ?? {});
1955
2225
  const anchorPresent = Object.keys(anchor).length > 0;
1956
2226
  const checks = {
@@ -1994,7 +2264,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
1994
2264
  versionOk = false;
1995
2265
  log.add("root_version_mismatch", `root_version_mismatch: root v=${reprIntOr(rootEntry["v"])} != bundle v=${reprIntOr(rawV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]), entry: rootEntry });
1996
2266
  }
1997
- const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
2267
+ const mixedEntries = entries.filter((e) => !unread.has(e) && !sameNumber(e["v"], rawV));
1998
2268
  // An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
1999
2269
  // everything else by its repr, compared by code point as Python compares strings, never by locale.
2000
2270
  const mixed = pySetOf(mixedEntries.map((e) => (intOr(e["v"]) ?? null))).sort(compareVersions);
@@ -2020,8 +2290,32 @@ export function verifyBundle(bundle, signer = null, options = {}) {
2020
2290
  // is a property of the bundle, not an error in the call.
2021
2291
  //
2022
2292
  // Kept in step with the Python port (`evidence._verify_bundle`, same check, same reason string).
2293
+ //
2294
+ // The same pass reads every entry's shape and event, and reports each defect an entry has. An
2295
+ // entry that is not a JSON object is `invalid_ledger_entry`, chain-level since it has no seq or
2296
+ // node, its index in the message. An event that is absent, not a string, or not one its chain's
2297
+ // version defines (`outcome` is v2-only) is `unknown_ledger_event`: every check below reads an
2298
+ // entry by its event, so such an entry was read by none of them.
2023
2299
  let unknownOk = true;
2024
- for (const e of entries) {
2300
+ const events = bundleV === 1 ? LEDGER_EVENTS_V1 : LEDGER_EVENTS;
2301
+ for (let index = 0; index < entries.length; index++) {
2302
+ const e = entries[index];
2303
+ if (unread.has(e)) {
2304
+ unknownOk = false;
2305
+ log.add("invalid_ledger_entry", `invalid_ledger_entry: entries[${index}] is ${jsonKind(rawEntries[index])}, not an object`, { entry: e });
2306
+ continue;
2307
+ }
2308
+ const ev = toPlain(e["event"]);
2309
+ if (typeof ev !== "string" || !events.has(ev)) {
2310
+ unknownOk = false;
2311
+ const why = typeof ev !== "string"
2312
+ ? ", which is not a string"
2313
+ : LEDGER_EVENTS.has(ev)
2314
+ ? ", a v2-only event on a schema_version=1 chain"
2315
+ : "";
2316
+ log.add("unknown_ledger_event", `unknown_ledger_event: entry carries an event this verifier does not evaluate and will not ` +
2317
+ `ignore: ${shown(e["event"])}${why}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
2318
+ }
2025
2319
  const extra = Object.keys(e)
2026
2320
  .filter((f) => !LEDGER_FIELDS.has(f))
2027
2321
  .sort();
@@ -2078,7 +2372,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
2078
2372
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
2079
2373
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
2080
2374
  const bundleChainId = orNull(bundle.chain_id);
2081
- const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
2375
+ const foreign = entries.find((e) => !unread.has(e) && orNull(e["chain_id"]) !== bundleChainId);
2082
2376
  const entriesOk = foreign === undefined;
2083
2377
  if (foreign !== undefined) {
2084
2378
  log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
@@ -2290,7 +2584,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
2290
2584
  chain_id: orNull(bundle.chain_id),
2291
2585
  execution_binding: eb,
2292
2586
  envelopes: envelopeSummary,
2293
- verified_against: expectedAnchor !== null || expectedHead !== null ? "expected_anchor" : "bundle_anchor",
2587
+ verified_against: verifiedAgainst,
2294
2588
  };
2295
2589
  }
2296
2590
  /** Read a bundle from JSON text, keeping every number's original literal. */