@nimbus-dev/connectors 0.2.2 → 0.2.4
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 +5 -3
- package/connectors/apple/src/server.ts +5 -1
- package/connectors/athena/src/tools.ts +12 -10
- package/connectors/aws/src/server.ts +5 -1
- package/connectors/aws/src/tools.ts +9 -7
- package/connectors/azure/src/server.ts +5 -1
- package/connectors/azure/src/tools.ts +10 -9
- package/connectors/bitbucket/src/server.ts +5 -1
- package/connectors/circleci/src/server.ts +5 -1
- package/connectors/cloud-logging/src/tools.ts +5 -8
- package/connectors/cloudwatch/src/tools.ts +6 -5
- package/connectors/confluence/src/server.ts +5 -1
- package/connectors/fastmail/src/server.ts +5 -1
- package/connectors/gcp/src/server.ts +5 -1
- package/connectors/gcp/src/tools.ts +11 -11
- package/connectors/github/src/server.ts +2 -0
- package/connectors/github-actions/src/server.ts +5 -1
- package/connectors/gitlab/src/server.ts +5 -1
- package/connectors/gmail/src/server.ts +5 -1
- package/connectors/google-drive/src/server.ts +5 -1
- package/connectors/iac/src/server.ts +5 -1
- package/connectors/iac/src/tools.ts +83 -25
- package/connectors/imap/src/server.ts +5 -1
- package/connectors/jenkins/src/server.ts +5 -1
- package/connectors/jira/src/server.ts +5 -1
- package/connectors/kubernetes/src/server.ts +5 -1
- package/connectors/kubernetes/src/tools.ts +9 -8
- package/connectors/linear/src/server.ts +5 -1
- package/connectors/notion/src/server.ts +5 -1
- package/connectors/obsidian/src/server.ts +5 -1
- package/connectors/onedrive/src/server.ts +5 -1
- package/connectors/outlook/src/server.ts +5 -1
- package/connectors/pagerduty/src/server.ts +5 -1
- package/connectors/protonmail/src/server.ts +5 -1
- package/connectors/sagemaker/src/tools.ts +5 -5
- package/connectors/slack/src/server.ts +5 -1
- package/connectors/teams/src/server.ts +5 -1
- package/package.json +1 -1
- package/shared/audit-chain.ts +588 -28
- package/shared/cli-json-kit.ts +35 -6
- package/shared/consent-kit.ts +115 -38
- package/shared/nimbus-spawn.ts +114 -27
- package/shared/run-read-only-mcp-connector.ts +7 -1
- package/shared/safe-cli-arg.ts +119 -17
- package/shared/windows-batch-args.ts +274 -0
- package/shared/write-budget.ts +100 -0
package/shared/audit-chain.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
697
|
+
prev = hash;
|
|
138
698
|
}
|
|
139
699
|
return { ok: true, count: lines.length };
|
|
140
700
|
}
|