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,7 +32,7 @@
32
32
  * `attenu_guard.evidence`.
33
33
  */
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
- exports.ENVELOPE_FAILURES = exports.PROCESS_ASSERTED = exports.WITNESS_SIGNED = exports.ENVELOPE_ALG = exports.ENVELOPE_RESULTS = exports.ENVELOPE_SUBJECT_MEMBERS = exports.ENVELOPE_MEMBERS = exports.ENVELOPE_TYP = exports.ENVELOPE_VERSION = exports.SUPPORTED_BUNDLE_VERSIONS = exports.EvidenceLeakError = exports.LEDGER_FIELDS = void 0;
35
+ exports.ENVELOPE_FAILURES = exports.PROCESS_ASSERTED = exports.WITNESS_SIGNED = exports.ENVELOPE_ALG = exports.ENVELOPE_RESULTS = exports.ENVELOPE_SUBJECT_MEMBERS = exports.ENVELOPE_MEMBERS = exports.ENVELOPE_TYP = exports.ENVELOPE_VERSION = exports.SUPPORTED_BUNDLE_VERSIONS = exports.EvidenceLeakError = exports.LEDGER_EVENTS_V1 = exports.LEDGER_EVENTS = exports.LEDGER_FIELDS = void 0;
36
36
  exports.redactionReport = redactionReport;
37
37
  exports.anchorFor = anchorFor;
38
38
  exports.exportBundle = exportBundle;
@@ -117,6 +117,26 @@ exports.LEDGER_FIELDS = new Set([
117
117
  "receipt",
118
118
  "pending_at_kill",
119
119
  ]);
