@nimbus-dev/connectors 0.2.2 → 0.2.3

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.
@@ -1,12 +1,29 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
2
- import { appendFile, readFile } from "node:fs/promises";
1
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, readlinkSync } from "node:fs";
3
+ import {
4
+ appendFile,
5
+ type FileHandle,
6
+ link,
7
+ open,
8
+ readFile,
9
+ realpath,
10
+ rename,
11
+ stat,
12
+ unlink,
13
+ } from "node:fs/promises";
14
+ import { hostname } from "node:os";
15
+ import { dirname, resolve } from "node:path";
16
+ import { setTimeout as sleep } from "node:timers/promises";
3
17
 
4
18
  /**
5
19
  * What happened to one gated action.
6
20
  *
7
- * `refused` is a server-side denial (out of scope, budget exhausted, or no elicitation-capable
8
- * client); `declined` is a human saying no. They are kept distinct because only one of them means a
9
- * person was actually asked.
21
+ * `declined` is a human saying no, so it always means a person was asked. `refused` is a
22
+ * server-side denial and does not say on its own whether anyone was asked; its `reason` does.
23
+ * `out of scope` and `budget exhausted` are decided before any prompt. `budget exhausted after
24
+ * approval` follows an `accepted` entry: writes in flight together spent the last of the budget
25
+ * while this one's prompt was open, so a human approved a write that then did not run. A client
26
+ * that cannot prompt is never offered a write tool at all, so it leaves no entry.
10
27
  */
11
28
  export type AuditOutcome =
12
29
  | "requested"
