@verax-ai/proxy 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/approvals.d.ts +13 -4
- package/dist/approvals.js +140 -22
- package/dist/checkpoints.d.ts +9 -0
- package/dist/checkpoints.js +44 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ledger-manifest.d.ts +9 -0
- package/dist/ledger-manifest.js +49 -6
- package/dist/ledger.d.ts +8 -0
- package/dist/ledger.js +45 -18
- package/dist/policy.d.ts +12 -1
- package/dist/policy.js +87 -16
- package/dist/proxy.js +177 -27
- package/dist/reconcile.d.ts +4 -1
- package/dist/reconcile.js +39 -2
- package/dist/types.d.ts +5 -2
- package/dist/verify-ledger.d.ts +39 -1
- package/dist/verify-ledger.js +731 -36
- package/package.json +3 -1
package/dist/verify-ledger.js
CHANGED
|
@@ -10,7 +10,46 @@
|
|
|
10
10
|
*
|
|
11
11
|
* signatures each record verifies against a public key
|
|
12
12
|
* chain each record names the hash of the one before it
|
|
13
|
-
* effects
|
|
13
|
+
* effects an effect is bound only when a decision with that ref exists and
|
|
14
|
+
* its recorded `effectHash` equals the row's `effectHash`, and
|
|
15
|
+
* the row carries an attestation and a witness receipt that both
|
|
16
|
+
* verify under one effect key. The signed COSE payload is
|
|
17
|
+
* `{ ref, effectHash, witnessClass, resultHash }`, compared after
|
|
18
|
+
* decode, and the receipt's effect (`body.effects[0]`, every
|
|
19
|
+
* field the writer put there) must be that same row. A
|
|
20
|
+
* thrown call relaxes only the hash, and only for the tool that
|
|
21
|
+
* was allowed: some decision on that ref has `decision: "allow"`
|
|
22
|
+
* and this row's class is exactly that decision's `effectClass`
|
|
23
|
+
* plus `:threw`. A deny, or an allow of a different tool, does
|
|
24
|
+
* not bind. The signature is still required. A
|
|
25
|
+
* `duplicate-effect` row is bound to the decision by ref and to
|
|
26
|
+
* the fixed refusal hash the writer stores, not to the
|
|
27
|
+
* decision's effectHash; the signature is still required.
|
|
28
|
+
* Anything else that claims that class is named. A ref has at
|
|
29
|
+
* most one primary effect row (a row that is not
|
|
30
|
+
* `duplicate-effect`). A further primary row for that ref is
|
|
31
|
+
* named `ref X has more than one effect row` and is not bound.
|
|
32
|
+
* A `self` row is checked under the effect key:
|
|
33
|
+
* `effectPublicKeyPem` when the reader pins one, otherwise the
|
|
34
|
+
* first receipt key on a `self` row. A `same-org` row is checked
|
|
35
|
+
* under the witness key: `witnessPublicKeyPem` when the reader
|
|
36
|
+
* pins one, otherwise the first receipt key on a `same-org` row.
|
|
37
|
+
* Pinning one of those keys and leaving the other to the file
|
|
38
|
+
* does not verify: a pinned effect key with any `same-org` row
|
|
39
|
+
* requires a pinned witness key, and a pinned witness key with
|
|
40
|
+
* any `self` row requires a pinned effect key. Agreement of a
|
|
41
|
+
* key with the files is not trust.
|
|
42
|
+
*
|
|
43
|
+
* Checkpoints are a fifth statement. Each row in `checkpoints.jsonl` must
|
|
44
|
+
* verify under one witness key: `checkpointPublicKeyPem` when the reader
|
|
45
|
+
* pins one, otherwise the first key the file carries. A line that does not
|
|
46
|
+
* parse, or that parses but is not a checkpoint, is named and `ok` is false.
|
|
47
|
+
* A missing file is not that: the tail line says there is no checkpoint.
|
|
48
|
+
* A row that does not
|
|
49
|
+
* verify is named and `ok` is false. The `prevCheckpointHash` chain is
|
|
50
|
+
* checked with `findCheckpointChainBreak`. The tail uses only the prefix
|
|
51
|
+
* that verified. A key taken from the file shows the checkpoints agree
|
|
52
|
+
* with each other, not that the key was ever trusted.
|
|
14
53
|
*
|
|
15
54
|
* And a fourth, which matters most and is the easiest to fudge: **which key**.
|
|
16
55
|
* A ledger checked against the key sitting next to it is internally
|
|
@@ -20,31 +59,119 @@
|
|
|
20
59
|
* the answer rather than a footnote, and `publicKeyPem` lets a reader pin a
|
|
21
60
|
* copy they hold themselves.
|
|
22
61
|
*/
|
|
23
|
-
import {
|
|
62
|
+
import { createPublicKey } from "node:crypto";
|
|
63
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
24
64
|
import { join } from "node:path";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
65
|
+
import { findCheckpointChainBreak, verifyCheckpoint, verifyCheckpointUnderPin, } from "@cedulon/checkpoint";
|
|
66
|
+
import { canonical, decisionRecordHash, findDecisionRecordChainBreak, verifyDecisionRecord } from "@cedulon/core";
|
|
67
|
+
import { coseFromHex, decodeCoseSign1, verifyCoseSign1 } from "@cedulon/cose";
|
|
68
|
+
import { verifyEffectExtract } from "@cedulon/effect-extract";
|
|
69
|
+
import { checkpointsPath, readCheckpointFile } from "./checkpoints.js";
|
|
70
|
+
import { sha256Canonical } from "./hash.js";
|
|
71
|
+
import { ledgerPiecePathProblem, readLedgerManifest, requireLedgerPiecePath, indexPath, manifestPath, parseIndexText, } from "./ledger-manifest.js";
|
|
72
|
+
/**
|
|
73
|
+
* A symbolic link is a named problem and is not read. Missing files are not
|
|
74
|
+
* this case. `lstatSync` does not follow the link.
|
|
75
|
+
*/
|
|
76
|
+
function symbolicLinkProblem(path) {
|
|
77
|
+
try {
|
|
78
|
+
if (lstatSync(path).isSymbolicLink())
|
|
79
|
+
return `symbolic link: ${path}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A piece path that leaves the directory is a problem and is not opened.
|
|
88
|
+
* A piece that is a symbolic link is named and is not opened. A decisions or
|
|
89
|
+
* effects file the manifest names and the disk lacks is `missing piece`.
|
|
90
|
+
* `existsSync` runs only after the path and link checks.
|
|
91
|
+
*/
|
|
92
|
+
function takePieceFile(dir, rel, problems, read) {
|
|
93
|
+
const problem = ledgerPiecePathProblem(dir, rel);
|
|
94
|
+
if (problem !== null) {
|
|
95
|
+
problems.push(problem);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const path = requireLedgerPiecePath(dir, rel);
|
|
99
|
+
const link = symbolicLinkProblem(path);
|
|
100
|
+
if (link !== null) {
|
|
101
|
+
problems.push(link);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!read)
|
|
105
|
+
return null;
|
|
106
|
+
// The writer creates the effects file for every piece, including a piece
|
|
107
|
+
// that has no effects yet (an empty file). A name in the manifest with no
|
|
108
|
+
// file on disk is not that empty piece.
|
|
109
|
+
if (!existsSync(path)) {
|
|
110
|
+
problems.push(`missing piece: ${rel}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
/** Decision and effect files this directory holds, oldest piece first. Inputs are confined and not read. */
|
|
116
|
+
function ledgerFiles(dir, problems) {
|
|
117
|
+
const manifestFile = manifestPath(dir);
|
|
118
|
+
const manifestLink = symbolicLinkProblem(manifestFile);
|
|
119
|
+
if (manifestLink !== null) {
|
|
120
|
+
problems.push(manifestLink);
|
|
121
|
+
return { decisions: [], effects: [] };
|
|
32
122
|
}
|
|
33
|
-
|
|
34
|
-
|
|
123
|
+
if (existsSync(manifestFile)) {
|
|
124
|
+
try {
|
|
125
|
+
const manifest = readLedgerManifest(dir);
|
|
126
|
+
if (manifest && Array.isArray(manifest.pieces) && manifest.pieces.length > 0) {
|
|
127
|
+
return pieceFiles(dir, manifest.pieces, problems);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
problems.push(err instanceof Error ? err.message : "ledger-manifest-unreadable");
|
|
132
|
+
return { decisions: [], effects: [] };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return legacyFiles(dir);
|
|
136
|
+
}
|
|
137
|
+
function legacyFiles(dir) {
|
|
138
|
+
const decisionsPath = join(dir, "decisions.jsonl");
|
|
139
|
+
const effectsPath = join(dir, "effects.jsonl");
|
|
140
|
+
return {
|
|
141
|
+
decisions: existsSync(decisionsPath) ? [decisionsPath] : [],
|
|
142
|
+
effects: existsSync(effectsPath) ? [effectsPath] : [],
|
|
143
|
+
};
|
|
35
144
|
}
|
|
36
|
-
function
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
145
|
+
function pieceFiles(dir, pieces, problems) {
|
|
146
|
+
const decisions = [];
|
|
147
|
+
const effects = [];
|
|
148
|
+
for (const p of pieces) {
|
|
149
|
+
const decision = takePieceFile(dir, p.decisions, problems, true);
|
|
150
|
+
if (decision)
|
|
151
|
+
decisions.push(decision);
|
|
152
|
+
const effect = takePieceFile(dir, p.effects, problems, true);
|
|
153
|
+
if (effect)
|
|
154
|
+
effects.push(effect);
|
|
155
|
+
takePieceFile(dir, p.inputs, problems, false);
|
|
40
156
|
}
|
|
41
|
-
|
|
42
|
-
return existsSync(tek) ? [tek] : [];
|
|
157
|
+
return { decisions, effects };
|
|
43
158
|
}
|
|
44
159
|
/** Parses JSONL, reporting the line a bad row sits on rather than throwing. */
|
|
45
160
|
function readJsonl(path, problems) {
|
|
46
161
|
const out = [];
|
|
47
|
-
const
|
|
162
|
+
const link = symbolicLinkProblem(path);
|
|
163
|
+
if (link !== null) {
|
|
164
|
+
problems.push(link);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = readFileSync(path, "utf8");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
problems.push(`unreadable file: ${path}`);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
48
175
|
const lines = text.split("\n");
|
|
49
176
|
for (let i = 0; i < lines.length; i += 1) {
|
|
50
177
|
const line = lines[i].trim();
|
|
@@ -59,12 +186,441 @@ function readJsonl(path, problems) {
|
|
|
59
186
|
}
|
|
60
187
|
return out;
|
|
61
188
|
}
|
|
189
|
+
function isPublicKeyPem(pem) {
|
|
190
|
+
return typeof pem === "string" && pem.includes("BEGIN PUBLIC KEY");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* RFC 9864 assigns alg -19 to Ed25519 only. A key that parses as something
|
|
194
|
+
* else is refused here. The same check belongs in `verifyCoseSign1`; this
|
|
195
|
+
* package does not patch that dependency.
|
|
196
|
+
* A PEM that does not parse is left to the verifier, which already fails it.
|
|
197
|
+
*/
|
|
198
|
+
function ed25519Refusal(pem) {
|
|
199
|
+
if (typeof pem !== "string" || pem.trim() === "")
|
|
200
|
+
return null;
|
|
201
|
+
try {
|
|
202
|
+
const key = createPublicKey(pem);
|
|
203
|
+
if (key.asymmetricKeyType === "ed25519")
|
|
204
|
+
return null;
|
|
205
|
+
return "public key is not ed25519";
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function noteEd25519(pem, problems) {
|
|
212
|
+
const refusal = ed25519Refusal(pem);
|
|
213
|
+
if (refusal)
|
|
214
|
+
problems.push(refusal);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The writer signs `{ ref, effectHash, witnessClass, resultHash }` as COSE
|
|
218
|
+
* Sign1 and a one-row extract. A row with either missing is not bound. Both
|
|
219
|
+
* must verify under the key the caller settled on for this row's witness
|
|
220
|
+
* class — never a key this row brought by itself.
|
|
221
|
+
*/
|
|
222
|
+
function effectSignatureCoversRow(effect, effectKey) {
|
|
223
|
+
const coseHex = effect.attestation?.coseHex;
|
|
224
|
+
const hasAtt = typeof coseHex === "string" && coseHex !== "";
|
|
225
|
+
const hasReceipt = effect.receipt !== undefined;
|
|
226
|
+
if (!hasAtt || !hasReceipt || !effect.receipt || !isPublicKeyPem(effectKey))
|
|
227
|
+
return false;
|
|
228
|
+
if (ed25519Refusal(effectKey))
|
|
229
|
+
return false;
|
|
230
|
+
const ref = effect.row?.ref;
|
|
231
|
+
const effectHash = effect.row?.effectHash;
|
|
232
|
+
const witnessClass = effect.witnessClass;
|
|
233
|
+
if (typeof ref !== "string" || typeof effectHash !== "string" || typeof witnessClass !== "string") {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const expected = canonical({
|
|
237
|
+
ref,
|
|
238
|
+
effectHash,
|
|
239
|
+
witnessClass,
|
|
240
|
+
resultHash: typeof effect.resultHash === "string" ? effect.resultHash : null,
|
|
241
|
+
});
|
|
242
|
+
let decodedPayload;
|
|
243
|
+
try {
|
|
244
|
+
const decoded = decodeCoseSign1(coseFromHex(coseHex));
|
|
245
|
+
decodedPayload = JSON.parse(Buffer.from(decoded.payload).toString("utf8"));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
if (canonical(decodedPayload) !== expected)
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
if (!verifyCoseSign1(coseFromHex(coseHex), effectKey, "application/json"))
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
if (!verifyEffectExtract(effect.receipt, effectKey))
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
let signedRow;
|
|
272
|
+
try {
|
|
273
|
+
signedRow = effect.receipt.body.effects[0];
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
if (signedRow == null || !effect.row)
|
|
279
|
+
return false;
|
|
280
|
+
try {
|
|
281
|
+
if (canonical(signedRow) !== canonical(effect.row))
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
function effectProblem(effect, ref) {
|
|
290
|
+
const signedClass = effect.receipt?.body?.effects?.[0]?.effectClass;
|
|
291
|
+
const diskClass = effect.row?.effectClass;
|
|
292
|
+
if (typeof signedClass === "string" && signedClass !== diskClass) {
|
|
293
|
+
return `effect class is not the signed class: ref ${ref ?? "(missing)"}`;
|
|
294
|
+
}
|
|
295
|
+
return `effect attestation does not cover the row: ref ${ref ?? "(missing)"}`;
|
|
296
|
+
}
|
|
297
|
+
function firstReceiptKey(rows, sameOrg) {
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
if ((row.witnessClass === "same-org") !== sameOrg)
|
|
300
|
+
continue;
|
|
301
|
+
const pem = row.receipt?.publicKeyPem;
|
|
302
|
+
if (isPublicKeyPem(pem))
|
|
303
|
+
return pem;
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const AGREE = "this shows the files are internally consistent, not that the key was ever trusted. Pin a key you hold to check that.";
|
|
308
|
+
function witnessTrustOf(pinned, taken, sameOrgCount) {
|
|
309
|
+
if (sameOrgCount === 0) {
|
|
310
|
+
return {
|
|
311
|
+
source: "none",
|
|
312
|
+
publicKeyPem: null,
|
|
313
|
+
note: "no same-org effects, so no witness key was used",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (pinned !== "") {
|
|
317
|
+
return {
|
|
318
|
+
source: "pinned",
|
|
319
|
+
publicKeyPem: pinned,
|
|
320
|
+
note: "verified against a key the reader supplied, not one taken from these files",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (taken) {
|
|
324
|
+
return {
|
|
325
|
+
source: "in-ledger",
|
|
326
|
+
publicKeyPem: taken,
|
|
327
|
+
note: `verified against the key carried in the same-org effects themselves: ${AGREE}`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
source: "none",
|
|
332
|
+
publicKeyPem: null,
|
|
333
|
+
note: "no witness key was found in these files",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function firstCheckpointKey(rows) {
|
|
337
|
+
for (const row of rows) {
|
|
338
|
+
if (isPublicKeyPem(row.publicKeyPem))
|
|
339
|
+
return row.publicKeyPem;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
function checkpointTrustOf(pinned, taken, checkpointCount) {
|
|
344
|
+
if (checkpointCount === 0) {
|
|
345
|
+
return {
|
|
346
|
+
source: "none",
|
|
347
|
+
publicKeyPem: null,
|
|
348
|
+
note: "no checkpoints, so no checkpoint key was used",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (pinned !== "") {
|
|
352
|
+
return {
|
|
353
|
+
source: "pinned",
|
|
354
|
+
publicKeyPem: pinned,
|
|
355
|
+
note: "verified against a key the reader supplied, not one taken from these files",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (taken) {
|
|
359
|
+
return {
|
|
360
|
+
source: "in-ledger",
|
|
361
|
+
publicKeyPem: taken,
|
|
362
|
+
note: "verified against the key carried in the checkpoints themselves: this shows the files are " +
|
|
363
|
+
"internally consistent, not that the key was ever trusted. Pin a key you hold to check that.",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
source: "none",
|
|
368
|
+
publicKeyPem: null,
|
|
369
|
+
note: "no checkpoint key was found in these files",
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** One key for every row. A pin ignores the key a row carries. */
|
|
373
|
+
function checkpointRowVerifies(row, key) {
|
|
374
|
+
try {
|
|
375
|
+
if (key && ed25519Refusal(key))
|
|
376
|
+
return false;
|
|
377
|
+
if (key)
|
|
378
|
+
return verifyCheckpointUnderPin(row, key) && verifyCheckpoint(row, key);
|
|
379
|
+
return verifyCheckpoint(row);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function effectTrustOf(pinned, taken, effectCount) {
|
|
386
|
+
if (effectCount === 0) {
|
|
387
|
+
return {
|
|
388
|
+
source: "none",
|
|
389
|
+
publicKeyPem: null,
|
|
390
|
+
note: "no effects, so no effect key was used",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (pinned !== "") {
|
|
394
|
+
return {
|
|
395
|
+
source: "pinned",
|
|
396
|
+
publicKeyPem: pinned,
|
|
397
|
+
note: "verified against a key the reader supplied, not one taken from these files",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (taken) {
|
|
401
|
+
return {
|
|
402
|
+
source: "in-ledger",
|
|
403
|
+
publicKeyPem: taken,
|
|
404
|
+
note: `verified against the key carried in the self effects themselves: ${AGREE}`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
source: "none",
|
|
409
|
+
publicKeyPem: null,
|
|
410
|
+
note: "no effect key was found in these files",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const INDEX_NONE = "index: none (cannot check for removed records)";
|
|
414
|
+
const EFFECT_COMPLETENESS_UNCHECKED = "effect completeness was not checked";
|
|
415
|
+
const EFFECT_COMPLETENESS_CHECKED = "effect completeness checked";
|
|
416
|
+
const TAIL_NONE = "tail: no checkpoint; removing the newest records with their effects is not detectable from these files";
|
|
417
|
+
/**
|
|
418
|
+
* A torn last line is the crash the writer already documents: earlier lines
|
|
419
|
+
* parsed, so that tail is skipped. Any other line that is not JSON, and a
|
|
420
|
+
* file whose every line fails to parse, is a problem.
|
|
421
|
+
*/
|
|
422
|
+
function indexLineProblems(text) {
|
|
423
|
+
const lines = text.split("\n");
|
|
424
|
+
const filled = [];
|
|
425
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
426
|
+
if (lines[i] !== "")
|
|
427
|
+
filled.push({ n: i + 1, line: lines[i] });
|
|
428
|
+
}
|
|
429
|
+
const problems = [];
|
|
430
|
+
let parsedOk = 0;
|
|
431
|
+
for (let i = 0; i < filled.length; i += 1) {
|
|
432
|
+
const last = i === filled.length - 1;
|
|
433
|
+
try {
|
|
434
|
+
JSON.parse(filled[i].line);
|
|
435
|
+
parsedOk += 1;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
if (!last || parsedOk === 0)
|
|
439
|
+
problems.push(`index line ${filled[i].n} is not JSON`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return problems;
|
|
443
|
+
}
|
|
444
|
+
function effectRefsInIndex(text) {
|
|
445
|
+
const refs = new Set();
|
|
446
|
+
for (const line of parseIndexText(text)) {
|
|
447
|
+
if (line.hasEffect || line.kind === "effect")
|
|
448
|
+
refs.add(line.ref);
|
|
449
|
+
}
|
|
450
|
+
return refs;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* `null` means the index is not there or could not be read, so effect
|
|
454
|
+
* completeness was not checked. An empty set means it was checked and it
|
|
455
|
+
* marks no effect.
|
|
456
|
+
*/
|
|
457
|
+
function indexStatement(dir, decisionRefs) {
|
|
458
|
+
const indexFile = indexPath(dir);
|
|
459
|
+
const indexLink = symbolicLinkProblem(indexFile);
|
|
460
|
+
if (indexLink !== null) {
|
|
461
|
+
return { index: { present: true, missing: 0, line: indexLink }, problems: [indexLink], effectRefs: null };
|
|
462
|
+
}
|
|
463
|
+
if (!existsSync(indexFile)) {
|
|
464
|
+
return { index: { present: false, missing: 0, line: INDEX_NONE }, problems: [], effectRefs: null };
|
|
465
|
+
}
|
|
466
|
+
let text = "";
|
|
467
|
+
try {
|
|
468
|
+
text = readFileSync(indexFile, "utf8");
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
const line = "index could not be read";
|
|
472
|
+
return { index: { present: true, missing: 0, line }, problems: [line], effectRefs: null };
|
|
473
|
+
}
|
|
474
|
+
const parseProblems = indexLineProblems(text);
|
|
475
|
+
const lines = parseIndexText(text);
|
|
476
|
+
const named = new Set();
|
|
477
|
+
for (const line of lines)
|
|
478
|
+
named.add(line.ref);
|
|
479
|
+
const effectRefs = effectRefsInIndex(text);
|
|
480
|
+
let missing = 0;
|
|
481
|
+
for (const ref of named) {
|
|
482
|
+
if (!decisionRefs.has(ref))
|
|
483
|
+
missing += 1;
|
|
484
|
+
}
|
|
485
|
+
if (missing > 0) {
|
|
486
|
+
const line = `index names ${missing} record(s) the ledger no longer holds`;
|
|
487
|
+
return { index: { present: true, missing, line }, problems: [...parseProblems, line], effectRefs };
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
index: { present: true, missing: 0, line: `index: ${named.size} ref(s), each still a decision` },
|
|
491
|
+
problems: parseProblems,
|
|
492
|
+
effectRefs,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function noteEffectCompleteness(effectRefs, bound, problems) {
|
|
496
|
+
if (effectRefs === null)
|
|
497
|
+
return EFFECT_COMPLETENESS_UNCHECKED;
|
|
498
|
+
for (const ref of effectRefs) {
|
|
499
|
+
if (!bound.has(ref)) {
|
|
500
|
+
problems.push(`index says ref ${ref} has an effect but no bound effect row`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return EFFECT_COMPLETENESS_CHECKED;
|
|
504
|
+
}
|
|
505
|
+
function tailStatement(dir, records, checkpointPublicKeyPem) {
|
|
506
|
+
const checkpointFile = checkpointsPath(dir);
|
|
507
|
+
const checkpointLink = symbolicLinkProblem(checkpointFile);
|
|
508
|
+
if (checkpointLink !== null) {
|
|
509
|
+
const pinned = checkpointPublicKeyPem.trim();
|
|
510
|
+
return {
|
|
511
|
+
tail: { line: TAIL_NONE, checkpoint: null },
|
|
512
|
+
checkpointTrust: checkpointTrustOf(pinned, null, 0),
|
|
513
|
+
problems: [checkpointLink],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const loaded = readCheckpointFile(dir);
|
|
517
|
+
const rows = loaded.rows;
|
|
518
|
+
const pinned = checkpointPublicKeyPem.trim();
|
|
519
|
+
const taken = firstCheckpointKey(rows);
|
|
520
|
+
const key = pinned !== "" ? pinned : taken;
|
|
521
|
+
const checkpointTrust = checkpointTrustOf(pinned, taken, rows.length);
|
|
522
|
+
const problems = [...loaded.problems];
|
|
523
|
+
noteEd25519(key, problems);
|
|
524
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
525
|
+
if (!checkpointRowVerifies(rows[i], key)) {
|
|
526
|
+
problems.push(`checkpoint signature does not verify: checkpoint ${i}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let brk = null;
|
|
530
|
+
try {
|
|
531
|
+
brk = findCheckpointChainBreak(rows, key ?? undefined);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
535
|
+
problems.push(`checkpoint chain could not be checked: ${message}`);
|
|
536
|
+
brk = rows.length > 0 ? { index: 0, reason: "bad-signature" } : null;
|
|
537
|
+
}
|
|
538
|
+
if (brk && brk.reason !== "bad-signature") {
|
|
539
|
+
problems.push(`checkpoint chain breaks at checkpoint ${brk.index} (${brk.reason}): prevCheckpointHash does not match`);
|
|
540
|
+
}
|
|
541
|
+
const covered = brk ? rows.slice(0, brk.index) : rows;
|
|
542
|
+
const newest = covered.length > 0 ? covered[covered.length - 1] : null;
|
|
543
|
+
if (!newest) {
|
|
544
|
+
return { tail: { line: TAIL_NONE, checkpoint: null }, checkpointTrust, problems };
|
|
545
|
+
}
|
|
546
|
+
const claims = newest.claims;
|
|
547
|
+
const receiptCount = typeof claims.receiptCount === "number" ? claims.receiptCount : null;
|
|
548
|
+
const chainHeadHash = typeof claims.chainHeadHash === "string" ? claims.chainHeadHash : null;
|
|
549
|
+
let ledgerHoldsRecord = null;
|
|
550
|
+
if (chainHeadHash) {
|
|
551
|
+
ledgerHoldsRecord = records.some((rec) => {
|
|
552
|
+
try {
|
|
553
|
+
return decisionRecordHash(rec) === chainHeadHash;
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (receiptCount !== null && records.length < receiptCount) {
|
|
561
|
+
problems.push(`checkpoint covers ${receiptCount} record(s); the ledger holds ${records.length}`);
|
|
562
|
+
}
|
|
563
|
+
if (ledgerHoldsRecord === false) {
|
|
564
|
+
problems.push("newest checkpoint names a record the ledger no longer holds");
|
|
565
|
+
}
|
|
566
|
+
const after = receiptCount === null ? null : records.length - receiptCount;
|
|
567
|
+
const line = after === null
|
|
568
|
+
? "tail: newest checkpoint carries no record count to compare"
|
|
569
|
+
: `tail: ${after < 0 ? 0 : after} record(s) after the newest checkpoint are not covered`;
|
|
570
|
+
return {
|
|
571
|
+
tail: { line, checkpoint: { receiptCount, chainHeadHash, ledgerHoldsRecord } },
|
|
572
|
+
checkpointTrust,
|
|
573
|
+
problems,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function unreadableResult(dir, problem) {
|
|
577
|
+
const trust = { source: "none", publicKeyPem: null, note: problem };
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
directory: dir,
|
|
581
|
+
decisions: 0,
|
|
582
|
+
effects: 0,
|
|
583
|
+
signaturesValid: 0,
|
|
584
|
+
signaturesInvalid: 0,
|
|
585
|
+
chainBreakAt: null,
|
|
586
|
+
effectsBound: 0,
|
|
587
|
+
effectsOrphaned: 0,
|
|
588
|
+
trust,
|
|
589
|
+
effectTrust: trust,
|
|
590
|
+
witnessTrust: trust,
|
|
591
|
+
checkpointTrust: trust,
|
|
592
|
+
index: { present: false, missing: 0, line: INDEX_NONE },
|
|
593
|
+
effectCompleteness: EFFECT_COMPLETENESS_UNCHECKED,
|
|
594
|
+
tail: { line: TAIL_NONE, checkpoint: null },
|
|
595
|
+
problems: [problem],
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function isDecisionRow(row) {
|
|
599
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
600
|
+
return false;
|
|
601
|
+
const claims = row.claims;
|
|
602
|
+
return claims !== null && typeof claims === "object";
|
|
603
|
+
}
|
|
62
604
|
export async function verifyLedger(dir, opts = {}) {
|
|
605
|
+
try {
|
|
606
|
+
return await verifyLedgerUnchecked(dir, opts);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
const problem = err instanceof Error ? err.message : "ledger could not be read";
|
|
610
|
+
return unreadableResult(dir, problem);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function verifyLedgerUnchecked(dir, opts = {}) {
|
|
63
614
|
const problems = [];
|
|
64
|
-
const
|
|
615
|
+
const files = ledgerFiles(dir, problems);
|
|
616
|
+
const kararDosyalari = files.decisions;
|
|
65
617
|
const records = [];
|
|
66
618
|
for (const path of kararDosyalari) {
|
|
67
619
|
for (const row of readJsonl(path, problems)) {
|
|
620
|
+
if (!isDecisionRow(row)) {
|
|
621
|
+
problems.push("record is not a decision");
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
68
624
|
records.push(row);
|
|
69
625
|
}
|
|
70
626
|
}
|
|
@@ -72,6 +628,12 @@ export async function verifyLedger(dir, opts = {}) {
|
|
|
72
628
|
// would let a deleted ledger pass as a verified one.
|
|
73
629
|
if (records.length === 0) {
|
|
74
630
|
problems.push("no decisions found: this directory holds no ledger to verify");
|
|
631
|
+
noteEd25519(opts.publicKeyPem, problems);
|
|
632
|
+
noteEd25519(opts.effectPublicKeyPem, problems);
|
|
633
|
+
const emptyIndex = indexStatement(dir, new Set());
|
|
634
|
+
const emptyTail = tailStatement(dir, records, opts.checkpointPublicKeyPem ?? "");
|
|
635
|
+
const effectCompleteness = noteEffectCompleteness(emptyIndex.effectRefs, new Set(), problems);
|
|
636
|
+
problems.push(...emptyIndex.problems, ...emptyTail.problems);
|
|
75
637
|
return {
|
|
76
638
|
ok: false,
|
|
77
639
|
directory: dir,
|
|
@@ -83,6 +645,12 @@ export async function verifyLedger(dir, opts = {}) {
|
|
|
83
645
|
effectsBound: 0,
|
|
84
646
|
effectsOrphaned: 0,
|
|
85
647
|
trust: { source: "none", publicKeyPem: null, note: "no records, so no key was used" },
|
|
648
|
+
effectTrust: effectTrustOf(opts.effectPublicKeyPem?.trim() ?? "", null, 0),
|
|
649
|
+
witnessTrust: witnessTrustOf(opts.witnessPublicKeyPem?.trim() ?? "", null, 0),
|
|
650
|
+
checkpointTrust: emptyTail.checkpointTrust,
|
|
651
|
+
index: emptyIndex.index,
|
|
652
|
+
effectCompleteness,
|
|
653
|
+
tail: emptyTail.tail,
|
|
86
654
|
problems,
|
|
87
655
|
};
|
|
88
656
|
}
|
|
@@ -103,11 +671,14 @@ export async function verifyLedger(dir, opts = {}) {
|
|
|
103
671
|
};
|
|
104
672
|
let signaturesValid = 0;
|
|
105
673
|
let signaturesInvalid = 0;
|
|
674
|
+
const recordRefused = ed25519Refusal(anahtar);
|
|
675
|
+
if (recordRefused)
|
|
676
|
+
problems.push(recordRefused);
|
|
106
677
|
for (let i = 0; i < records.length; i += 1) {
|
|
107
678
|
const rec = records[i];
|
|
108
679
|
let gecerli = false;
|
|
109
680
|
try {
|
|
110
|
-
gecerli = verifyDecisionRecord(rec, anahtar ?? undefined);
|
|
681
|
+
gecerli = !recordRefused && verifyDecisionRecord(rec, anahtar ?? undefined);
|
|
111
682
|
}
|
|
112
683
|
catch {
|
|
113
684
|
gecerli = false;
|
|
@@ -124,36 +695,154 @@ export async function verifyLedger(dir, opts = {}) {
|
|
|
124
695
|
// Returns `{ index, reason }` or null. The reason is carried through: a
|
|
125
696
|
// broken link and a bad signature are different accidents, and a reader
|
|
126
697
|
// chasing one should not be told the other.
|
|
127
|
-
|
|
698
|
+
let brk = null;
|
|
699
|
+
try {
|
|
700
|
+
brk = findDecisionRecordChainBreak(records, anahtar ? [anahtar] : undefined);
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
problems.push(err instanceof Error ? err.message : "record shape is not a decision");
|
|
704
|
+
}
|
|
128
705
|
const chainBreakAt = brk ? brk.index : null;
|
|
129
706
|
if (brk) {
|
|
130
707
|
problems.push(`chain breaks at record ${brk.index} (${brk.reason}): a row was changed, removed or inserted`);
|
|
131
708
|
}
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
709
|
+
// An effect is bound only when a decision with that ref records the same
|
|
710
|
+
// effectHash. A `:threw` row relaxes the hash and nothing else: it binds
|
|
711
|
+
// only when some decision on that ref is an allow and the row's class is
|
|
712
|
+
// exactly that decision's effectClass plus `:threw`. A deny did not throw,
|
|
713
|
+
// and an allow of a different tool is not this class. A `duplicate-effect`
|
|
714
|
+
// row must carry the writer's fixed refusal hash. The attestation plus
|
|
715
|
+
// receipt still verify under one effect key. A mismatch is named and counts
|
|
716
|
+
// as orphaned. A second primary row for a ref that already has one is named
|
|
717
|
+
// and is not bound, even when the first row itself did not bind.
|
|
135
718
|
const refler = new Set();
|
|
719
|
+
const hashesByRef = new Map();
|
|
720
|
+
const allowedClassesByRef = new Map();
|
|
136
721
|
for (const r of records) {
|
|
137
|
-
if (typeof r.claims?.ref
|
|
138
|
-
|
|
722
|
+
if (typeof r.claims?.ref !== "string")
|
|
723
|
+
continue;
|
|
724
|
+
refler.add(r.claims.ref);
|
|
725
|
+
const recorded = r.claims?.effectHash;
|
|
726
|
+
if (typeof recorded === "string") {
|
|
727
|
+
const bag = hashesByRef.get(r.claims.ref) ?? new Set();
|
|
728
|
+
bag.add(recorded);
|
|
729
|
+
hashesByRef.set(r.claims.ref, bag);
|
|
730
|
+
}
|
|
731
|
+
if (r.claims.decision === "allow" && typeof r.claims.effectClass === "string" && r.claims.effectClass !== "") {
|
|
732
|
+
const allowed = allowedClassesByRef.get(r.claims.ref) ?? new Set();
|
|
733
|
+
allowed.add(r.claims.effectClass);
|
|
734
|
+
allowedClassesByRef.set(r.claims.ref, allowed);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const effectRows = [];
|
|
738
|
+
for (const path of files.effects) {
|
|
739
|
+
for (const row of readJsonl(path, problems)) {
|
|
740
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
741
|
+
problems.push("effect is not an effect row");
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
effectRows.push(row);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const pinnedEffect = opts.effectPublicKeyPem?.trim() ?? "";
|
|
748
|
+
const pinnedWitness = opts.witnessPublicKeyPem?.trim() ?? "";
|
|
749
|
+
const selfKeyTaken = firstReceiptKey(effectRows, false);
|
|
750
|
+
const sameKeyTaken = firstReceiptKey(effectRows, true);
|
|
751
|
+
const effectKey = pinnedEffect !== "" ? pinnedEffect : selfKeyTaken;
|
|
752
|
+
const witnessKey = pinnedWitness !== "" ? pinnedWitness : sameKeyTaken;
|
|
753
|
+
const selfCount = effectRows.filter((row) => row.witnessClass !== "same-org").length;
|
|
754
|
+
const sameCount = effectRows.length - selfCount;
|
|
755
|
+
const effectTrust = effectTrustOf(pinnedEffect, selfKeyTaken, selfCount);
|
|
756
|
+
const witnessTrust = witnessTrustOf(pinnedWitness, sameKeyTaken, sameCount);
|
|
757
|
+
if (pinnedEffect !== "" && pinnedWitness === "" && sameCount > 0) {
|
|
758
|
+
problems.push("same-org rows need --witness-key when --effect-key is pinned");
|
|
759
|
+
}
|
|
760
|
+
if (pinnedWitness !== "" && pinnedEffect === "" && selfCount > 0) {
|
|
761
|
+
problems.push("self rows need --effect-key when --witness-key is pinned");
|
|
139
762
|
}
|
|
140
|
-
|
|
763
|
+
noteEd25519(effectKey, problems);
|
|
764
|
+
noteEd25519(witnessKey, problems);
|
|
765
|
+
let effects = effectRows.length;
|
|
141
766
|
let effectsBound = 0;
|
|
142
767
|
let effectsOrphaned = 0;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
768
|
+
const boundPrimaryRefs = new Set();
|
|
769
|
+
const primarySeen = new Set();
|
|
770
|
+
const duplicateSeen = new Set();
|
|
771
|
+
for (const e of effectRows) {
|
|
772
|
+
const ref = typeof e.row?.ref === "string" ? e.row.ref : null;
|
|
773
|
+
const effectHash = typeof e.row?.effectHash === "string" ? e.row.effectHash : null;
|
|
774
|
+
const effectClass = typeof e.row?.effectClass === "string" ? e.row.effectClass : "";
|
|
775
|
+
if (effectClass === "duplicate-effect") {
|
|
776
|
+
const sig = typeof e.attestation?.coseHex === "string" ? e.attestation.coseHex : "";
|
|
777
|
+
const dupKey = `${ref ?? ""}\0${sig}`;
|
|
778
|
+
if (sig !== "" && duplicateSeen.has(dupKey)) {
|
|
779
|
+
problems.push(`duplicate-effect row repeated: ref ${ref ?? "(missing)"}`);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (sig !== "")
|
|
783
|
+
duplicateSeen.add(dupKey);
|
|
784
|
+
const fixed = ref === null ? null : sha256Canonical({ refused: "duplicate-effect", ref });
|
|
785
|
+
const boundRefusal = ref !== null && refler.has(ref) && effectHash !== null && effectHash === fixed;
|
|
786
|
+
if (!boundRefusal) {
|
|
787
|
+
effectsOrphaned += 1;
|
|
788
|
+
problems.push(ref === null || !refler.has(ref)
|
|
789
|
+
? `duplicate-effect with no decision: ref ${ref ?? "(missing)"}`
|
|
790
|
+
: `duplicate-effect hash is not the fixed refusal: ref ${ref}`);
|
|
791
|
+
continue;
|
|
150
792
|
}
|
|
151
|
-
|
|
793
|
+
if (!effectSignatureCoversRow(e, e.witnessClass === "same-org" ? witnessKey : effectKey)) {
|
|
152
794
|
effectsOrphaned += 1;
|
|
153
|
-
problems.push(
|
|
795
|
+
problems.push(effectProblem(e, ref));
|
|
796
|
+
continue;
|
|
154
797
|
}
|
|
798
|
+
effectsBound += 1;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (ref !== null && primarySeen.has(ref)) {
|
|
802
|
+
problems.push(`ref ${ref} has more than one effect row`);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (ref !== null)
|
|
806
|
+
primarySeen.add(ref);
|
|
807
|
+
const hashes = ref === null ? undefined : hashesByRef.get(ref);
|
|
808
|
+
const hashMatch = effectHash !== null && hashes?.has(effectHash) === true;
|
|
809
|
+
if (effectClass.endsWith(":threw")) {
|
|
810
|
+
const allowed = ref === null ? undefined : allowedClassesByRef.get(ref);
|
|
811
|
+
let thrownOk = false;
|
|
812
|
+
if (allowed) {
|
|
813
|
+
for (const cls of allowed) {
|
|
814
|
+
if (`${cls}:threw` === effectClass) {
|
|
815
|
+
thrownOk = true;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (!thrownOk) {
|
|
821
|
+
effectsOrphaned += 1;
|
|
822
|
+
problems.push(`thrown effect does not match an allowed decision: ref ${ref ?? "(missing)"}`);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
else if (!hashMatch) {
|
|
827
|
+
effectsOrphaned += 1;
|
|
828
|
+
problems.push(ref === null || !refler.has(ref)
|
|
829
|
+
? `effect with no decision: ref ${ref ?? "(missing)"}`
|
|
830
|
+
: `effect hash does not match its decision: ref ${ref}`);
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (!effectSignatureCoversRow(e, e.witnessClass === "same-org" ? witnessKey : effectKey)) {
|
|
834
|
+
effectsOrphaned += 1;
|
|
835
|
+
problems.push(effectProblem(e, ref));
|
|
836
|
+
continue;
|
|
155
837
|
}
|
|
838
|
+
effectsBound += 1;
|
|
839
|
+
if (ref !== null)
|
|
840
|
+
boundPrimaryRefs.add(ref);
|
|
156
841
|
}
|
|
842
|
+
const indexed = indexStatement(dir, refler);
|
|
843
|
+
const effectCompleteness = noteEffectCompleteness(indexed.effectRefs, boundPrimaryRefs, problems);
|
|
844
|
+
const tailed = tailStatement(dir, records, opts.checkpointPublicKeyPem ?? "");
|
|
845
|
+
problems.push(...indexed.problems, ...tailed.problems);
|
|
157
846
|
const ok = problems.length === 0 && signaturesInvalid === 0 && chainBreakAt === null && effectsOrphaned === 0;
|
|
158
847
|
return {
|
|
159
848
|
ok,
|
|
@@ -166,6 +855,12 @@ export async function verifyLedger(dir, opts = {}) {
|
|
|
166
855
|
effectsBound,
|
|
167
856
|
effectsOrphaned,
|
|
168
857
|
trust,
|
|
858
|
+
effectTrust,
|
|
859
|
+
witnessTrust,
|
|
860
|
+
checkpointTrust: tailed.checkpointTrust,
|
|
861
|
+
index: indexed.index,
|
|
862
|
+
effectCompleteness,
|
|
863
|
+
tail: tailed.tail,
|
|
169
864
|
problems,
|
|
170
865
|
};
|
|
171
866
|
}
|