120
+ /**
121
+ * The COMPLETE set of ledger event names, closed: the `event` enum of
122
+ * `schema/agent-audit.schema.json` (in the Python distribution). Every check in `verifyBundle`
123
+ * reads an entry by its event, so an entry whose event none of them reads was skipped by all of
124
+ * them: a `done` renamed `frobnicate` verified, and an `allow` renamed left containment without a
125
+ * word. `verifyBundle` reports such an entry as `unknown_ledger_event`. The Python implementation's
126
+ * `evidence.LEDGER_EVENTS`, name for name.
127
+ */
128
+ exports.LEDGER_EVENTS = new Set([
129
+ "root",
130
+ "spawn",
131
+ "spawn_denied",
132
+ "allow",
133
+ "deny",
134
+ "kill",
135
+ "done",
136
+ "outcome",
137
+ ]);
138
+ /** `outcome` is v2-only (0.9.0, execution binding): a schemaVersion 1 chain has the other seven. */
139
+ exports.LEDGER_EVENTS_V1 = new Set([...exports.LEDGER_EVENTS].filter((event) => event !== "outcome"));
120
140
  /**
121
141
  * Thrown by `exportBundle({strict: true})` when a bundle would carry a field or
122
142
  * context key outside the allow-list — potential customer data the custody
@@ -164,27 +184,60 @@ function redactTask(t) {
164
184
  function redactionReport(entries, contextAllowlist) {
165
185
  const allow = contextAllowlist === undefined || contextAllowlist === null ? null : new Set(contextAllowlist);
166
186
  const violations = [];
167
- entries.forEach((e, i) => {
187
+ // What it cannot read is a violation, never a throw, and never read by projection (a string's
188
+ // characters as fields). The Python implementation's same rule and shapes.
189
+ if (!Array.isArray(entries)) {
190
+ return { ok: false, violations: [{ event_index: null, event: null, entries: (0, ceilings_js_1.jsonKind)(entries) }] };
191
+ }
192
+ for (let i = 0; i < entries.length; i++) {
193
+ const e = entries[i];
194
+ if (!(0, audit_js_1.isLedgerObject)(e)) {
195
+ violations.push({ event_index: i, event: null, entry: (0, ceilings_js_1.jsonKind)(e) });
196
+ continue;
197
+ }
168
198
  for (const f of Object.keys(e)) {
169
199
  if (!exports.LEDGER_FIELDS.has(f)) {
170
- violations.push({ event_index: i, event: (0, canonical_js_1.toPlain)(e["event"]), field: f });
200
+ violations.push({ event_index: i, event: orNull(e["event"]), field: f });
171
201
  }
172
202
  }
173
203
  if (allow !== null) {
174
- const ctx = (e["context"] ?? {});
175
- if (ctx !== null && typeof ctx === "object" && !Array.isArray(ctx)) {
176
- for (const k of Object.keys(ctx)) {
177
- if (!allow.has(k)) {
178
- violations.push({ event_index: i, event: (0, canonical_js_1.toPlain)(e["event"]), context_key: k });
179
- }
204
+ // A context Python counts as false (null, "", 0, false, [] or {}) is no context, as
205
+ // `verifyBundle` reads it; any other context that is not an object is a violation.
206
+ const raw = e["context"];
207
+ if (pyFalsy((0, canonical_js_1.toPlain)(raw)))
208
+ continue;
209
+ if (!(0, audit_js_1.isLedgerObject)(raw)) {
210
+ violations.push({ event_index: i, event: orNull(e["event"]), context: (0, ceilings_js_1.jsonKind)(raw) });
211
+ continue;
212
+ }
213
+ for (const k of Object.keys(raw)) {
214
+ if (!allow.has(k)) {
215
+ violations.push({ event_index: i, event: orNull(e["event"]), context_key: k });
180
216
  }
181
217
  }
182
218
  }
183
- });
219
+ }
184
220
  return { ok: violations.length === 0, violations };
185
221
  }
222
+ /**
223
+ * `entries` as the functions that SIGN them take them: an array of JSON objects. These produce
224
+ * evidence rather than read it, so anything else is refused by name, with a TypeError, before
225
+ * anything is signed over it. Before, a TypeError in the runtime's own words, or a signature over
226
+ * whatever was read. The Python implementation's `export_bundle` takes an `AuditLog`, so it has no
227
+ * such input to refuse.
228
+ */
229
+ function signable(entries) {
230
+ if (!Array.isArray(entries))
231
+ throw new TypeError(`entries is ${(0, ceilings_js_1.jsonKind)(entries)}, not an array`);
232
+ for (let i = 0; i < entries.length; i++) {
233
+ if (!(0, audit_js_1.isLedgerObject)(entries[i]))
234
+ throw new TypeError(`entries[${i}] is ${(0, ceilings_js_1.jsonKind)(entries[i])}, not an object`);
235
+ }
236
+ return entries;
237
+ }
186
238
  /** A signed commitment to the head of `entries` — mirrors `AuditLog.anchor`. */