@@ -77,7 +94,7 @@ function canonicalJson(value: unknown): string {
77
94
  * `ALLOWED_CONNECTOR_DEPS`, and the standalone artifact must run under Node. Same construction,
78
95
  * different primitive.
79
96
  */
80
- function linkHash(prev: string, entry: AuditEntry): string {
97
+ function linkHash(prev: string, entry: unknown): string {
81
98
  return createHash("sha256").update(prev).update(canonicalJson(entry)).digest("hex");
82
99
  }
83
100
 
@@ -88,29 +105,564 @@ function hashEquals(a: string, b: string): boolean {
88
105
  return ab.length === bb.length && timingSafeEqual(ab, bb);
89
106
  }
90
107
 
108
+ function errorCode(e: unknown): string | undefined {
109
+ if (typeof e !== "object" || e === null || !("code" in e)) return undefined;
110
+ return typeof e.code === "string" ? e.code : undefined;
111
+ }
112
+
91
113
  async function readLines(path: string): Promise<string[]> {
92
114
  let text: string;
93
115
  try {
94
116
  text = await readFile(path, "utf8");
95
- } catch {
96
- return [];
117
+ } catch (e) {
118
+ // Only a log that does not exist yet is empty. Any other failure to read it — a permission, a
119
+ // directory at the path, a Windows sharing violation — says nothing about its contents, and
120
+ // reading it as empty chained the next entry to the genesis hash in the middle of a log that
121
+ // already had entries, and made verification pass on a log nobody could read.
122
+ if (errorCode(e) === "ENOENT") return [];
123
+ throw e;
97
124
  }
98
125
  const trimmed = text.trimEnd();
99
126
  return trimmed === "" ? [] : trimmed.split("\n");
100
127
  }
101
128
 
102
- /** Append one entry, linked to the current tail. */
103
- export async function appendAuditEntry(path: string, entry: AuditEntry): Promise<void> {
104
- const lines = await readLines(path);
105
- const last = lines.at(-1);
106
- const prev = last === undefined ? GENESIS_HASH : (JSON.parse(last) as ChainedLine).hash;
107
- const line: ChainedLine = {
108
- seq: lines.length + 1,
109
- prev,
110
- hash: linkHash(prev, entry),
111
- entry,
112
- };
113
- await appendFile(path, `${JSON.stringify(line)}\n`, "utf8");
129
+ /**
130
+ * How long one writer may hold a log's lock, and how long another waits for it.
131
+ *
132
+ * An append holds the lock for one read of the log and one write: 85 ms for a 155 MB log, measured.
133
+ */
134
+ export type AuditLockOptions = {
135
+ /**
136
+ * Once a lock file is older than this, its holder is taken to be gone and the lock is taken over,
137
+ * which is what lets the log recover from a writer killed mid-append. Ten seconds is two orders
138
+ * of magnitude above any append a live writer makes.
139
+ */
140
+ readonly staleMs: number;
141
+ /**
142
+ * How long an append waits for the lock before it fails. Longer than `staleMs`, so a waiter
143
+ * always outlasts a left-behind lock it could not prove abandoned straight away.
144
+ */
145
+ readonly timeoutMs: number;
146
+ /**
147
+ * The file operations the lock is made of, `node:fs/promises` unless given. Only tests pass
148
+ * this, to reproduce failures that only some filesystems produce: WSL's `/mnt` drives among them.
149
+ */
150
+ readonly fs?: LockFs;
151
+ };
152
+
153
+ /** One open lock file. */
154
+ export type LockHandle = Pick<FileHandle, "readFile" | "stat" | "writeFile" | "close">;
155
+
156
+ /** The file operations the lock is made of. The log itself is read and written directly. */
157
+ export type LockFs = {
158
+ readonly open: (path: string, flags: "r" | "wx") => Promise<LockHandle>;
159
+ readonly readFile: (path: string, encoding: "utf8") => Promise<string>;
160
+ readonly rename: (from: string, to: string) => Promise<void>;
161
+ readonly link: (existing: string, created: string) => Promise<void>;
162
+ readonly unlink: (path: string) => Promise<void>;
163
+ readonly stat: (path: string) => Promise<{ isDirectory(): boolean }>;
164
+ };
165
+
166
+ const NODE_FS: LockFs = { open, readFile, rename, link, unlink, stat };
167
+
168
+ const DEFAULT_AUDIT_LOCK: AuditLockOptions = { staleMs: 10_000, timeoutMs: 30_000 };
169
+
170
+ /**
171
+ * How many times `readSettled` reads before it gives up, with `backoffMs` between: under a tenth of
172
+ * a second in all.
173
+ */
174
+ const SETTLE_ATTEMPTS = 6;
175
+
176
+ /** The appends queued in THIS process, per log; each starts when the one before it has settled. */
177
+ const appendQueues = new Map<string, Promise<void>>();
178
+
179
+ function inQueue(key: string, task: () => Promise<void>): Promise<void> {
180
+ const run = (appendQueues.get(key) ?? Promise.resolve()).then(task);
181
+ // A failed append must not stall the ones behind it, so the next waits on the settled result.
182
+ const settled = run.catch(() => undefined);
183
+ appendQueues.set(key, settled);
184
+ // `settled` never rejects, and nothing waits on its cleanup.
185
+ void settled.then(() => {
186
+ if (appendQueues.get(key) === settled) appendQueues.delete(key);
187
+ });
188
+ return run;
189
+ }
190
+
191
+ type LockOwner = {
192
+ readonly pid: number;
193
+ readonly host: string;
194
+ readonly pidSpace: string | undefined;
195
+ };
196
+
197
+ function lockOwner(content: string): LockOwner | undefined {
198
+ let parsed: unknown;
199
+ try {
200
+ parsed = JSON.parse(content);
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ if (typeof parsed !== "object" || parsed === null) return undefined;
205
+ const { pid, host, pidSpace } = parsed as Record<string, unknown>;
206
+ if (typeof pid !== "number" || typeof host !== "string") return undefined;
207
+ return { pid, host, pidSpace: typeof pidSpace === "string" ? pidSpace : undefined };
208
+ }
209
+
210
+ /**
211
+ * The processes a pid probe from this process can see, named so that two processes share the name
212
+ * only when they see the same ones. On Linux that is the running kernel, by its boot id, and this
213
+ * process's PID namespace: containers, Flatpak sandboxes and WSL each run in a namespace of their
214
+ * own, often under the hostname of the machine they run on, and a pid from another namespace means
215
+ * nothing here. On Windows and macOS it is the platform: WSL runs under its Windows host's
216
+ * hostname, and the platform keeps its Linux pids apart from Windows ones.
217
+ *
218
+ * `undefined` where it cannot be read, and then no lock is judged by its pid at all.
219
+ */
220
+ function readPidSpace(): string | undefined {
221
+ if (process.platform === "win32" || process.platform === "darwin") return process.platform;
222
+ if (process.platform !== "linux") return undefined;
223
+ try {
224
+ const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
225
+ return `linux ${boot} ${readlinkSync("/proc/self/ns/pid")}`;
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /** Read once: a process stays in the PID namespace, and on the kernel, it started in. */
232
+ let ownPidSpace: { readonly value: string | undefined } | undefined;
233
+
234
+ function localPidSpace(): string | undefined {
235
+ ownPidSpace ??= { value: readPidSpace() };
236
+ return ownPidSpace.value;
237
+ }
238
+
239
+ /**
240
+ * Whether a process in this one's pid space has provably exited. Signal 0 tests for a process
241
+ * without delivering anything, and only ESRCH proves it gone: EPERM is a live process this one may
242
+ * not signal. A pid that is not a positive integer is never signalled, since 0 and negative pids
243
+ * address process groups.
244
+ */
245
+ function hasExited(pid: number): boolean {
246
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
247
+ try {
248
+ process.kill(pid, 0);
249
+ return false;
250
+ } catch (e) {
251
+ return errorCode(e) === "ESRCH";
252
+ }
253
+ }
254
+
255
+ type HeldLock = { readonly content: string; readonly ageMs: number };
256
+
257
+ /** Whether an error says the file is no longer there: ESTALE is NFS's word for it. */
258
+ function isGone(e: unknown): boolean {
259
+ const code = errorCode(e);
260
+ return code === "ENOENT" || code === "ESTALE";
261
+ }
262
+
263
+ /** Read a held lock through ONE handle, so its content and its age describe the same file. */
264
+ async function inspectLock(fs: LockFs, lockPath: string): Promise<HeldLock | undefined> {
265
+ let handle: LockHandle;
266
+ try {
267
+ handle = await fs.open(lockPath, "r");
268
+ } catch (e) {
269
+ // Released between the failed create and this read.
270
+ if (errorCode(e) === "ENOENT") return undefined;
271
+ throw e;
272
+ }
273
+ try {
274
+ const content = await handle.readFile("utf8");
275
+ const { mtimeMs } = await handle.stat();
276
+ return { content, ageMs: Date.now() - mtimeMs };
277
+ } finally {
278
+ await handle.close();
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Read a lock, trying again for a moment while the read fails or comes back empty. Resolves to the
284
+ * last content read, which can be empty, or to `undefined` when no read succeeded.
285
+ *
286
+ * A lock is empty only from its creation until its token is written, or for good when its holder
287
+ * died between the two, so an empty read is not yet an answer about whose it is. A failed read is
288
+ * often not one either: WSL's `/mnt` drives report a lock that was renamed a moment ago as missing,
289
+ * even to the process that renamed it. Measured there: 345 of 730 reads straight after a rename
290
+ * failed while two other processes were reading the lock, and a single retry a millisecond later
291
+ * read every one of them.
292
+ */
293
+ async function readSettled(fs: LockFs, path: string): Promise<string | undefined> {
294
+ let lastRead: string | undefined;
295
+ for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
296
+ if (attempt > 0) await sleep(backoffMs(attempt - 1)); // NOSONAR S9382: retries of one read, which must not overlap — the next read is worth making only once the lock has had time to settle.
297
+ try {
298
+ const content = await fs.readFile(path, "utf8");
299
+ if (content !== "") return content;
300
+ lastRead = content;
301
+ } catch {
302
+ // Missing for a moment, or not readable at all: tried again until the attempts run out.
303
+ }
304
+ }
305
+ return lastRead;
306
+ }
307
+
308
+ /**
309
+ * A lock may be taken over when its holder is provably gone — a process this one can see, that has
310
+ * exited — or when it is older than any live append. The second covers what the first cannot
311
+ * judge: a holder on another host or in another PID namespace, a pid that has since been reused,
312
+ * and a lock whose body was never written because its holder died between creating and writing it.
313
+ *
314
+ * The pid is probed only for a lock written on this host and in this pid space (`readPidSpace`).
315
+ * A holder the probe cannot see reads as exited while it runs: WSL beside Windows, both under one
316
+ * hostname, took over each other's live locks at once and broke the chain. On Windows and macOS
317
+ * the hostname is all that tells two machines apart, so two machines sharing a log must not share
318
+ * a hostname.
319
+ */
320
+ function isAbandoned(lock: HeldLock, staleMs: number): boolean {
321
+ if (lock.ageMs > staleMs) return true;
322
+ const owner = lockOwner(lock.content);
323
+ const here = localPidSpace();
324
+ return (
325
+ owner !== undefined &&
326
+ here !== undefined &&
327
+ owner.pidSpace === here &&
328
+ owner.host === hostname() &&
329
+ hasExited(owner.pid)
330
+ );
331
+ }
332
+
333
+ /**
334
+ * Remove the lock file only if it is the one whose content is `expected`; resolves to whether it
335
+ * was. Reading it and then unlinking it would remove a lock another writer took between the two
336
+ * calls, so the file is renamed aside first — one atomic step that claims exactly one file — and a
337
+ * file that turns out to be someone else's lock is linked back. So is one that cannot be read back,
338
+ * whose owner is then unknown, and this rejects.
339
+ */
340
+ async function removeLockIf(fs: LockFs, lockPath: string, expected: string): Promise<boolean> {
341
+ const aside = `${lockPath}.${randomBytes(6).toString("hex")}`;
342
+ try {
343
+ await fs.rename(lockPath, aside);
344
+ } catch (e) {
345
+ if (errorCode(e) === "ENOENT") return false;
346
+ throw e;
347
+ }
348
+ // Never judged from a failed read: on WSL's `/mnt` drives that made a writer give back its own
349
+ // lock, then report it taken over.
350
+ const moved = await readSettled(fs, aside);
351
+ if (moved !== expected) {
352
+ // Not the lock meant: give it back. If a third writer has taken the free name in the meantime
353
+ // this fails, and the displaced holder finds its lock gone at its own check before it appends,
354
+ // or when it releases, and its append refuses.
355
+ await fs.link(aside, lockPath).catch(() => undefined);
356
+ }
357
+ // Only a name nothing reads remains if this fails.
358
+ await fs.unlink(aside).catch(() => undefined);
359
+ if (moved === undefined) {
360
+ throw new Error(`could not read ${lockPath} back after moving it aside, so it was put back`);
361
+ }
362
+ return moved === expected;
363
+ }
364
+
365
+ /** Create the lock, holding `token`. False when another writer holds it. */
366
+ async function tryCreateLock(fs: LockFs, lockPath: string, token: string): Promise<boolean> {
367
+ let handle: LockHandle;
368
+ try {
369
+ // `wx` is O_CREAT | O_EXCL (CREATE_NEW on Windows): of all the writers racing here, exactly one
370
+ // creates the file, on all three platforms.
371
+ handle = await fs.open(lockPath, "wx");
372
+ } catch (e) {
373
+ if (errorCode(e) === "EEXIST") return false;
374
+ throw e;
375
+ }
376
+ let written = false;
377
+ try {
378
+ await handle.writeFile(token, "utf8");
379
+ written = true;
380
+ } finally {
381
+ await handle.close();
382
+ // A lock holding no token can only be judged by its age; do not leave one behind.
383
+ if (!written) await fs.unlink(lockPath).catch(() => undefined);
384
+ }
385
+ return true;
386
+ }
387
+
388
+ /**
389
+ * Remove an abandoned lock — one writer at a time.
390
+ *
391
+ * Writers waiting on one lock find it abandoned together, each judging it from what it read before
392
+ * the others acted. Removing it on that judgement let a slower writer move aside the fresh lock a
393
+ * faster one had just taken in its place; it was handed back, but the faster writer's check before
394
+ * it appends had already found its lock gone, and it refused its own entry. So a takeover first
395
+ * takes a second lock, `<log>.lock.takeover`, and judges the lock again only while holding it: the
396
+ * slower writer then finds the faster one's fresh lock, or none, and removes nothing.
397
+ *
398
+ * Resolves to whether the lock is gone. A takeover holds its lock for a moment, so one older than
399
+ * `staleMs` was left by a writer that died during it, and is removed for the next attempt.
400
+ */
401
+ async function takeOver(fs: LockFs, lockPath: string, opts: AuditLockOptions): Promise<boolean> {
402
+ const takeoverPath = `${lockPath}.takeover`;
403
+ const token = randomBytes(16).toString("hex");
404
+ if (!(await tryCreateLock(fs, takeoverPath, token))) {
405
+ const stuck = await inspectLock(fs, takeoverPath);
406
+ if (stuck !== undefined && stuck.ageMs > opts.staleMs) {
407
+ await removeLockIf(fs, takeoverPath, stuck.content);
408
+ }
409
+ return false;
410
+ }
411
+ try {
412
+ const held = await inspectLock(fs, lockPath);
413
+ if (held === undefined) return true;
414
+ return isAbandoned(held, opts.staleMs) && (await removeLockIf(fs, lockPath, held.content));
415
+ } finally {
416
+ await removeLockIf(fs, takeoverPath, token);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Whether an error creating or reading the lock means it is changing hands, rather than that it
422
+ * cannot be had.
423
+ *
424
+ * Windows answers an open of a file that is being deleted with EPERM, not ENOENT, for the moment
425
+ * until the deleting handle closes. Measured: a lock path that another process created and unlinked
426
+ * in a loop made 221 to 720 of about 30,000 concurrent opens fail with EPERM within three seconds.
427
+ * This module never unlinks the lock path itself — a release renames it aside, and the same churn
428
+ * done by rename produced none — but an operator clearing a stuck lock by hand can, and EBUSY is
429
+ * another process holding the file open without sharing. Elsewhere both are a real permission
430
+ * problem, and fail at once.
431
+ *
432
+ * A lock that has gone since it was opened, or that is renamed while it is being created, is
433
+ * changing hands too. Most filesystems go on serving a file renamed while it is open, but WSL's
434
+ * `/mnt` drives answer ENOENT, and NFS can answer ESTALE. Measured on WSL, with other processes
435
+ * taking and releasing the lock in a loop: a fifth to a half of the reads of an opened lock failed
436
+ * that way, and 5 to 17 percent of the creates racing a Windows process. A lock whose directory is
437
+ * missing gives ENOENT too, and that one fails at once.
438
+ */
439
+ async function lockInTransition(fs: LockFs, e: unknown, lockPath: string): Promise<boolean> {
440
+ if (isGone(e)) {
441
+ return fs.stat(dirname(lockPath)).then(
442
+ (s) => s.isDirectory(),
443
+ () => false,
444
+ );
445
+ }
446
+ const code = errorCode(e);
447
+ return process.platform === "win32" && (code === "EPERM" || code === "EBUSY");
448
+ }
449
+
450
+ /** 2, 4, 8 … 64 ms, plus up to half again at random, so waiters do not retry in lockstep. */
451
+ function backoffMs(attempt: number): number {
452
+ const base = Math.min(2 ** (attempt + 1), 64);
453
+ return base + randomInt(0, base / 2 + 1);
454
+ }
455
+
456
+ async function acquireLock(
457
+ fs: LockFs,
458
+ path: string,
459
+ lockPath: string,
460
+ opts: AuditLockOptions,
461
+ ): Promise<string> {
462
+ const token = JSON.stringify({
463
+ pid: process.pid,
464
+ host: hostname(),
465
+ pidSpace: localPidSpace(),
466
+ nonce: randomBytes(16).toString("hex"),
467
+ since: new Date().toISOString(),
468
+ });
469
+ const deadline = Date.now() + opts.timeoutMs;
470
+ let transient: unknown;
471
+ for (let attempt = 0; ; attempt += 1) {
472
+ const step = await tryLock(fs, lockPath, token, opts);
473
+ if (step.taken) return token;
474
+ transient = step.transient ?? transient;
475
+ if (Date.now() >= deadline) {
476
+ throw lockTimeout(path, lockPath, opts.timeoutMs, step.held, transient);
477
+ }
478
+ if (!step.freed) await sleep(backoffMs(attempt)); // NOSONAR S9382: the backoff between attempts at one lock, which must not overlap — a waiter backs off to let the holder finish, by a random amount so waiters do not retry in lockstep.
479
+ }
480
+ }
481
+
482
+ /** One attempt at the lock: taken, or what stood in the way. */
483
+ type LockTry =
484
+ | { readonly taken: true }
485
+ | {
486
+ readonly taken: false;
487
+ /** The lock as it was found, when it could be read. */
488
+ readonly held: HeldLock | undefined;
489
+ /** Released already, or abandoned and now removed: worth trying again at once. */
490
+ readonly freed: boolean;
491
+ /** An error read as the lock changing hands, which is waited out rather than thrown. */
492
+ readonly transient?: unknown;
493
+ };
494
+
495
+ async function tryLock(
496
+ fs: LockFs,
497
+ lockPath: string,
498
+ token: string,
499
+ opts: AuditLockOptions,
500
+ ): Promise<LockTry> {
501
+ let held: HeldLock | undefined;
502
+ try {
503
+ if (await tryCreateLock(fs, lockPath, token)) return { taken: true };
504
+ held = await inspectLock(fs, lockPath);
505
+ // A takeover that fails — another writer's is under way, or a rename was refused while another
506
+ // process had the file open — is not skipped but retried, after the wait, against a fresh look
507
+ // at the lock.
508
+ const freed =
509
+ held === undefined ||
510
+ (isAbandoned(held, opts.staleMs) && (await takeOver(fs, lockPath, opts).catch(() => false)));
511
+ return { taken: false, held, freed };
512
+ } catch (e) {
513
+ if (!(await lockInTransition(fs, e, lockPath))) throw e;
514
+ return { taken: false, held, freed: false, transient: e };
515
+ }
516
+ }
517
+
518
+ function lockTimeout(
519
+ path: string,
520
+ lockPath: string,
521
+ timeoutMs: number,
522
+ held: HeldLock | undefined,
523
+ transient: unknown,
524
+ ): Error {
525
+ const pid = held === undefined ? undefined : lockOwner(held.content)?.pid;
526
+ const holder = pid === undefined ? "" : `, held by process ${pid}`;
527
+ // Waited out as a lock changing hands, but it may be the real cause: name it.
528
+ const lastError = transient === undefined ? "" : ` (last seen: ${errorCode(transient)})`;
529
+ return new Error(
530
+ `audit log ${path}: gave up after ${timeoutMs} ms waiting for its lock ${lockPath}${holder}` +
531
+ `${lastError}; the entry was not written`,
532
+ );
533
+ }
534
+
535
+ /**
536
+ * Release a lock this append holds. False means it was no longer ours: another writer judged it
537
+ * abandoned and took it over while the entry was being written.
538
+ */
539
+ async function releaseLock(fs: LockFs, lockPath: string, token: string): Promise<boolean> {
540
+ try {
541
+ return await removeLockIf(fs, lockPath, token);
542
+ } catch (e) {
543
+ // The entry is written and chained, so the append stands. The lock file stays behind and the
544
+ // next writer takes it over once it is `staleMs` old. Say so on stderr: stdout is a stdio MCP
545
+ // server's protocol channel.
546
+ process.stderr.write(
547
+ `nimbus-connector: could not remove the audit-log lock ${lockPath}: ` +
548
+ `${e instanceof Error ? e.message : String(e)}\n`,
549
+ );
550
+ return true;
551
+ }
552
+ }
553
+
554
+ /** The hash a new entry links to: the one the log's last line carries. */
555
+ function tailHash(path: string, last: string, lineNo: number): string {
556
+ const hash = parseLine(last)?.hash;
557
+ if (typeof hash === "string") return hash;
558
+ // A torn last line, from a writer that died mid-write or a hand edit. Linking past it would hide
559
+ // whatever it was; refuse, so the write it records does not run unrecorded.
560
+ throw new Error(
561
+ `audit log ${path}: line ${lineNo} is not a chained entry, so no entry can be linked after it`,
562
+ );
563
+ }
564
+
565
+ /** A log line as read back: a `ChainedLine`'s fields, none of them checked yet. */
566
+ type UncheckedLine = { readonly [K in keyof ChainedLine]?: unknown };
567
+
568
+ /** A log line's fields, or `undefined` for a line that is not a JSON object. */
569
+ function parseLine(raw: string): UncheckedLine | undefined {
570
+ let parsed: unknown;
571
+ try {
572
+ parsed = JSON.parse(raw);
573
+ } catch {
574
+ return undefined;
575
+ }
576
+ return typeof parsed === "object" && parsed !== null ? (parsed as UncheckedLine) : undefined;
577
+ }
578
+
579
+ /**
580
+ * Append one entry, linked to the line that is actually last when it is written.
581
+ *
582
+ * The link is read from the log and the entry written after it, so two appends interleaved between
583
+ * those steps both link to the same predecessor and the chain breaks at the second: the consent
584
+ * kit's records from parallel tool calls did exactly that, and so did two connector processes
585
+ * sharing one `NIMBUS_MCP_AUDIT_LOG`. Appends are therefore serialised twice over. Within a
586
+ * process they queue per log, so entries land in the order they were recorded. Across processes
587
+ * the read and the write happen under a lock file beside the log, `<log>.lock`, created with
588
+ * `O_EXCL`; a lock whose holder has died is taken over, as `isAbandoned` and `takeOver` describe.
589
+ *
590
+ * Fails closed. An append that cannot take the lock within `timeoutMs`, cannot read the log, or
591
+ * finds a last line it cannot link to writes nothing and rejects — and the consent kit does not
592
+ * run a write whose entry did not land.
593
+ *
594
+ * The lock sits beside the file the path resolves to through any symlink, so every writer that
595
+ * reaches the log through a symlink still takes the one lock. A hard link to the log, or a
596
+ * container mount of the log file alone rather than its directory, still gets a lock of its own.
597
+ */
598
+ export function appendAuditEntry(
599
+ path: string,
600
+ entry: AuditEntry,
601
+ lock: AuditLockOptions = DEFAULT_AUDIT_LOCK,
602
+ ): Promise<void> {
603
+ const named = resolve(path);
604
+ return inQueue(named, async () => appendUnderLock(await logFile(named), entry, lock));
605
+ }
606
+
607
+ /**
608
+ * The file a log path names, every symlink resolved. The log is created first if it does not exist
609
+ * yet: a symlink to a log nobody has written to resolves to nothing, and its first append would
610
+ * lock beside the symlink while later ones lock beside the file.
611
+ */
612
+ async function logFile(named: string): Promise<string> {
613
+ await (await open(named, "a")).close();
614
+ return realpath(named);
615
+ }
616
+
617
+ async function appendUnderLock(
618
+ log: string,
619
+ entry: AuditEntry,
620
+ opts: AuditLockOptions,
621
+ ): Promise<void> {
622
+ const fs = opts.fs ?? NODE_FS;
623
+ const lockPath = `${log}.lock`;
624
+ const token = await acquireLock(fs, log, lockPath, opts);
625
+ let outcome: "written" | "lost" | { readonly error: unknown };
626
+ try {
627
+ const lines = await readLines(log);
628
+ const last = lines.at(-1);
629
+ const prev = last === undefined ? GENESIS_HASH : tailHash(log, last, lines.length);
630
+ const line: ChainedLine = { seq: lines.length + 1, prev, hash: linkHash(prev, entry), entry };
631
+ // A lock taken over while the log was being read may be guarding another writer's append right
632
+ // now. Checked as late as it can be, so the only window left open is between this check and
633
+ // the write.
634
+ const holder = await readSettled(fs, lockPath);
635
+ if (holder === undefined) {
636
+ throw new Error(
637
+ `audit log ${log}: could not read its lock ${lockPath} back before writing, ` +
638
+ "so the entry was not written",
639
+ );
640
+ }
641
+ if (holder === token) {
642
+ await appendFile(log, `${JSON.stringify(line)}\n`, "utf8");
643
+ outcome = "written";
644
+ } else {
645
+ outcome = "lost";
646
+ }
647
+ } catch (error) {
648
+ outcome = { error };
649
+ }
650
+ // A lock that is someone else's now is theirs to release; even renaming it aside and back would
651
+ // leave its name free for a moment, for a third writer to take.
652
+ if (outcome === "lost") {
653
+ throw new Error(
654
+ `audit log ${log}: another writer took over this append's lock as abandoned; ` +
655
+ "the entry was not written",
656
+ );
657
+ }
658
+ const stillOurs = await releaseLock(fs, lockPath, token);
659
+ if (outcome !== "written") throw outcome.error;
660
+ if (!stillOurs) {
661
+ throw new Error(
662
+ `audit log ${log}: the entry was written, but another writer took over its lock while it ` +
663
+ "was being written, so the chain may not verify past it",
664
+ );
665
+ }
114
666
  }
115
667
 
116
668
  /**
@@ -118,6 +670,10 @@ export async function appendAuditEntry(path: string, entry: AuditEntry): Promise
118
670
  *
119
671
  * Returns the 1-based line where the links first stop agreeing, which covers both a tampered entry
120
672
  * and a deleted one — a deletion breaks the successor's `prev` link.
673
+ *
674
+ * A log that does not exist is an empty chain; one that exists but cannot be read rejects, rather
675
+ * than verifying as empty. The log is read without taking its lock, so a line that is still being
676
+ * written when it is read reports as a break: verify a log no connector is writing to.
121
677
  */
122
678
  export async function verifyAuditChain(
123
679
  path: string,
@@ -125,16 +681,20 @@ export async function verifyAuditChain(
125
681
  const lines = await readLines(path);
126
682
  let prev = GENESIS_HASH;
127
683
  for (const [i, raw] of lines.entries()) {
128
- let line: ChainedLine;
129
- try {
130
- line = JSON.parse(raw) as ChainedLine;
131
- } catch {
132
- return { ok: false, brokenAtLine: i + 1 };
133
- }
134
- if (!hashEquals(line.prev, prev) || !hashEquals(line.hash, linkHash(prev, line.entry))) {
684
+ // A line that is not JSON, or JSON without both links (`null`, `{}`), breaks the chain at its
685
+ // own line too. It used to reject instead, since a missing link is not a string to compare.
686
+ const line = parseLine(raw);
687
+ const linked = line?.prev;
688
+ const hash = line?.hash;
689
+ if (
690
+ typeof linked !== "string" ||
691
+ typeof hash !== "string" ||
692
+ !hashEquals(linked, prev) ||
693
+ !hashEquals(hash, linkHash(prev, line?.entry))
694
+ ) {
135
695
  return { ok: false, brokenAtLine: i + 1 };
136
696
  }
137
- prev = line.hash;
697
+ prev = hash;
138
698
  }
139
699
  return { ok: true, count: lines.length };
140
700
  }