@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.
- package/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- 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
|
+
}
|