attenu-guard 0.4.0 → 0.6.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.
- package/CHANGELOG.md +269 -1
- package/README.md +11 -0
- package/dist/cjs/adapters/langgraph.d.ts +187 -0
- package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
- package/dist/cjs/adapters/langgraph.js +316 -19
- package/dist/cjs/adapters/langgraph.js.map +1 -1
- package/dist/cjs/evidence.d.ts +34 -0
- package/dist/cjs/evidence.d.ts.map +1 -1
- package/dist/cjs/evidence.js +192 -60
- package/dist/cjs/evidence.js.map +1 -1
- package/dist/cjs/guard.d.ts.map +1 -1
- package/dist/cjs/guard.js +21 -3
- package/dist/cjs/guard.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/adapters/langgraph.d.ts +187 -0
- package/dist/esm/adapters/langgraph.d.ts.map +1 -1
- package/dist/esm/adapters/langgraph.js +314 -18
- package/dist/esm/adapters/langgraph.js.map +1 -1
- package/dist/esm/evidence.d.ts +34 -0
- package/dist/esm/evidence.d.ts.map +1 -1
- package/dist/esm/evidence.js +192 -60
- package/dist/esm/evidence.js.map +1 -1
- package/dist/esm/guard.d.ts.map +1 -1
- package/dist/esm/guard.js +21 -3
- package/dist/esm/guard.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/esm/evidence.js
CHANGED
|
@@ -18,6 +18,14 @@
|
|
|
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`.
|
|
@@ -217,6 +225,33 @@ function orNull(value) {
|
|
|
217
225
|
const plain = toPlain(value);
|
|
218
226
|
return plain === undefined ? null : plain;
|
|
219
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* The verifier's failure list, kept in two shapes that cannot drift apart.
|
|
230
|
+
*
|
|
231
|
+
* `messages` is the string list `verifyBundle` has always returned as
|
|
232
|
+
* `failures`; those exact strings are a published contract, so they are never
|
|
233
|
+
* reworded here. `details` is the structured twin of each one, appended in the
|
|
234
|
+
* same call. Every failure in this module goes through `add`, so a new check
|
|
235
|
+
* cannot add a message without its twin — `test/bundle-vectors.test.ts` greps
|
|
236
|
+
* this file for a direct append to a failure list and fails on one, and asserts
|
|
237
|
+
* the two lists stay in step at every site.
|
|
238
|
+
*/
|
|
239
|
+
class FailureLog {
|
|
240
|
+
messages = [];
|
|
241
|
+
details = [];
|
|
242
|
+
add(reason, detail, position = {}) {
|
|
243
|
+
const { seq = null, node = null, callId = null } = position;
|
|
244
|
+
this.messages.push(detail);
|
|
245
|
+
this.details.push({ reason, seq, node, call_id: callId, detail });
|
|
246
|
+
}
|
|
247
|
+
extend(other) {
|
|
248
|
+
this.messages.push(...other.messages);
|
|
249
|
+
this.details.push(...other.details);
|
|
250
|
+
}
|
|
251
|
+
get length() {
|
|
252
|
+
return this.messages.length;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
220
255
|
/**
|
|
221
256
|
* `node -> Authority` and `node -> parent`, reconstructed from `root` and
|
|
222
257
|
* `spawn` events alone. No engine state.
|
|
@@ -224,29 +259,40 @@ function orNull(value) {
|
|
|
224
259
|
function nodeAuthorities(entries) {
|
|
225
260
|
const auth = new Map();
|
|
226
261
|
const parent = new Map();
|
|
227
|
-
const failures =
|
|
262
|
+
const failures = new FailureLog();
|
|
263
|
+
const definedBy = new Map();
|
|
228
264
|
for (const e of entries) {
|
|
229
265
|
const ev = toPlain(e["event"]);
|
|
230
266
|
const node = toPlain(e["node"]);
|
|
231
267
|
if (ev === "root") {
|
|
268
|
+
definedBy.set(node, e);
|
|
232
269
|
try {
|
|
233
270
|
auth.set(node, Authority.fromWire(e["authority"] ?? null));
|
|
234
271
|
}
|
|
235
272
|
catch (exc) {
|
|
236
|
-
|
|
273
|
+
// One of the two historical messages that name a node before their colon rather than a
|
|
274
|
+
// reason token, so the reason is stated here instead of parsed out of the string.
|
|
275
|
+
failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
|
|
276
|
+
seq: orNull(e["seq"]),
|
|
277
|
+
node: orNull(e["node"]),
|
|
278
|
+
});
|
|
237
279
|
}
|
|
238
280
|
}
|
|
239
281
|
else if (ev === "spawn") {
|
|
282
|
+
definedBy.set(node, e);
|
|
240
283
|
parent.set(node, toPlain(e["parent"]) ?? null);
|
|
241
284
|
try {
|
|
242
285
|
auth.set(node, Authority.fromWire(e["granted"] ?? null));
|
|
243
286
|
}
|
|
244
287
|
catch (exc) {
|
|
245
|
-
failures.
|
|
288
|
+
failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
|
|
289
|
+
seq: orNull(e["seq"]),
|
|
290
|
+
node: orNull(e["node"]),
|
|
291
|
+
});
|
|
246
292
|
}
|
|
247
293
|
}
|
|
248
294
|
}
|
|
249
|
-
return { auth, parent, failures };
|
|
295
|
+
return { auth, parent, failures, definedBy };
|
|
250
296
|
}
|
|
251
297
|
/**
|
|
252
298
|
* A view of the chain from the bundle: each node with its agent, task,
|
|
@@ -585,24 +631,31 @@ const V2_ONLY_FIELDS = [
|
|
|
585
631
|
* invalid regardless of which field it is (merge-gate item 4/(c)).
|
|
586
632
|
*/
|
|
587
633
|
function v2FieldLeaksOnV1(entries) {
|
|
588
|
-
const failures =
|
|
634
|
+
const failures = new FailureLog();
|
|
589
635
|
for (const e of entries) {
|
|
590
636
|
const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
|
|
591
637
|
if (leaked.length > 0) {
|
|
592
|
-
failures.
|
|
593
|
-
`carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry
|
|
638
|
+
failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${pyRepr(toPlain(e["seq"]))} event=${pyRepr(toPlain(e["event"]))} ` +
|
|
639
|
+
`carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
|
|
594
640
|
}
|
|
595
641
|
}
|
|
596
642
|
return failures;
|
|
597
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* `[the execution_binding report, its failures]`. The report's own `failures` key keeps its
|
|
646
|
+
* historical list-of-strings shape — the structured twins ride alongside it rather than inside
|
|
647
|
+
* it, so this sub-report's published shape is unchanged.
|
|
648
|
+
*/
|
|
598
649
|
function executionBinding(entries, bundleV) {
|
|
599
650
|
if (bundleV === 1) {
|
|
600
651
|
const leaked = v2FieldLeaksOnV1(entries);
|
|
601
|
-
return leaked.length > 0
|
|
652
|
+
return leaked.length > 0
|
|
653
|
+
? [{ status: "not applicable", failures: leaked.messages }, leaked]
|
|
654
|
+
: [{ status: "not applicable" }, new FailureLog()];
|
|
602
655
|
}
|
|
603
656
|
if (bundleV !== 2)
|
|
604
|
-
return { status: "not applicable" };
|
|
605
|
-
const failures =
|
|
657
|
+
return [{ status: "not applicable" }, new FailureLog()];
|
|
658
|
+
const failures = new FailureLog();
|
|
606
659
|
const seenCallIds = new Map(); // callId -> [event, node, seq]
|
|
607
660
|
const allows = new Map();
|
|
608
661
|
const outcomes = new Map();
|
|
@@ -618,8 +671,12 @@ function executionBinding(entries, bundleV) {
|
|
|
618
671
|
if (node !== null)
|
|
619
672
|
nodes.add(node);
|
|
620
673
|
const err = validateRoot(e);
|
|
621
|
-
if (err)
|
|
622
|
-
failures.
|
|
674
|
+
if (err) {
|
|
675
|
+
failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
|
|
676
|
+
seq: orNull(e["seq"]),
|
|
677
|
+
node: orNull(e["node"]),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
623
680
|
}
|
|
624
681
|
else if (ev === "spawn") {
|
|
625
682
|
if (node !== null)
|
|
@@ -633,8 +690,12 @@ function executionBinding(entries, bundleV) {
|
|
|
633
690
|
for (const r of toPlain(e["revoked"]) ?? [])
|
|
634
691
|
revokedNodes.add(r);
|
|
635
692
|
const err = validateKill(e);
|
|
636
|
-
if (err)
|
|
637
|
-
failures.
|
|
693
|
+
if (err) {
|
|
694
|
+
failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
|
|
695
|
+
seq: orNull(e["seq"]),
|
|
696
|
+
node: orNull(e["node"]),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
638
699
|
}
|
|
639
700
|
if (ev === "allow" || ev === "deny") {
|
|
640
701
|
const cid = toPlain(e["call_id"]);
|
|
@@ -642,8 +703,10 @@ function executionBinding(entries, bundleV) {
|
|
|
642
703
|
if (cid !== null && cid !== undefined) {
|
|
643
704
|
const prior = seenCallIds.get(cid);
|
|
644
705
|
if (prior !== undefined) {
|
|
645
|
-
|
|
646
|
-
|
|
706
|
+
// Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
|
|
707
|
+
// record, the first one having been legitimate when it was written.
|
|
708
|
+
failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
|
|
709
|
+
`${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
|
|
647
710
|
}
|
|
648
711
|
else {
|
|
649
712
|
seenCallIds.set(cid, [ev, node, seq]);
|
|
@@ -651,7 +714,11 @@ function executionBinding(entries, bundleV) {
|
|
|
651
714
|
}
|
|
652
715
|
const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
|
|
653
716
|
if (err) {
|
|
654
|
-
failures.
|
|
717
|
+
failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
|
|
718
|
+
seq: orNull(e["seq"]),
|
|
719
|
+
node: orNull(e["node"]),
|
|
720
|
+
callId: cid ?? null,
|
|
721
|
+
});
|
|
655
722
|
if (ev === "allow" && cid !== null && cid !== undefined)
|
|
656
723
|
invalidAllowIds.add(cid);
|
|
657
724
|
continue;
|
|
@@ -664,12 +731,16 @@ function executionBinding(entries, bundleV) {
|
|
|
664
731
|
const seq = toPlain(e["seq"]);
|
|
665
732
|
const err = validateOutcome(e);
|
|
666
733
|
if (err) {
|
|
667
|
-
failures.
|
|
734
|
+
failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
|
|
735
|
+
seq: orNull(e["seq"]),
|
|
736
|
+
node: orNull(e["node"]),
|
|
737
|
+
callId: cid ?? null,
|
|
738
|
+
});
|
|
668
739
|
continue;
|
|
669
740
|
}
|
|
670
741
|
if (cid !== null && outcomes.has(cid)) {
|
|
671
|
-
failures.
|
|
672
|
-
`${pyRepr(toPlain(outcomes.get(cid)["seq"]))})
|
|
742
|
+
failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
|
|
743
|
+
`${pyRepr(toPlain(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
|
|
673
744
|
continue;
|
|
674
745
|
}
|
|
675
746
|
if (cid !== null)
|
|
@@ -683,29 +754,32 @@ function executionBinding(entries, bundleV) {
|
|
|
683
754
|
// its recorded content disagrees with what was authorized (spec: "parameter equality is
|
|
684
755
|
// established only for calls where both hashes are present; elsewhere only identity and order
|
|
685
756
|
// binding was checked" — params_mismatch is that separate concern).
|
|
757
|
+
// Every failure in this loop is about a PAIR, and is positioned on the `outcome` entry: the
|
|
758
|
+
// allow was a complete, valid record when it was written, and it is the outcome that fails to
|
|
759
|
+
// bind to it (or reports different arguments than were authorized).
|
|
686
760
|
const boundOk = new Set();
|
|
687
761
|
for (const [cid, oc] of outcomes) {
|
|
688
762
|
const allowE = allows.get(cid);
|
|
689
763
|
if (allowE === undefined) {
|
|
690
|
-
failures.
|
|
764
|
+
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
765
|
continue;
|
|
692
766
|
}
|
|
693
767
|
const nodeOk = toPlain(allowE["node"]) === toPlain(oc["node"]);
|
|
694
768
|
if (!nodeOk) {
|
|
695
|
-
failures.
|
|
696
|
-
`outcome on node ${pyRepr(toPlain(oc["node"]))}
|
|
769
|
+
failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr(toPlain(allowE["node"]))} but ` +
|
|
770
|
+
`outcome on node ${pyRepr(toPlain(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
|
|
697
771
|
}
|
|
698
772
|
const ocSeq = toPlain(oc["seq"]);
|
|
699
773
|
const allowSeq = toPlain(allowE["seq"]);
|
|
700
774
|
const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
|
|
701
775
|
if (!orderOk) {
|
|
702
|
-
failures.
|
|
703
|
-
`after allow seq ${pyRepr(allowSeq ?? null)}
|
|
776
|
+
failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
|
|
777
|
+
`after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
|
|
704
778
|
}
|
|
705
779
|
const ah = toPlain(allowE["authorized_params_hash"]);
|
|
706
780
|
const ih = toPlain(oc["invoked_params_hash"]);
|
|
707
781
|
if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
|
|
708
|
-
failures.
|
|
782
|
+
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
783
|
}
|
|
710
784
|
if (nodeOk && orderOk)
|
|
711
785
|
boundOk.add(cid);
|
|
@@ -775,13 +849,52 @@ function executionBinding(entries, bundleV) {
|
|
|
775
849
|
}
|
|
776
850
|
if (Object.values(perCall).some((s) => s === "unobserved"))
|
|
777
851
|
escalate("incomplete");
|
|
778
|
-
return
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
852
|
+
return [
|
|
853
|
+
{
|
|
854
|
+
aggregate,
|
|
855
|
+
params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
|
|
856
|
+
per_call: perCall,
|
|
857
|
+
per_node_lifecycle: lifecycle,
|
|
858
|
+
failures: failures.messages,
|
|
859
|
+
},
|
|
783
860
|
failures,
|
|
784
|
-
|
|
861
|
+
];
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
|
|
865
|
+
*
|
|
866
|
+
* `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
|
|
867
|
+
* module reports; this walk exists so the structured twin of that message can say WHERE, which
|
|
868
|
+
* the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
|
|
869
|
+
* exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
|
|
870
|
+
* consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
|
|
871
|
+
* chain-level.
|
|
872
|
+
*/
|
|
873
|
+
function integrityPosition(entries) {
|
|
874
|
+
let prev = GENESIS;
|
|
875
|
+
for (let i = 0; i < entries.length; i++) {
|
|
876
|
+
const e = entries[i];
|
|
877
|
+
const payload = {};
|
|
878
|
+
for (const [k, v] of Object.entries(e)) {
|
|
879
|
+
if (k !== "hash")
|
|
880
|
+
payload[k] = v;
|
|
881
|
+
}
|
|
882
|
+
let broken;
|
|
883
|
+
try {
|
|
884
|
+
broken =
|
|
885
|
+
orNull(e["seq"]) !== i ||
|
|
886
|
+
orNull(payload["prev_hash"]) !== prev ||
|
|
887
|
+
hashEntry(prev, payload) !== orNull(e["hash"]);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// An unhashable payload is itself the break, at this entry.
|
|
891
|
+
return [orNull(e["seq"]), orNull(e["node"])];
|
|
892
|
+
}
|
|
893
|
+
if (broken)
|
|
894
|
+
return [orNull(e["seq"]), orNull(e["node"])];
|
|
895
|
+
prev = orNull(e["hash"]);
|
|
896
|
+
}
|
|
897
|
+
return [null, null];
|
|
785
898
|
}
|
|
786
899
|
export function verifyBundle(bundle, signer = null, options = {}) {
|
|
787
900
|
const entries = bundle.entries ?? [];
|
|
@@ -797,38 +910,41 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
797
910
|
root: false,
|
|
798
911
|
expected_anchor: "not checked",
|
|
799
912
|
};
|
|
800
|
-
const
|
|
913
|
+
const log = new FailureLog();
|
|
801
914
|
// (0) version: the bundle must declare a schema version this build understands, and — when
|
|
802
915
|
// an anchor is present — the anchor must be anchoring THAT version, not a different one.
|
|
803
916
|
const bundleV = toPlain(bundle.v);
|
|
804
917
|
let versionOk = typeof bundleV === "number" && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
|
|
805
918
|
if (!versionOk) {
|
|
806
919
|
const supported = Array.from(SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
|
|
807
|
-
|
|
920
|
+
log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
|
|
808
921
|
}
|
|
809
922
|
const anchorV = toPlain(anchor["v"]);
|
|
810
923
|
if (anchorPresent && anchorV !== bundleV) {
|
|
811
924
|
versionOk = false;
|
|
812
|
-
|
|
925
|
+
log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
|
|
813
926
|
}
|
|
814
927
|
// (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
|
|
815
928
|
// sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
|
|
816
929
|
const rootEvents = entries.filter((e) => toPlain(e["event"]) === "root");
|
|
817
930
|
checks.root = rootEvents.length === 1;
|
|
818
931
|
if (!checks.root) {
|
|
819
|
-
|
|
932
|
+
log.add("missing_root", `missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
|
|
820
933
|
}
|
|
821
934
|
const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
|
|
822
935
|
// 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
|
|
823
936
|
// entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
|
|
824
937
|
if (rootEntry !== undefined && toPlain(rootEntry["v"]) !== bundleV) {
|
|
825
938
|
versionOk = false;
|
|
826
|
-
|
|
939
|
+
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
940
|
}
|
|
828
|
-
const
|
|
941
|
+
const mixedEntries = entries.filter((e) => toPlain(e["v"]) !== bundleV);
|
|
942
|
+
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
943
|
if (mixed.length > 0) {
|
|
830
944
|
versionOk = false;
|
|
831
|
-
|
|
945
|
+
// One aggregate message over every offending entry (unchanged); the twin is positioned on
|
|
946
|
+
// the first of them, which is where a reader looks.
|
|
947
|
+
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
948
|
}
|
|
833
949
|
checks.version = versionOk;
|
|
834
950
|
// (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
|
|
@@ -842,7 +958,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
842
958
|
const [expSeq, expHash] = expectedHead;
|
|
843
959
|
if (actualSeq !== expSeq || actualHead !== expHash) {
|
|
844
960
|
expectedOk = false;
|
|
845
|
-
|
|
961
|
+
log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
|
|
846
962
|
`independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
|
|
847
963
|
}
|
|
848
964
|
}
|
|
@@ -853,7 +969,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
853
969
|
toPlain(ea["chain_id"]) !== toPlain(bundle.chain_id) ||
|
|
854
970
|
toPlain(ea["v"]) !== bundleV) {
|
|
855
971
|
expectedOk = false;
|
|
856
|
-
|
|
972
|
+
log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
|
|
857
973
|
"the independently retained expected anchor");
|
|
858
974
|
}
|
|
859
975
|
}
|
|
@@ -863,32 +979,40 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
863
979
|
// must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
|
|
864
980
|
// for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
|
|
865
981
|
const bundleChainId = orNull(bundle.chain_id);
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
982
|
+
const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
|
|
983
|
+
const entriesOk = foreign === undefined;
|
|
984
|
+
if (foreign !== undefined) {
|
|
985
|
+
log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
|
|
986
|
+
seq: orNull(foreign["seq"]),
|
|
987
|
+
node: orNull(foreign["node"]),
|
|
988
|
+
});
|
|
869
989
|
}
|
|
870
990
|
const anchorChainId = orNull(anchor["chain_id"]);
|
|
871
991
|
const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
|
|
872
992
|
if (!anchorChainOk) {
|
|
873
|
-
|
|
993
|
+
log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
|
|
874
994
|
}
|
|
875
995
|
checks.chain_id = entriesOk && anchorChainOk;
|
|
876
996
|
// (1) integrity: the hash chain, plus the signed anchor when a key is given.
|
|
877
997
|
const [okChain, err] = AuditLog.verify(entries);
|
|
878
|
-
if (!okChain)
|
|
879
|
-
|
|
998
|
+
if (!okChain) {
|
|
999
|
+
const [badSeq, badNode] = integrityPosition(entries);
|
|
1000
|
+
log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
|
|
1001
|
+
}
|
|
880
1002
|
if (signer !== null) {
|
|
881
1003
|
const [okAnchor, aerr] = AuditLog.verifyAnchor(entries, anchor, signer);
|
|
882
1004
|
checks.anchor = okAnchor ? "verified" : "FAILED";
|
|
1005
|
+
// Chain-level by construction: the anchor commits to the head of the WHOLE ledger, so a
|
|
1006
|
+
// consistently re-hashed chain has no single offending entry to point at.
|
|
883
1007
|
if (!okAnchor)
|
|
884
|
-
|
|
1008
|
+
log.add("integrity(anchor)", `integrity(anchor): ${aerr}`);
|
|
885
1009
|
checks.integrity = okChain && okAnchor;
|
|
886
1010
|
}
|
|
887
1011
|
else {
|
|
888
1012
|
checks.integrity = okChain;
|
|
889
1013
|
}
|
|
890
|
-
const { auth, parent, failures: afail } = nodeAuthorities(entries);
|
|
891
|
-
|
|
1014
|
+
const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
|
|
1015
|
+
log.extend(afail);
|
|
892
1016
|
// (2) monotonicity: every child ⊆ its parent.
|
|
893
1017
|
let mono = true;
|
|
894
1018
|
for (const [node, pid] of parent) {
|
|
@@ -899,8 +1023,9 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
899
1023
|
const extra = Array.from(child.scopes).filter((s) => !p.scopes.has(s));
|
|
900
1024
|
if (!child.isNarrowerThan(p) && extra.length > 0) {
|
|
901
1025
|
mono = false;
|
|
902
|
-
|
|
903
|
-
|
|
1026
|
+
const spawnE = definedBy.get(node);
|
|
1027
|
+
log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (child scopes ` +
|
|
1028
|
+
`[${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] not held by parent)`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
|
|
904
1029
|
}
|
|
905
1030
|
}
|
|
906
1031
|
checks.monotonicity = mono && afail.length === 0;
|
|
@@ -917,19 +1042,25 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
917
1042
|
const a = auth.get(node);
|
|
918
1043
|
if (a === undefined) {
|
|
919
1044
|
contained = false;
|
|
920
|
-
|
|
1045
|
+
log.add("containment", `containment: allow on unknown node ${node}`, {
|
|
1046
|
+
seq: orNull(e["seq"]),
|
|
1047
|
+
node: orNull(e["node"]),
|
|
1048
|
+
callId: orNull(e["call_id"]),
|
|
1049
|
+
});
|
|
921
1050
|
continue;
|
|
922
1051
|
}
|
|
923
1052
|
if (!a.permits(scope, ctx).allowed) {
|
|
924
1053
|
contained = false;
|
|
925
|
-
|
|
926
|
-
`[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]
|
|
1054
|
+
log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
|
|
1055
|
+
`[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]) });
|
|
927
1056
|
}
|
|
928
1057
|
}
|
|
929
1058
|
checks.containment = contained;
|
|
930
|
-
const eb = versionOk
|
|
931
|
-
|
|
932
|
-
|
|
1059
|
+
const [eb, ebFailures] = versionOk
|
|
1060
|
+
? executionBinding(entries, bundleV)
|
|
1061
|
+
: [{ status: "not applicable" }, new FailureLog()];
|
|
1062
|
+
if (eb.failures !== undefined && eb.failures.length > 0)
|
|
1063
|
+
log.extend(ebFailures);
|
|
933
1064
|
// "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
|
|
934
1065
|
// ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
|
|
935
1066
|
// either already lands its own entry in `failures`, which the `ok` computation still gates on.
|
|
@@ -939,11 +1070,12 @@ export function verifyBundle(bundle, signer = null, options = {}) {
|
|
|
939
1070
|
checks.version &&
|
|
940
1071
|
checks.chain_id &&
|
|
941
1072
|
checks.root &&
|
|
942
|
-
|
|
1073
|
+
log.length === 0;
|
|
943
1074
|
return {
|
|
944
1075
|
ok,
|
|
945
1076
|
checks,
|
|
946
|
-
failures,
|
|
1077
|
+
failures: log.messages,
|
|
1078
|
+
failure_details: log.details,
|
|
947
1079
|
nodes: auth.size,
|
|
948
1080
|
actions_checked: actions,
|
|
949
1081
|
chain_id: orNull(bundle.chain_id),
|