187
- function anchorFor(entries, signer, ts = 0) {
239
+ function anchorFor(given, signer, ts = 0) {
240
+ const entries = signable(given);
188
241
  let seq;
189
242
  let head;
190
243
  if (entries.length === 0) {
@@ -222,7 +275,7 @@ function exportBundle(auditLog, signer, options = {}) {
222
275
  throw new Error("sign envelopes over the redacted ledger: export with redact_task=True first, then " +
223
276
  "sign_envelope over the exported entries");
224
277
  }
225
- const source = auditLog instanceof audit_js_1.AuditLog ? auditLog.entries : auditLog;
278
+ const source = signable(auditLog instanceof audit_js_1.AuditLog ? auditLog.entries : auditLog);
226
279
  const entries = source.map((e) => ({ ...e }));
227
280
  if (options.redactTask) {
228
281
  for (const e of entries) {
@@ -371,6 +424,45 @@ function monotonicityDetail(child, parent) {
371
424
  // it exists so that such a dimension cannot fail SILENTLY.
372
425
  return "child not narrower than parent";
373
426
  }
427
+ /**
428
+ * Why `value`, a root's `authority` or a spawn's `granted`, cannot be read as an authority by the
429
+ * type of one of its members, or null. The object itself is an object; `scopes` and `constraints`,
430
+ * when present, arrays, and a scope a string; `ttl`, when present and not null, a finite number.
431
+ * `Authority.fromWire` reports the rest, as before: a member it does not read, a scope outside the
432
+ * grammar, a constraint of the wrong shape.
433
+ *
434
+ * `fromWire` read a `ttl` that was not a number as unbounded, so a root `ttl: "x"` verified, and a
435
+ * null `scopes` or `constraints` as empty; the Python implementation raised on the first and on
436
+ * the null ones. The Python implementation's `_authority_wire_error`, message for message.
437
+ */
438
+ function authorityWireError(value, member) {
439
+ if (!(0, audit_js_1.isLedgerObject)(value))
440
+ return `${member} is ${(0, ceilings_js_1.jsonKind)(value)}, not an object`;
441
+ if (Object.keys(value).some((k) => k !== "scopes" && k !== "constraints" && k !== "ttl")) {
442
+ return null; // fromWire names the members it does not read
443
+ }
444
+ if (Object.hasOwn(value, "scopes")) {
445
+ const scopes = value["scopes"];
446
+ if (!Array.isArray(scopes))
447
+ return `scopes is ${(0, ceilings_js_1.jsonKind)(scopes)}, not an array`;
448
+ for (const scope of scopes) {
449
+ if (typeof scope !== "string")
450
+ return `a scope is ${(0, ceilings_js_1.jsonKind)(scope)}, not a string`;
451
+ }
452
+ }
453
+ if (Object.hasOwn(value, "constraints") && !Array.isArray(value["constraints"])) {
454
+ return `constraints is ${(0, ceilings_js_1.jsonKind)(value["constraints"])}, not an array`;
455
+ }
456
+ const ttl = value["ttl"];
457
+ if (ttl !== undefined && ttl !== null) {
458
+ const plain = (0, canonical_js_1.toPlain)(ttl);
459
+ if (typeof plain !== "number")
460
+ return `ttl is ${(0, ceilings_js_1.jsonKind)(ttl)}, not a number`;
461
+ if (!Number.isFinite(plain))
462
+ return "ttl is not a finite number";
463
+ }
464
+ return null;
465
+ }
374
466
  /**
375
467
  * `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
376
468
  * order. No engine state.
@@ -409,6 +501,9 @@ function nodeAuthorities(entries) {
409
501
  definedBy.set(node, e);
410
502
  if (ev === "root") {
411
503
  try {
504
+ const wrong = authorityWireError(e["authority"] ?? null, "authority");
505
+ if (wrong !== null)
506
+ throw new Error(wrong);
412
507
  auth.set(node, authority_js_1.Authority.fromWire(e["authority"] ?? null));
413
508
  }
414
509
  catch (exc) {
@@ -418,6 +513,9 @@ function nodeAuthorities(entries) {
418
513
  else {
419
514
  parent.set(node, orNull(e["parent"]));
420
515
  try {
516
+ const wrong = authorityWireError(e["granted"] ?? null, "granted");
517
+ if (wrong !== null)
518
+ throw new Error(wrong);
421
519
  auth.set(node, authority_js_1.Authority.fromWire(e["granted"] ?? null));
422
520
  }
423
521
  catch (exc) {
@@ -447,7 +545,10 @@ function noteRevoked(entry, revokedAt) {
447
545
  * renders. Derived from the ledger alone.
448
546
  */
449
547
  function delegationGraph(bundle) {
450
- const entries = bundle.entries ?? [];
548
+ // A view, not a verdict: it shows what it can read and throws on nothing. A bundle, or entries,
549
+ // it cannot read is an empty graph, and an entry that is not a JSON object names no node;
550
+ // `verifyBundle` is what reports either.
551
+ const entries = readableEntries(bundle);
451
552
  const { auth, parent } = nodeAuthorities(entries);
452
553
  const meta = {};
453
554
  for (const e of entries) {
@@ -477,9 +578,14 @@ function delegationGraph(bundle) {
477
578
  }
478
579
  else if (ev === "deny" && Object.hasOwn(meta, n)) {
479
580
  meta[n].denies += 1;
480
- // A deny without a disposition is named by its reason.
481
- const d = ((0, canonical_js_1.toPlain)(e["disposition"]) ?? (0, canonical_js_1.toPlain)(e["reason"]) ?? "unstated");
482
- meta[n].denials_by_disposition[d] = (meta[n].denials_by_disposition[d] ?? 0) + 1;
581
+ // A deny without a disposition is named by its reason, read as Python reads
582
+ // `disposition or reason`: an empty disposition (null, "", 0, false, [] or {}) names none.
583
+ // A key is a string; a value of another kind is filed by its printed form.
584
+ const disposition = (0, canonical_js_1.toPlain)(e["disposition"]);
585
+ const reason = (0, canonical_js_1.toPlain)(e["reason"]);
586
+ const picked = !pyFalsy(disposition) ? e["disposition"] : !pyFalsy(reason) ? e["reason"] : "unstated";
587
+ const d = typeof (0, canonical_js_1.toPlain)(picked) === "string" ? (0, canonical_js_1.toPlain)(picked) : (0, display_js_1.shown)(picked);
588
+ setOwn(meta[n].denials_by_disposition, d, (Object.hasOwn(meta[n].denials_by_disposition, d) ? meta[n].denials_by_disposition[d] : 0) + 1);
483
589
  }
484
590
  else if (ev === "done" && Object.hasOwn(meta, n)) {
485
591
  meta[n].complete = true;
@@ -498,7 +604,7 @@ function delegationGraph(bundle) {
498
604
  if (typeof p === "string" && p !== "")
499
605
  edges.push({ parent: p, child });
500
606
  }
501
- return { chain_id: orNull(bundle.chain_id), nodes: meta, edges };
607
+ return { chain_id: (0, audit_js_1.isLedgerObject)(bundle) ? orNull(bundle["chain_id"]) : null, nodes: meta, edges };
502
608
  }
503
609
  /**
504
610
  * Every refusal on the ledger, grouped by (node, tool, scope, disposition, requested) — the rows
@@ -515,12 +621,16 @@ function delegationGraph(bundle) {
515
621
  * larger event of the two.
516
622
  */
517
623
  function denials(bundle) {
518
- const entries = bundle.entries ?? [];
624
+ // A view, not a verdict, like `delegationGraph`: a bundle, or entries, it cannot read has no
625
+ // rows, an entry that is not a JSON object is no refusal, and it throws on nothing.
626
+ const entries = readableEntries(bundle);
519
627
  const agentOf = new Map();
520
628
  for (const e of entries) {
521
629
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
522
- if (ev === "root" || ev === "spawn") {
523
- agentOf.set((0, canonical_js_1.toPlain)(e["node"]), orNull(e["agent"]));
630
+ const node = (0, canonical_js_1.toPlain)(e["node"]);
631
+ // A node id is a string; anything else names no node.
632
+ if ((ev === "root" || ev === "spawn") && typeof node === "string") {
633
+ agentOf.set(node, orNull(e["agent"]));
524
634
  }
525
635
  }
526
636
  const rows = new Map();
@@ -532,19 +642,22 @@ function denials(bundle) {
532
642
  // what was refused), so it is folded onto the node that asked.
533
643
  const node = ev === "spawn_denied" ? orNull(e["parent"]) : orNull(e["node"]);
534
644
  const requested = ev === "spawn_denied" ? orNull(e["agent"]) : null;
535
- const key = JSON.stringify([
536
- node,
537
- orNull(e["tool"]),
538
- orNull(e["scope"]),
539
- orNull(e["disposition"]),
540
- requested,
645
+ // Grouped by the escaped JSON text of the five values, as the Python implementation groups
646
+ // them, which tells `true` from 1 and 1.0 from 1 as written.
647
+ const asWritten = (v) => (v === undefined ? null : v);
648
+ const key = (0, display_js_1.escaped)([
649
+ asWritten(ev === "spawn_denied" ? e["parent"] : e["node"]),
650
+ asWritten(e["tool"]),
651
+ asWritten(e["scope"]),
652
+ asWritten(e["disposition"]),
653
+ ev === "spawn_denied" ? asWritten(e["agent"]) : null,
541
654
  ]);
542
- const seq = (0, canonical_js_1.toPlain)(e["seq"]);
655
+ const seq = orNull(e["seq"]);
543
656
  const existing = rows.get(key);
544
657
  if (existing === undefined) {
545
658
  rows.set(key, {
546
659
  node,
547
- agent: agentOf.get(node) ?? null,
660
+ agent: typeof node === "string" ? (agentOf.get(node) ?? null) : null,
548
661
  tool: orNull(e["tool"]),
549
662
  scope: orNull(e["scope"]),
550
663
  disposition: orNull(e["disposition"]),
@@ -561,7 +674,9 @@ function denials(bundle) {
561
674
  existing.last_seq = seq;
562
675
  }
563
676
  }
564
- return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
677
+ // In the order each row first occurs. Sorting by `first_seq` said the same on a well-formed
678
+ // ledger, and ordered a seq that is not a number by NaN.
679
+ return Array.from(rows.values());
565
680
  }
566
681
  // `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
567
682
  // messages carry. Both implementations report the same failure bytes, so the distinction between
@@ -648,6 +763,10 @@ exports.ENVELOPE_FAILURES = [
648
763
  * implementation, not a second one for envelopes.
649
764
  */
650
765
  function envelopeSigningInput(envelope) {
766
+ // An envelope that is not a JSON object has no members to sign. The Python implementation raises
767
+ // ValueError with the same words.
768
+ if (!(0, audit_js_1.isLedgerObject)(envelope))
769
+ throw new Error(`an envelope is ${(0, ceilings_js_1.jsonKind)(envelope)}, not an object`);
651
770
  const body = {};
652
771
  for (const [k, v] of Object.entries(envelope))
653
772
  if (k !== "sig")
@@ -661,7 +780,12 @@ function envelopeSigningInput(envelope) {
661
780
  * envelope finds the entry it covers through `subjectIndex`.
662
781
  */
663
782
  function stateKey(e, index) {
664
- return String("seq" in e ? orNull((0, canonical_js_1.intOr)(e["seq"])) : index);
783
+ // An entry that is not a JSON object has no seq member, so it is filed by its index; a seq that
784
+ // is a list or an object by its escaped JSON text, as the Python implementation files it.
785
+ if (!(0, audit_js_1.isLedgerObject)(e) || !("seq" in e))
786
+ return String(index);
787
+ const seq = orNull((0, canonical_js_1.intOr)(e["seq"]));
788
+ return seq !== null && typeof seq === "object" ? (0, display_js_1.escaped)(seq) : String(seq);
665
789
  }
666
790
  /**
667
791
  * Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
@@ -808,14 +932,20 @@ function compareVersions(a, b) {
808
932
  * verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
809
933
  * defines no subject for.
810
934
  */
811
- function envelopeSubject(entries, seq) {
935
+ function envelopeSubject(given, seq) {
936
+ // `entries` that are not an array are refused, and an entry that is not a JSON object is read as
937
+ // one with no members, as the verifier reads it. The Python implementation's ValueError, worded
938
+ // the same.
939
+ if (!Array.isArray(given))
940
+ throw new Error(`entries is ${(0, ceilings_js_1.jsonKind)(given)}, not an array`);
941
+ const entries = Array.from(given, (e) => ((0, audit_js_1.isLedgerObject)(e) ? e : {}));
812
942
  const n = (0, canonical_js_1.integral)(seq);
813
943
  const at = n === null ? undefined : subjectIndex(entries).get(n);
814
944
  if (n === null || at === undefined)
815
945
  throw new Error(`no entry at seq ${seq}`);
816
946
  const entry = entries[at];
817
947
  const event = (0, canonical_js_1.toPlain)(entry["event"]);
818
- if (!exports.ENVELOPE_SUBJECT_MEMBERS.has(event)) {
948
+ if (typeof event !== "string" || !exports.ENVELOPE_SUBJECT_MEMBERS.has(event)) {
819
949
  throw new Error(`envelope v${exports.ENVELOPE_VERSION} defines no subject for event '${event}'`);
820
950
  }
821
951
  const subject = {
@@ -1391,7 +1521,30 @@ function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, r
1391
1521
  * one the witness signed is in it.
1392
1522
  */
1393
1523
  function verifyEnvelopes(bundle, options = {}) {
1394
- const entries = bundle.entries ?? [];
1524
+ // A bundle it cannot read, or whose `entries` or `envelopes` it cannot (the anchor is not read
1525
+ // here), is reported, `ok` false and status "not checked", one `invalid_bundle` failure per
1526
+ // member, as `verifyBundle` reports it. An entry that is not a JSON object is read as one with
1527
+ // no members, filed by its index. The Python implementation's same report.
1528
+ const unreadable = unreadableBundle(bundle, ["entries", "envelopes"]);
1529
+ if (unreadable.length > 0) {
1530
+ const log = new FailureLog();
1531
+ for (const detail of unreadable)
1532
+ log.add("invalid_bundle", detail);
1533
+ return {
1534
+ ok: false,
1535
+ status: "not checked",
1536
+ count: 0,
1537
+ witness_signed: [],
1538
+ states: {},
1539
+ results: {},
1540
+ witnesses: {},
1541
+ lines: {},
1542
+ failures: log.messages,
1543
+ failure_details: log.details,
1544
+ failure_entries: unreadable.map(() => null),
1545
+ };
1546
+ }
1547
+ const entries = readableEntries(bundle);
1395
1548
  const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
1396
1549
  return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
1397
1550
  }
@@ -1942,9 +2095,16 @@ function executionBinding(entries, bundleV) {
1942
2095
  * re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
1943
2096
  */
1944
2097
  function integrityBreak(entries) {
2098
+ // `entries` that are not an array are no chain, and no entry is where it breaks.
2099
+ if (!Array.isArray(entries))
2100
+ return null;
1945
2101
  let prev = audit_js_1.GENESIS;
1946
2102
  for (let i = 0; i < entries.length; i++) {
1947
2103
  const e = entries[i];
2104
+ // An entry that is not a JSON object carries no member, so the chain breaks at it, as
2105
+ // `AuditLog.verify` reads it.
2106
+ if (!(0, audit_js_1.isLedgerObject)(e))
2107
+ return i;
1948
2108
  const payload = {};
1949
2109
  for (const [k, v] of Object.entries(e)) {
1950
2110
  if (k !== "hash")
@@ -1967,8 +2127,118 @@ function integrityBreak(entries) {
1967
2127
  }
1968
2128
  return null;
1969
2129
  }
2130
+ /** What each bundle member this module reads has to be, when it is present and not null. */
2131
+ const BUNDLE_MEMBERS = {
2132
+ entries: ["an array", (v) => Array.isArray(v)],
2133
+ anchor: ["an object", (v) => (0, audit_js_1.isLedgerObject)(v)],
2134
+ envelopes: ["an array", (v) => Array.isArray(v)],
2135
+ };
2136
+ /**
2137
+ * The `invalid_bundle` messages for a bundle this module cannot read: one when the bundle is not a
2138
+ * JSON object, or one for each member in `members` that is present, not null, and not the type the
2139
+ * format gives it. Empty when there is nothing of the kind. Reading such a member anyway reads it
2140
+ * by projection (a string's characters as entries) or throws. The Python implementation's
2141
+ * `_unreadable_bundle`.
2142
+ */
2143
+ function unreadableBundle(bundle, members = ["entries", "anchor", "envelopes"]) {
2144
+ if (!(0, audit_js_1.isLedgerObject)(bundle))
2145
+ return [`invalid_bundle: the bundle is ${(0, ceilings_js_1.jsonKind)(bundle)}, not an object`];
2146
+ const out = [];
2147
+ for (const member of members) {
2148
+ const [want, ok] = BUNDLE_MEMBERS[member];
2149
+ const value = bundle[member];
2150
+ if (value !== undefined && value !== null && !ok(value)) {
2151
+ out.push(`invalid_bundle: ${member} is ${(0, ceilings_js_1.jsonKind)(value)}, not ${want}`);
2152
+ }
2153
+ }
2154
+ return out;
2155
+ }
2156
+ /**
2157
+ * A bundle's entries as the readers that never fail read them: none when the bundle or its
2158
+ * `entries` cannot be read (`unreadableBundle`), and an entry that is not a JSON object read as one
2159
+ * with no members, which names no node, no event and no seq. The Python implementation's
2160
+ * `_readable_entries`.
2161
+ */
2162
+ function readableEntries(bundle) {
2163
+ if (!(0, audit_js_1.isLedgerObject)(bundle))
2164
+ return [];
2165
+ const entries = bundle["entries"];
2166
+ if (!Array.isArray(entries))
2167
+ return [];
2168
+ return Array.from(entries, (e) => ((0, audit_js_1.isLedgerObject)(e) ? e : {}));
2169
+ }
2170
+ /**
2171
+ * The report on a bundle this verifier cannot read at all (`unreadableBundle`): one
2172
+ * `invalid_bundle` failure per member it cannot read, each about no single entry; every check
2173
+ * false, and every status `"not checked"`, since none ran. The report has every member a full
2174
+ * verification's has, so a reader of it needs no second shape. The Python implementation's
2175
+ * `_invalid_bundle_report`, member for member.
2176
+ */
2177
+ function invalidBundleReport(bundle, details, verifiedAgainst) {
2178
+ const log = new FailureLog();
2179
+ for (const detail of details)
2180
+ log.add("invalid_bundle", detail);
2181
+ return {
2182
+ ok: false,
2183
+ checks: {
2184
+ integrity: false,
2185
+ monotonicity: false,
2186
+ containment: false,
2187
+ anchor: "not checked",
2188
+ version: false,
2189
+ ledger_fields: false,
2190
+ chain_id: false,
2191
+ root: false,
2192
+ expected_anchor: "not checked",
2193
+ envelopes: "not checked",
2194
+ },
2195
+ failures: log.messages,
2196
+ failure_details: log.details,
2197
+ failure_entries: details.map(() => null),
2198
+ nodes: 0,
2199
+ actions_checked: 0,
2200
+ ungated: 0,
2201
+ chain_id: (0, audit_js_1.isLedgerObject)(bundle) ? orNull(bundle["chain_id"]) : null,
2202
+ execution_binding: { status: "not applicable" },
2203
+ envelopes: {
2204
+ status: "not checked",
2205
+ count: 0,
2206
+ witness_signed: [],
2207
+ states: {},
2208
+ results: {},
2209
+ witnesses: {},
2210
+ lines: {},
2211
+ failures: [],
2212
+ },
2213
+ verified_against: verifiedAgainst,
2214
+ };
2215
+ }
1970
2216
  function verifyBundle(bundle, signer = null, options = {}) {
1971
- const entries = bundle.entries ?? [];
2217
+ const verifiedAgainst = (options.expectedAnchor ?? null) !== null || (options.expectedHead ?? null) !== null
2218
+ ? "expected_anchor"
2219
+ : "bundle_anchor";
2220
+ // A bundle is a JSON object, its `entries` and `envelopes` arrays and its `anchor` an object.
2221
+ // Anything else threw out of this function (a TypeError), whose 0.8.0 note says it never throws,
2222
+ // or was read by projection (a string anchor's characters as its members). Such a bundle is
2223
+ // reported alone, as `invalid_bundle`, once per member it cannot read. An absent or null member
2224
+ // is absent. The Python implementation's same rule, message for message.
2225
+ const given = bundle;
2226
+ const unreadable = unreadableBundle(given);
2227
+ if (unreadable.length > 0 || !(0, audit_js_1.isLedgerObject)(given))
2228
+ return invalidBundleReport(given, unreadable, verifiedAgainst);
2229
+ const rawEntries = (given["entries"] ?? []);
2230
+ // An entry that is not a JSON object is reported once, as `invalid_ledger_entry` in (0b2), and
2231
+ // read everywhere else as an entry with no members: the checks that read an entry's fields skip
2232
+ // it, and the hash chain breaks at it. Each stand-in is a new object, so `failure_entries` finds
2233
+ // its index by identity, as it finds every other entry's. `Array.from` visits a hole as well.
2234
+ const unread = new Set();
2235
+ const entries = Array.from(rawEntries, (e) => {
2236
+ if ((0, audit_js_1.isLedgerObject)(e))
2237
+ return e;
2238
+ const standIn = {};
2239
+ unread.add(standIn);
2240
+ return standIn;
2241
+ });
1972
2242
  const anchor = (bundle.anchor ?? {});
1973
2243
  const anchorPresent = Object.keys(anchor).length > 0;
1974
2244
  const checks = {
@@ -2012,7 +2282,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
2012
2282
  versionOk = false;
2013
2283
  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 });
2014
2284
  }
2015
- const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
2285
+ const mixedEntries = entries.filter((e) => !unread.has(e) && !sameNumber(e["v"], rawV));
2016
2286
  // An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
2017
2287
  // everything else by its repr, compared by code point as Python compares strings, never by locale.
2018
2288
  const mixed = pySetOf(mixedEntries.map((e) => ((0, canonical_js_1.intOr)(e["v"]) ?? null))).sort(compareVersions);
@@ -2038,8 +2308,32 @@ function verifyBundle(bundle, signer = null, options = {}) {
2038
2308
  // is a property of the bundle, not an error in the call.
2039
2309
  //
2040
2310
  // Kept in step with the Python port (`evidence._verify_bundle`, same check, same reason string).
2311
+ //
2312
+ // The same pass reads every entry's shape and event, and reports each defect an entry has. An
2313
+ // entry that is not a JSON object is `invalid_ledger_entry`, chain-level since it has no seq or
2314
+ // node, its index in the message. An event that is absent, not a string, or not one its chain's
2315
+ // version defines (`outcome` is v2-only) is `unknown_ledger_event`: every check below reads an
2316
+ // entry by its event, so such an entry was read by none of them.
2041
2317
  let unknownOk = true;
2042
- for (const e of entries) {
2318
+ const events = bundleV === 1 ? exports.LEDGER_EVENTS_V1 : exports.LEDGER_EVENTS;
2319
+ for (let index = 0; index < entries.length; index++) {
2320
+ const e = entries[index];
2321
+ if (unread.has(e)) {
2322
+ unknownOk = false;
2323
+ log.add("invalid_ledger_entry", `invalid_ledger_entry: entries[${index}] is ${(0, ceilings_js_1.jsonKind)(rawEntries[index])}, not an object`, { entry: e });
2324
+ continue;
2325
+ }
2326
+ const ev = (0, canonical_js_1.toPlain)(e["event"]);
2327
+ if (typeof ev !== "string" || !events.has(ev)) {
2328
+ unknownOk = false;
2329
+ const why = typeof ev !== "string"
2330
+ ? ", which is not a string"
2331
+ : exports.LEDGER_EVENTS.has(ev)
2332
+ ? ", a v2-only event on a schema_version=1 chain"
2333
+ : "";
2334
+ log.add("unknown_ledger_event", `unknown_ledger_event: entry carries an event this verifier does not evaluate and will not ` +
2335
+ `ignore: ${(0, display_js_1.shown)(e["event"])}${why}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
2336
+ }
2043
2337
  const extra = Object.keys(e)
2044
2338
  .filter((f) => !exports.LEDGER_FIELDS.has(f))
2045
2339
  .sort();
@@ -2096,7 +2390,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
2096
2390
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
2097
2391
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
2098
2392
  const bundleChainId = orNull(bundle.chain_id);
2099
- const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
2393
+ const foreign = entries.find((e) => !unread.has(e) && orNull(e["chain_id"]) !== bundleChainId);
2100
2394
  const entriesOk = foreign === undefined;
2101
2395
  if (foreign !== undefined) {
2102
2396
  log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${(0, display_js_1.pyRepr)(bundleChainId)}`, {
@@ -2308,7 +2602,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
2308
2602
  chain_id: orNull(bundle.chain_id),
2309
2603
  execution_binding: eb,
2310
2604
  envelopes: envelopeSummary,
2311
- verified_against: expectedAnchor !== null || expectedHead !== null ? "expected_anchor" : "bundle_anchor",
2605
+ verified_against: verifiedAgainst,
2312
2606
  };
2313
2607
  }
2314
2608
  /** Read a bundle from JSON text, keeping every number's original literal. */