@cirvix_ai/agent-control 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. package/src/testing.mjs +145 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Human-in-the-loop approvals, locally.
3
+ *
4
+ * A REQUIRE_APPROVAL decision suspends a call for a person. This is the local
5
+ * queue that person works from — a JSONL file next to the audit chain, plus
6
+ * `cirvix approvals` / `cirvix approve` / `cirvix deny` to work it.
7
+ *
8
+ * WHY THE DEFAULT IS NON-BLOCKING
9
+ *
10
+ * The obvious design has `request()` wait until somebody answers. It is wrong
11
+ * for the common case: the agent is a subprocess of an editor, nobody is
12
+ * watching a second terminal, and a blocking hold looks exactly like a hung
13
+ * tool call. The agent sits there until it times out, and the operator's
14
+ * conclusion is that Cirvix broke their editor.
15
+ *
16
+ * So by default `request()` records the approval and returns `pending`
17
+ * immediately. The gateway renders that as a readable tool result naming the
18
+ * approval id — the agent learns the call is waiting on a named human, and can
19
+ * say so or work on something else. `--wait` opts into blocking for the case
20
+ * where somebody genuinely is watching, and it always has a timeout.
21
+ *
22
+ * THE STATE MACHINE IS DELIBERATELY SMALL
23
+ *
24
+ * pending ──approve──▶ approved (terminal)
25
+ * ──deny─────▶ denied (terminal)
26
+ * ──expire───▶ expired (terminal)
27
+ *
28
+ * Terminal means terminal: an approved request cannot later be denied, and a
29
+ * decided request cannot be decided twice. Without that, "who approved this"
30
+ * has more than one answer and the record stops being evidence.
31
+ */
32
+
33
+ import { appendFile, readFile } from "node:fs/promises";
34
+ import { createHash } from "node:crypto";
35
+
36
+ import { requestId } from "./normalize.mjs";
37
+
38
+ export const STATE = {
39
+ PENDING: "pending",
40
+ APPROVED: "approved",
41
+ DENIED: "denied",
42
+ EXPIRED: "expired",
43
+ /** Approved, and the call it authorized has since run. */
44
+ CONSUMED: "consumed",
45
+ };
46
+
47
+ const TERMINAL = new Set([STATE.APPROVED, STATE.DENIED, STATE.EXPIRED, STATE.CONSUMED]);
48
+
49
+ /** Default lifetime of an unanswered approval. */
50
+ const DEFAULT_TTL_MS = 15 * 60 * 1000;
51
+
52
+ /**
53
+ * How long an approval stays spendable after a person grants it.
54
+ *
55
+ * Shorter than the pending TTL on purpose. "Yes, do that" means yes to the
56
+ * thing in front of the approver now — not to the same call at 3am next
57
+ * Tuesday, by which time the state it was reasoning about has changed.
58
+ */
59
+ const DEFAULT_GRANT_TTL_MS = 10 * 60 * 1000;
60
+
61
+ /**
62
+ * What an approval is an approval OF.
63
+ *
64
+ * This is the difference between a working control and a confused deputy. An
65
+ * approval bound to a *tool* means approving `database.write` on `audit_log`
66
+ * also authorizes `database.write` on `salaries` — the agent asks once,
67
+ * gets a yes, and spends it on something else. So the grant is bound to the
68
+ * exact call: agent, action, canonical resource, command, and a hash of the
69
+ * arguments.
70
+ *
71
+ * The ARGUMENTS ARE HASHED, NEVER STORED. They routinely contain credential
72
+ * material, and an approval queue is a file an operator reads and a console
73
+ * displays. A hash binds precisely and discloses nothing.
74
+ *
75
+ * THE CHAIN OF CUSTODY IS PART OF WHAT WAS APPROVED.
76
+ *
77
+ * It was not, and the gap was approval laundering. An operator approves a
78
+ * database write and is shown `planner → worker`. The same agent, tool and
79
+ * arguments then arrive under `planner → attacker → worker`, and every check
80
+ * passes: identity really is `worker`, the chain really does narrow correctly,
81
+ * and policy really did ask for an approval that really was granted. Four
82
+ * subsystems agreeing about four different operations.
83
+ *
84
+ * The inverse mattered just as much: get the approval while acting under a
85
+ * narrow delegation, then present none at all. Without a delegation the call is
86
+ * governed by policy alone — which is wider — so the yes would be released into
87
+ * a larger authority than the one it was granted under.
88
+ *
89
+ * `null` for a call made with no delegation, which keeps single-agent
90
+ * deployments byte-identical to before.
91
+ */
92
+ export function approvalFingerprint(call) {
93
+ const canonical = JSON.stringify({
94
+ agent: call.agent ?? null,
95
+ action: call.action ?? call.tool ?? null,
96
+ resource: call.resource ?? "",
97
+ command: call.command ?? null,
98
+ delegation: call.delegation?.principals ?? null,
99
+ args: stableStringify(call.arguments ?? {}),
100
+ });
101
+ return "sha256:" + createHash("sha256").update(canonical).digest("hex").slice(0, 32);
102
+ }
103
+
104
+ /** Key-sorted serialization, so argument order cannot change the fingerprint. */
105
+ function stableStringify(value, depth = 0) {
106
+ if (depth > 12 || value === null || typeof value !== "object") return JSON.stringify(value);
107
+ if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v, depth + 1)).join(",")}]`;
108
+ return `{${Object.keys(value)
109
+ .sort()
110
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k], depth + 1)}`)
111
+ .join(",")}}`;
112
+ }
113
+
114
+ export class ApprovalStore {
115
+ /** id → record, rebuilt from the log on open. */
116
+ #byId = new Map();
117
+
118
+ /**
119
+ * @param {string} path JSONL log; every state transition is appended
120
+ * @param {object} [opts]
121
+ * @param {number} [opts.ttlMs]
122
+ * @param {(e:object)=>void} [opts.onEvent]
123
+ */
124
+ constructor(path, { ttlMs = DEFAULT_TTL_MS, grantTtlMs = DEFAULT_GRANT_TTL_MS, onEvent = () => {} } = {}) {
125
+ this.path = path;
126
+ this.ttlMs = ttlMs;
127
+ /** How long a granted approval stays spendable. See DEFAULT_GRANT_TTL_MS. */
128
+ this.grantTtlMs = grantTtlMs;
129
+ this.onEvent = onEvent;
130
+ }
131
+
132
+ /**
133
+ * Replays the log to rebuild current state.
134
+ *
135
+ * Append-only with replay, rather than rewriting a state file: the history of
136
+ * who decided what and when is the point, and a mutable file loses it on the
137
+ * first concurrent write.
138
+ */
139
+ async open() {
140
+ let text = "";
141
+ try {
142
+ text = await readFile(this.path, "utf8");
143
+ } catch {
144
+ return this;
145
+ }
146
+ for (const line of text.split("\n").filter(Boolean)) {
147
+ let entry;
148
+ try {
149
+ entry = JSON.parse(line);
150
+ } catch {
151
+ continue;
152
+ }
153
+ if (entry.type === "request") {
154
+ this.#byId.set(entry.id, { ...entry, state: STATE.PENDING });
155
+ } else if (entry.type === "decision") {
156
+ const existing = this.#byId.get(entry.id);
157
+ if (!existing) continue;
158
+
159
+ // Spending a grant is the one transition allowed out of a terminal
160
+ // state, because APPROVED is terminal for *deciding* and not for
161
+ // *using*. Replayed explicitly so a restart mid-run cannot resurrect a
162
+ // grant that was already spent — otherwise a crash between execution
163
+ // and the next start turns a single-use approval into a reusable one.
164
+ if (entry.state === STATE.CONSUMED) {
165
+ if (existing.state === STATE.APPROVED) {
166
+ existing.state = STATE.CONSUMED;
167
+ existing.consumedAt = entry.ts;
168
+ existing.consumedBy = entry.consumedBy ?? null;
169
+ }
170
+ continue;
171
+ }
172
+
173
+ // Any other decision for an already-terminal request is ignored, so a
174
+ // hand-edited log cannot rewrite history by appending a second verdict.
175
+ if (!TERMINAL.has(existing.state)) {
176
+ existing.state = entry.state;
177
+ existing.decidedBy = entry.decidedBy;
178
+ existing.decidedAt = entry.ts;
179
+ existing.note = entry.note ?? null;
180
+ }
181
+ }
182
+ }
183
+ return this;
184
+ }
185
+
186
+ /**
187
+ * Records a call waiting on a human.
188
+ *
189
+ * Returns immediately with `pending` unless `wait` is set. Identical calls do
190
+ * NOT deduplicate: two attempts to deploy to production are two decisions a
191
+ * person should make, even if the arguments match.
192
+ *
193
+ * @returns {Promise<{id:string,state:string,decidedBy?:string}>}
194
+ */
195
+ async request(fields, { wait = 0, pollMs = 500 } = {}) {
196
+ const id = fields.approval_id ?? requestId("apr");
197
+ const record = {
198
+ type: "request",
199
+ id,
200
+ ts: new Date().toISOString(),
201
+ expiresAt: new Date(Date.now() + this.ttlMs).toISOString(),
202
+ request_id: fields.request_id ?? null,
203
+ agent: fields.agent ?? null,
204
+ tool: fields.tool ?? null,
205
+ resource: fields.resource ?? null,
206
+ risk: fields.risk ?? null,
207
+ rule: fields.rule ?? null,
208
+ reason: fields.reason ?? null,
209
+ approvers: fields.approvers ?? [],
210
+ // What this approval is an approval OF. Without it a grant is bound to a
211
+ // tool rather than to a call, and one yes authorizes every later use of
212
+ // that tool.
213
+ fingerprint: fields.fingerprint ?? null,
214
+ };
215
+
216
+ this.#byId.set(id, { ...record, state: STATE.PENDING });
217
+ await this.#write(record);
218
+ this.onEvent({ kind: "approval_requested", ...record });
219
+
220
+ if (!wait) return { id, state: STATE.PENDING };
221
+
222
+ // Polling rather than watching the file: a watcher is one more failure mode
223
+ // across three platforms, and an approval is a human-scale event where a
224
+ // 500ms poll is imperceptible.
225
+ const deadline = Date.now() + wait;
226
+ while (Date.now() < deadline) {
227
+ const current = this.get(id);
228
+ if (current && TERMINAL.has(current.state)) {
229
+ return { id, state: current.state, decidedBy: current.decidedBy };
230
+ }
231
+ await new Promise((r) => setTimeout(r, pollMs));
232
+ await this.open();
233
+ }
234
+ return { id, state: STATE.PENDING };
235
+ }
236
+
237
+ /**
238
+ * Decides a pending approval.
239
+ *
240
+ * @param {string} id
241
+ * @param {"approved"|"denied"} state
242
+ * @param {string} decidedBy who is accountable — never defaulted silently
243
+ */
244
+ async decide(id, state, decidedBy, note = null) {
245
+ if (state !== STATE.APPROVED && state !== STATE.DENIED) {
246
+ throw new Error(`Approvals are approved or denied, not "${state}".`);
247
+ }
248
+ if (!decidedBy) {
249
+ throw new Error("An approval decision must name who made it.");
250
+ }
251
+
252
+ const record = this.get(id);
253
+ if (!record) throw new Error(`No approval with id ${id}.`);
254
+ if (TERMINAL.has(record.state)) {
255
+ throw new Error(
256
+ `Approval ${id} is already ${record.state}${record.decidedBy ? ` (by ${record.decidedBy})` : ""}. A decided approval cannot be decided again.`,
257
+ );
258
+ }
259
+ if (this.#isExpired(record)) {
260
+ await this.#expire(record);
261
+ throw new Error(`Approval ${id} expired at ${record.expiresAt} and can no longer be decided.`);
262
+ }
263
+
264
+ const entry = {
265
+ type: "decision",
266
+ id,
267
+ ts: new Date().toISOString(),
268
+ state,
269
+ decidedBy,
270
+ note,
271
+ };
272
+ record.state = state;
273
+ record.decidedBy = decidedBy;
274
+ record.decidedAt = entry.ts;
275
+ record.note = note;
276
+
277
+ await this.#write(entry);
278
+ this.onEvent({ kind: "approval_decided", id, state, decidedBy });
279
+ return record;
280
+ }
281
+
282
+ get(id) {
283
+ const record = this.#byId.get(id);
284
+ if (record && record.state === STATE.PENDING && this.#isExpired(record)) {
285
+ record.state = STATE.EXPIRED;
286
+ }
287
+ return record ?? null;
288
+ }
289
+
290
+ /**
291
+ * An approved, unspent grant for exactly this call — or null.
292
+ *
293
+ * THIS METHOD IS THE ENTIRE POINT OF THE APPROVAL FEATURE, AND IT DID NOT
294
+ * EXIST.
295
+ *
296
+ * Without it there is no path from "a person said yes" to "the call runs":
297
+ * every submission created a fresh pending request, so an approved approval
298
+ * released nothing and the agent retried into a new queue entry forever. A
299
+ * hold that can never be released is a denial with extra steps, and the
300
+ * human-in-the-loop feature was decorative. The state-machine tests found it.
301
+ *
302
+ * Three properties, each load-bearing:
303
+ *
304
+ * MATCHED BY FINGERPRINT, not by tool. Approving `database.write` on
305
+ * `audit_log` must not release `database.write` on `salaries`.
306
+ *
307
+ * SINGLE USE. A grant authorizes one execution. Otherwise one yes
308
+ * authorizes an unbounded number of identical calls, forever, which is not
309
+ * what anybody means when they click approve.
310
+ *
311
+ * SEPARATELY EXPIRING. A grant goes stale faster than a pending request,
312
+ * because "yes, do that" refers to the situation the approver was looking
313
+ * at.
314
+ */
315
+ findGrant(fingerprint) {
316
+ if (!fingerprint) return null;
317
+ for (const record of this.#byId.values()) {
318
+ if (record.state !== STATE.APPROVED) continue;
319
+ if (record.fingerprint !== fingerprint) continue;
320
+ if (this.#grantExpired(record)) continue;
321
+ return record;
322
+ }
323
+ return null;
324
+ }
325
+
326
+ #grantExpired(record) {
327
+ if (!record.decidedAt) return false;
328
+ return Date.now() - new Date(record.decidedAt).getTime() > this.grantTtlMs;
329
+ }
330
+
331
+ /**
332
+ * Spends a grant, recording which call spent it.
333
+ *
334
+ * Append-only like every other transition, so "what did that approval
335
+ * authorize" has an answer after the fact — which is the question asked in
336
+ * the incident review, not during the run.
337
+ */
338
+ async consume(id, requestIdentifier) {
339
+ const record = this.get(id);
340
+ if (!record) throw new Error(`No approval with id ${id}.`);
341
+ if (record.state !== STATE.APPROVED) {
342
+ throw new Error(`Approval ${id} is ${record.state}, not approved; it cannot be spent.`);
343
+ }
344
+ if (this.#grantExpired(record)) {
345
+ throw new Error(`Approval ${id} was granted too long ago to spend.`);
346
+ }
347
+
348
+ const entry = {
349
+ type: "decision",
350
+ id,
351
+ ts: new Date().toISOString(),
352
+ state: STATE.CONSUMED,
353
+ decidedBy: record.decidedBy,
354
+ consumedBy: requestIdentifier ?? null,
355
+ };
356
+ record.state = STATE.CONSUMED;
357
+ record.consumedAt = entry.ts;
358
+ record.consumedBy = entry.consumedBy;
359
+
360
+ await this.#write(entry);
361
+ this.onEvent({ kind: "approval_consumed", id, requestId: requestIdentifier ?? null });
362
+ return record;
363
+ }
364
+
365
+ /** Everything still waiting on somebody, oldest first. */
366
+ pending() {
367
+ return [...this.#byId.values()]
368
+ .filter((r) => this.get(r.id)?.state === STATE.PENDING)
369
+ .sort((a, b) => a.ts.localeCompare(b.ts));
370
+ }
371
+
372
+ all() {
373
+ return [...this.#byId.values()].map((r) => this.get(r.id));
374
+ }
375
+
376
+ #isExpired(record) {
377
+ return Boolean(record.expiresAt && Date.now() > new Date(record.expiresAt).getTime());
378
+ }
379
+
380
+ async #expire(record) {
381
+ record.state = STATE.EXPIRED;
382
+ await this.#write({ type: "decision", id: record.id, ts: new Date().toISOString(), state: STATE.EXPIRED, decidedBy: null });
383
+ }
384
+
385
+ async #write(entry) {
386
+ await appendFile(this.path, JSON.stringify(entry) + "\n", "utf8");
387
+ }
388
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The audit chain.
3
+ *
4
+ * Append-only JSONL where every record commits to the hash of its
5
+ * predecessor. Editing or removing any record breaks verification from that
6
+ * point forward, and `verify()` reports exactly where.
7
+ *
8
+ * What this does and does not prove — stated here because the distinction is
9
+ * the whole value and it is routinely overstated by vendors:
10
+ *
11
+ * PROVES: no record was altered or removed after it was written,
12
+ * assuming any published checkpoint root is trusted.
13
+ * DOES NOT: prove a record was written truthfully in the first place.
14
+ * That property comes from the enforcement path, not the log.
15
+ * DOES NOT: prevent destruction. Someone with disk access can delete the
16
+ * file. The chain guarantees that doing so is *visible*.
17
+ *
18
+ * Hashes are SHA-256 over a canonical JSON serialization — key order is fixed
19
+ * before hashing, because `JSON.stringify` preserves insertion order and two
20
+ * semantically identical records would otherwise hash differently.
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { appendFile, readFile } from "node:fs/promises";
25
+
26
+ const GENESIS = "sha256:" + "0".repeat(64);
27
+
28
+ /** Deterministic serialization — sorted keys, all the way down. */
29
+ export function canonicalJson(value) {
30
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
31
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
32
+ const keys = Object.keys(value).sort();
33
+ return `{${keys
34
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`)
35
+ .join(",")}}`;
36
+ }
37
+
38
+ export function hashRecord(record) {
39
+ const { hash: _ignored, ...rest } = record;
40
+ return "sha256:" + createHash("sha256").update(canonicalJson(rest)).digest("hex");
41
+ }
42
+
43
+ export class AuditChain {
44
+ #path;
45
+ #seq = 0;
46
+ #prev = GENESIS;
47
+ /** Serializes appends. See `append` for why this is not optional. */
48
+ #tail = Promise.resolve();
49
+
50
+ constructor(path) {
51
+ this.#path = path;
52
+ }
53
+
54
+ /** Reads the tail so appends continue an existing chain rather than forking it. */
55
+ async open() {
56
+ const records = await this.read();
57
+ const last = records[records.length - 1];
58
+ if (last) {
59
+ this.#seq = last.seq;
60
+ this.#prev = last.hash;
61
+ }
62
+ return this;
63
+ }
64
+
65
+ async read() {
66
+ let text = "";
67
+ try {
68
+ text = await readFile(this.#path, "utf8");
69
+ } catch {
70
+ return [];
71
+ }
72
+ return text
73
+ .split("\n")
74
+ .filter(Boolean)
75
+ .map((line) => {
76
+ try {
77
+ return JSON.parse(line);
78
+ } catch {
79
+ return { malformed: true, raw: line };
80
+ }
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Appends a decision. `ts` is injected rather than read from the clock so
86
+ * the chain is reproducible in tests and identical inputs hash identically.
87
+ *
88
+ * APPENDS ARE SERIALIZED, AND THAT IS LOAD-BEARING.
89
+ *
90
+ * The obvious implementation advances `#prev` synchronously and awaits the
91
+ * write. Under concurrency that is wrong in a way that is invisible until it
92
+ * matters: forty in-flight `append()` calls compute a correct chain in call
93
+ * order, then their writes land in whatever order the filesystem returns
94
+ * them, and the on-disk sequence no longer matches the hashes.
95
+ *
96
+ * The result is a chain that fails `verify()` on a run where nothing was
97
+ * tampered with. That is worse than having no chain at all — an operator
98
+ * investigating an incident sees "chain broken at record 5" and cannot tell
99
+ * it from an attacker having edited the log. The one signal the audit trail
100
+ * exists to provide is destroyed by ordinary load.
101
+ *
102
+ * Found by the consistency oracle under twenty concurrent calls.
103
+ */
104
+ async append(entry, ts) {
105
+ const queued = this.#tail.then(
106
+ () => this.#appendSerially(entry, ts),
107
+ () => this.#appendSerially(entry, ts),
108
+ );
109
+ // The queue must not break on one failed write, so the tail swallows the
110
+ // rejection. Callers still see it — `queued` is what they await.
111
+ this.#tail = queued.then(
112
+ () => undefined,
113
+ () => undefined,
114
+ );
115
+ return queued;
116
+ }
117
+
118
+ /**
119
+ * The critical section: build the record, write it, and only then advance.
120
+ *
121
+ * In-memory state is committed AFTER the write resolves. A failed append
122
+ * therefore leaves the chain where it was, so the next record continues from
123
+ * the last durable one rather than from a hash that was never persisted.
124
+ */
125
+ async #appendSerially(entry, ts) {
126
+ const seq = this.#seq + 1;
127
+ const record = {
128
+ seq,
129
+ ts: ts ?? new Date().toISOString(),
130
+ prev_hash: this.#prev,
131
+ ...entry,
132
+ };
133
+ record.hash = hashRecord(record);
134
+
135
+ await appendFile(this.#path, JSON.stringify(record) + "\n", "utf8");
136
+
137
+ this.#seq = seq;
138
+ this.#prev = record.hash;
139
+ return record;
140
+ }
141
+
142
+ /** Resolves once every queued append has been written. */
143
+ async flush() {
144
+ await this.#tail;
145
+ }
146
+
147
+ /**
148
+ * Recomputes the chain. Returns the first break rather than a boolean, so an
149
+ * operator learns *where* tampering starts, not merely that it happened.
150
+ */
151
+ async verify() {
152
+ const records = await this.read();
153
+ let prev = GENESIS;
154
+
155
+ for (let i = 0; i < records.length; i++) {
156
+ const r = records[i];
157
+ if (r.malformed) {
158
+ return { ok: false, records: records.length, brokenAt: i, reason: "Malformed record — line is not valid JSON." };
159
+ }
160
+ if (r.prev_hash !== prev) {
161
+ return {
162
+ ok: false,
163
+ records: records.length,
164
+ brokenAt: r.seq,
165
+ reason: `Record ${r.seq} does not follow its predecessor. A record was altered or removed before this point.`,
166
+ };
167
+ }
168
+ if (hashRecord(r) !== r.hash) {
169
+ return {
170
+ ok: false,
171
+ records: records.length,
172
+ brokenAt: r.seq,
173
+ reason: `Record ${r.seq} has been modified — its contents no longer match its hash.`,
174
+ };
175
+ }
176
+ prev = r.hash;
177
+ }
178
+
179
+ return { ok: true, records: records.length, head: prev };
180
+ }
181
+ }