shraga 0.1.112 → 0.1.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +2 -0
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +1 -1
  5. package/dist/client/assets/index-DIDtPQb-.css +10 -0
  6. package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
  7. package/dist/client/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/client/App.tsx +33 -8
  10. package/src/client/components/BackendStatusBanner.tsx +62 -0
  11. package/src/client/components/ConfigPanel.tsx +61 -15
  12. package/src/client/components/ConversationHeader.tsx +5 -1
  13. package/src/client/components/McpManager.tsx +26 -9
  14. package/src/client/components/SkillsManager.tsx +48 -27
  15. package/src/client/hooks/useAuth.ts +11 -2
  16. package/src/client/hooks/useIsOwner.ts +24 -0
  17. package/src/client/hooks/useModules.ts +5 -1
  18. package/src/client/lib/api.ts +21 -5
  19. package/src/client/lib/backendHealth.ts +230 -0
  20. package/src/client/lib/debug.ts +48 -0
  21. package/src/client/lib/sessionApi.ts +24 -8
  22. package/src/client/lib/ws.ts +21 -13
  23. package/src/scripts/harden-audit.sh +55 -0
  24. package/src/server/api-key-routes.ts +64 -0
  25. package/src/server/api-keys.ts +181 -43
  26. package/src/server/auth.ts +113 -47
  27. package/src/server/boot.ts +158 -104
  28. package/src/server/claude.ts +98 -3
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +2 -4
  31. package/src/server/engine/claude-code.ts +44 -14
  32. package/src/server/engine/types.ts +7 -0
  33. package/src/server/hooks.ts +19 -0
  34. package/src/server/mcp-oauth.ts +24 -5
  35. package/src/server/mcp-server.ts +55 -25
  36. package/src/server/modules/routes.ts +2 -6
  37. package/src/server/notify-owners.ts +5 -17
  38. package/src/server/owners.ts +14 -0
  39. package/src/server/scheduler/builtins.ts +3 -1
  40. package/src/server/scheduler/runner.ts +3 -0
  41. package/src/server/security/audit.ts +498 -0
  42. package/src/server/security/enforce.ts +306 -0
  43. package/src/server/security/escalate.ts +194 -0
  44. package/src/server/security/guard.ts +329 -0
  45. package/src/server/security/owner-only.ts +15 -0
  46. package/src/server/security/owner-routes.ts +43 -0
  47. package/src/server/security/policy.ts +413 -0
  48. package/src/server/security/principal.ts +80 -0
  49. package/src/server/security/revocation.ts +50 -0
  50. package/src/server/security/runtime.ts +174 -0
  51. package/src/server/sessions.ts +35 -0
  52. package/src/server/slack/bot.ts +44 -11
  53. package/src/server/slack/context-cache.ts +40 -7
  54. package/src/server/webhook-lane/feature.ts +17 -6
  55. package/src/shared/models.ts +11 -0
  56. package/dist/client/assets/index-DIMte_k6.css +0 -10
  57. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -8,7 +8,9 @@ import type { Schedule } from './types.ts';
8
8
  // which it is NOT for an npm-consumer app.
9
9
  const SUMMARIZER_CMD = `bun run ${fileURLToPath(new URL('../../scripts/summarize-conversations.ts', import.meta.url))}`;
10
10
 
11
- export const SYSTEM_UID = '__system__';
11
+ // Single definition lives with the security principal (a system lane resolves to operator there).
12
+ import { SYSTEM_UID } from '../security/principal.ts';
13
+ export { SYSTEM_UID };
12
14
  export const NIGHTLY_RECONCILE_SCHEDULE_ID = 'builtin-nightly-reconcile';
13
15
  export const DAILY_GARDEN_SCHEDULE_ID = 'builtin-daily-garden';
14
16
  export const HOURLY_SUMMARIZER_SCHEDULE_ID = 'builtin-conversation-summarizer';
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs';
3
3
  import { dirname, resolve, isAbsolute } from 'node:path';
4
4
  import { DATA_DIR } from '../paths.ts';
5
5
  import { streamChat, type PermissionHandler } from '../claude.ts';
6
+ import { fromInternal } from '../security/principal.ts';
6
7
  import { getMcpConfig } from '../mcp.ts';
7
8
  import { appendMessage, createScheduledSession, updateScheduledSessionStatus, setRunStatus, registerLivePartial, unregisterLivePartial, writePartial, clearPartial, acquireSessionLock, releaseSessionLock, type ConvBlock } from '../sessions.ts';
8
9
  import type { Schedule, ScheduleRunSummary } from './types.ts';
@@ -303,6 +304,8 @@ export async function runSchedule(
303
304
  toolUses.clear();
304
305
  try {
305
306
  for await (const ev of streamChat({
307
+ // No human at run time: the creator's identity, marked internal (re-resolved per run).
308
+ principal: fromInternal({ uid: schedule.createdBy.uid, email: schedule.createdBy.email, lane: 'scheduler' }),
306
309
  prompt,
307
310
  sessionId,
308
311
  uid: schedule.createdBy.uid,
@@ -0,0 +1,498 @@
1
+ // Audit: append-only, hash-chained JSONL log at data/audit/YYYY-MM.jsonl (UTC month).
2
+ //
3
+ // Each line = {ts, type, principal?, role?, sessionId?, target?, reason?, meta?, prevHash, hash} where
4
+ // hash = sha256(prevHash + canonical(line without hash)) and canonical = JSON with keys sorted recursively.
5
+ // The chain is ONE chain across months and restarts: on construct the last hash is recovered by reading the
6
+ // newest file backwards from its end (never the whole file). The first-ever line carries GENESIS_HASH.
7
+ // Head state is shared per resolved dir within the process, so several Audit instances on one dir append to one chain.
8
+ // Across PROCESSES (blue-green flip: promoted instance + the old one's drain lines) every append holds a cross-process
9
+ // mutex — mkdir of a SIBLING `.<dir>.lock` (auditLockPath), atomic on a local fs — across the whole re-sync + write:
10
+ // if the NEWEST month file in the dir
11
+ // isn't the file/size we last left (another process appended or rotated), tail recovery re-runs before linking. So
12
+ // writers that take the lock serialize into one chain. The wait is bounded (LOCK_WAIT_MS): on timeout the append fails
13
+ // (counted, logged, not written). A lock older than LOCK_STALE_MS (holder died mid-section) is broken with a warn.
14
+ // Residual gaps: a writer that doesn't take the lock (a pre-lock build during its own flip) is only size-detected and
15
+ // can still fork; two processes breaking the same stale lock at once can both enter. verify() reports any fork.
16
+ //
17
+ // Crash safety: a partial last line (crash mid-write, failed append) is sealed with '\n' on recovery and after a
18
+ // failed append, so the next record starts on its own line and links to the last GOOD hash. verify() still reports
19
+ // the partial line as `unparseable` (the break is real); records after it chain correctly from the good hash.
20
+ //
21
+ // append() never throws into the caller: a failed write is logged, counted in `failures`, and does not
22
+ // advance the chain. If the dir exists but can't be read during recovery, the instance is unhealthy and appends
23
+ // fail (rather than restart from genesis and fork) until a recovery succeeds.
24
+ //
25
+ // Limitations — the chain is UNKEYED: anyone with write access can rewrite the whole log with recomputed hashes, and
26
+ // deleting the newest file(s) or tail lines leaves a valid shorter chain. Neither is detectable without an external
27
+ // head anchor; the OS append-only flag (`chattr +a`, src/scripts/harden-audit.sh) + the data-sync offsite copy, whose
28
+ // commit messages carry `audit-head: <hash>` (readAuditHead), are the mitigation.
29
+ //
30
+ // `chattr +a` compatibility — everything this module does inside the dir must work when the dir and its month files are
31
+ // append-only: an append-only DIR allows creating entries but not unlinking/renaming them; an append-only FILE opens
32
+ // for write only with O_APPEND and never with O_TRUNC. So: appends open O_APPEND (appendRegular) ✓, sealTail appends
33
+ // '\n' ✓, reads are read-only ✓ — and the lock lives OUTSIDE the dir, because rmdir of a lock inside an append-only dir
34
+ // is EPERM (the first append would leave it held forever, then every append fails breaking it). A pre-tamper build
35
+ // locks `<dir>/.lock` instead: during a blue-green flip between such a build and this one the two don't exclude each
36
+ // other (verify() reports a fork), and harden-audit.sh refuses to run while `<dir>/.lock` exists.
37
+ //
38
+ // Planted entries — `+a` on the dir can't stop the server user from CREATING a month entry, e.g. next month's name as
39
+ // a symlink to a file outside (appends would land there, truncatable) or as a directory (appends would fail forever).
40
+ // Only regular files count as month files: every listing lstat()s, every open is O_NOFOLLOW + fstat-must-be-regular.
41
+ // A non-regular entry is skipped by recovery/query/readAuditHead, reported by verify() as `not-regular-file`, and on
42
+ // first sight per process counted in `failures`, logged, and handed to `onAnomaly` (runtime → owner alert). An append
43
+ // whose month entry is planted is REFUSED (null) rather than written under another name, which would fork the chain:
44
+ // that month's records are lost, loudly, until root removes the entry (chattr -a the dir, rm, re-run harden-audit.sh).
45
+ import { createHash } from 'node:crypto';
46
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmdirSync, statSync, writeSync, type Stats } from 'node:fs';
47
+ import path from 'node:path';
48
+ import { dataPath } from '../paths.ts';
49
+
50
+ export type AuditEventType =
51
+ | 'auth.allow' | 'auth.deny' | 'role.resolve' | 'turn.start' | 'turn.end' | 'tool.allow' | 'tool.deny'
52
+ | 'guard.limit' | 'guard.block' | 'escalate' | 'policy.change' | 'policy.tamper'
53
+ | 'key.create' | 'key.revoke' | 'token.revoke' | 'session.delete';
54
+
55
+ const TYPES = new Set<string>(['auth.allow', 'auth.deny', 'role.resolve', 'turn.start', 'turn.end', 'tool.allow', 'tool.deny',
56
+ 'guard.limit', 'guard.block', 'escalate', 'policy.change', 'policy.tamper', 'key.create', 'key.revoke', 'token.revoke', 'session.delete']);
57
+
58
+ /**
59
+ * What callers pass. `meta` must carry ids/names/enums — NOT free text (conversations already hold content).
60
+ * Sanitization is a safety net, not DLP:
61
+ * - EVERY string (top-level fields, meta keys and values, array items) is truncated to `maxString` FIRST (bounds regex
62
+ * cost), then only the credential substring (Bearer/Basic + ≥16-char token, sk-…, xox?-…, JWT, PEM key) → '[redacted]'.
63
+ * - meta keys named (whole, or as a `_`/`-`/camelCase suffix) token, accessToken, refreshToken, secret, clientSecret,
64
+ * password, passwd, pwd, authorization, cookie, apiKey, privateKey, signature are STRIPPED at any depth; `*Id` keys stay.
65
+ * Keys named exactly prompt, body, content, text, message(s), input, output are stripped too.
66
+ * - meta depth ≤ 4, arrays ≤ 50 items; serialized meta over `maxMetaBytes` is replaced by `{ _truncated: true, keys }`.
67
+ */
68
+ export interface AuditEvent {
69
+ type: AuditEventType;
70
+ principal?: string;
71
+ role?: string;
72
+ sessionId?: string;
73
+ target?: string;
74
+ reason?: string;
75
+ meta?: Record<string, unknown>;
76
+ }
77
+ export interface AuditRecord extends AuditEvent { ts: string; prevHash: string; hash: string }
78
+ export interface AuditQuery { from?: string | number | Date; to?: string | number | Date; type?: AuditEventType | AuditEventType[]; principal?: string; limit: number; cursor?: string }
79
+ export interface AuditPage { items: AuditRecord[]; nextCursor?: string }
80
+ export interface AuditVerify { ok: boolean; lines: number; brokenAt?: { file: string; line: number; reason: 'unparseable' | 'prev-hash' | 'hash' | 'unreadable' | 'not-regular-file' }; /** Planted (non-regular) month entries seen, in order. */ planted?: string[] }
81
+ /** A month-named entry that isn't a regular file (see header: planted entries). */
82
+ export interface AuditAnomaly { dir: string; file: string; kind: string }
83
+
84
+ export const GENESIS_HASH = '0'.repeat(64);
85
+ const FILE_RE = /^\d{4}-\d{2}\.jsonl$/;
86
+ const SECRET_NAMES = new Set(['token', 'accesstoken', 'refreshtoken', 'secret', 'clientsecret', 'password', 'passwd', 'pwd', 'authorization', 'cookie', 'apikey', 'privatekey', 'signature']);
87
+ const CONTENT_KEY_RE = /^(prompt|body|content|text|messages?|input|output)$/i;
88
+ const CRED_RE = /\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{16,}|\bsk-[A-Za-z0-9_-]{10,}|\bxox[a-z]-[A-Za-z0-9-]*|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?|-----BEGIN [A-Z ]*KEY-----[\s\S]*?(?:-----END [A-Z ]*KEY-----|$)/gi;
89
+ /** Whole key, or its last one/two `_`/`-`/camelCase words, is a secret name. `*Id` keys (apiKeyId) are ids, kept. */
90
+ const isSecretKey = (k: string) => {
91
+ if (k.endsWith('Id')) return false;
92
+ const w = k.split(/[_-]|(?<=[a-z0-9])(?=[A-Z])/).map(s => s.toLowerCase());
93
+ return [w.join(''), w.at(-1)!, w.slice(-2).join('')].some(n => SECRET_NAMES.has(n));
94
+ };
95
+ const CHUNK = 64 * 1024;
96
+ const MAX_QUERY = 1000;
97
+ const LOCK_WAIT_MS = 200;
98
+ const LOCK_STALE_MS = 2000;
99
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
100
+ /** Synchronous sleep without spinning the CPU (append is sync by contract). */
101
+ const sleepSync = (ms: number) => { Atomics.wait(SLEEP, 0, 0, ms); };
102
+
103
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
104
+
105
+ /** JSON with object keys sorted recursively (undefined dropped; array holes/undefined → null, as JSON.stringify writes). */
106
+ export function canonical(v: unknown): string {
107
+ if (Array.isArray(v)) return `[${Array.from(v, x => (x === undefined ? 'null' : canonical(x))).join(',')}]`;
108
+ if (v && typeof v === 'object') {
109
+ return `{${Object.keys(v).sort().filter(k => (v as any)[k] !== undefined).map(k => `${JSON.stringify(k)}:${canonical((v as any)[k])}`).join(',')}}`;
110
+ }
111
+ return JSON.stringify(v) ?? 'null';
112
+ }
113
+
114
+ const hashOf = (rec: Omit<AuditRecord, 'hash'>) => sha256(rec.prevHash + canonical(rec));
115
+
116
+ const kindOf = (st: Stats) => st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'directory' : st.isFIFO() ? 'fifo' : st.isSocket() ? 'socket' : 'special file';
117
+ /** Open `file` only if it is a regular file: O_NOFOLLOW (a symlink → ELOOP), O_NONBLOCK (a swapped-in FIFO can't hang
118
+ * the sync caller), then fstat on the fd — closes the lstat→open race for directories and specials too. */
119
+ function openRegular(file: string, flags: number, mode?: number): number {
120
+ const fd = openSync(file, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, mode);
121
+ const st = fstatSync(fd);
122
+ if (!st.isFile()) { closeSync(fd); throw Object.assign(new Error(`${path.basename(file)} is a ${kindOf(st)}, not a regular file`), { code: 'ENOTREG' }); }
123
+ return fd;
124
+ }
125
+ /** O_APPEND write (works on a `chattr +a` file) to a regular file, created 0600 if missing. */
126
+ function appendRegular(file: string, data: string): void {
127
+ const fd = openRegular(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT, 0o600);
128
+ try { const buf = Buffer.from(data); for (let off = 0; off < buf.length;) off += writeSync(fd, buf, off, buf.length - off); } finally { closeSync(fd); }
129
+ }
130
+ /** Month entries of `dir`, sorted: regular files, and every other entry under a month name. Throws readdir errors. */
131
+ function listMonths(dir: string): { files: string[]; planted: AuditAnomaly[] } {
132
+ const files: string[] = [], planted: AuditAnomaly[] = [];
133
+ for (const f of readdirSync(dir).filter(f => FILE_RE.test(f)).sort()) {
134
+ let st: Stats;
135
+ try { st = lstatSync(path.join(dir, f)); } catch (e: any) { if (e.code === 'ENOENT') continue; throw e; }
136
+ if (st.isFile()) files.push(f); else planted.push({ dir, file: f, kind: kindOf(st) });
137
+ }
138
+ return { files, planted };
139
+ }
140
+
141
+ /** Lines of `file` from the end backwards, starting before byte `end`. `start` = byte offset of the line. */
142
+ function* reverseLines(file: string, end?: number): Generator<{ line: string; start: number }> {
143
+ const fd = openRegular(file, constants.O_RDONLY);
144
+ try {
145
+ let pos = Math.min(end ?? Infinity, fstatSync(fd).size);
146
+ let tail = Buffer.alloc(0);
147
+ while (pos > 0) {
148
+ const n = Math.min(CHUNK, pos); pos -= n;
149
+ const buf = Buffer.alloc(n); readSync(fd, buf, 0, n, pos);
150
+ const data = Buffer.concat([buf, tail]);
151
+ let stop = data.length;
152
+ for (let i = data.lastIndexOf(10, stop - 1); i !== -1; i = stop > 0 ? data.lastIndexOf(10, stop - 1) : -1) {
153
+ if (stop > i + 1) yield { line: data.subarray(i + 1, stop).toString('utf8'), start: pos + i + 1 };
154
+ stop = i;
155
+ }
156
+ tail = data.subarray(0, stop);
157
+ }
158
+ if (tail.length) yield { line: tail.toString('utf8'), start: 0 };
159
+ } finally { closeSync(fd); }
160
+ }
161
+
162
+ /** Lines of `file` in order, read in chunks (1-based line numbers, empty lines skipped but counted). */
163
+ function* forwardLines(file: string): Generator<{ line: string; n: number }> {
164
+ const fd = openRegular(file, constants.O_RDONLY);
165
+ try {
166
+ let pos = 0, n = 0, rest = Buffer.alloc(0);
167
+ for (;;) {
168
+ const buf = Buffer.alloc(CHUNK); const got = readSync(fd, buf, 0, CHUNK, pos); pos += got;
169
+ const data = Buffer.concat([rest, buf.subarray(0, got)]);
170
+ let from = 0;
171
+ for (let i = data.indexOf(10); i !== -1; i = data.indexOf(10, from)) {
172
+ n++; if (i > from) yield { line: data.subarray(from, i).toString('utf8'), n };
173
+ from = i + 1;
174
+ }
175
+ rest = data.subarray(from);
176
+ if (!got) break;
177
+ }
178
+ if (rest.length) yield { line: rest.toString('utf8'), n: n + 1 };
179
+ } finally { closeSync(fd); }
180
+ }
181
+
182
+ /** If `file` is non-empty and doesn't end with '\n', append one — a partial line must not absorb the next record. */
183
+ function sealTail(file: string): boolean {
184
+ let fd: number;
185
+ try { fd = openRegular(file, constants.O_RDONLY); } catch (e: any) { if (e.code === 'ENOENT') return false; throw e; }
186
+ let partial = false;
187
+ try {
188
+ const size = fstatSync(fd).size;
189
+ if (size) { const b = Buffer.alloc(1); readSync(fd, b, 0, 1, size - 1); partial = b[0] !== 10; }
190
+ } finally { closeSync(fd); }
191
+ if (partial) appendRegular(file, '\n');
192
+ return partial;
193
+ }
194
+
195
+ const parse = (line: string): AuditRecord | null => {
196
+ try { const r = JSON.parse(line); return r && typeof r.hash === 'string' && typeof r.prevHash === 'string' ? r : null; } catch { return null; }
197
+ };
198
+ const toIso = (v: string | number | Date) => new Date(v).toISOString();
199
+ /** Canonical dir identity: realpath of the nearest existing ancestor + the rest (a symlink spelling or a not-yet-created dir under /var → /private/var must not get its own head). */
200
+ const realDir = (dir: string): string => {
201
+ const abs = path.resolve(dir);
202
+ try { return realpathSync(abs); } catch { const up = path.dirname(abs); return up === abs ? abs : path.join(realDir(up), path.basename(abs)); }
203
+ };
204
+
205
+ /** Cross-process lock for `dir`: a sibling of the (real) dir, so it can be released when the dir is `chattr +a`. */
206
+ export const auditLockPath = (dir: string): string => {
207
+ const real = realDir(dir);
208
+ return path.join(path.dirname(real), `.${path.basename(real)}.lock`);
209
+ };
210
+
211
+ /** Hash of the newest parseable record in `files` (sorted), read backwards; `bound` caps the read of one file. */
212
+ function tailHash(dir: string, files: string[], bound?: { file: string; size: number }, onSkip?: (file: string) => void): string {
213
+ for (const f of [...files].reverse()) {
214
+ const fp = path.join(dir, f);
215
+ for (const { line } of reverseLines(fp, fp === bound?.file ? bound.size : undefined)) {
216
+ const r = parse(line);
217
+ if (r) return r.hash;
218
+ onSkip?.(f);
219
+ }
220
+ }
221
+ return GENESIS_HASH;
222
+ }
223
+
224
+ /** Read-only chain head of `dir` (no seal, no lock, no shared state) — for anchoring it outside the box. GENESIS_HASH
225
+ * when there's no record; throws if the dir exists but can't be read. */
226
+ export function readAuditHead(dir: string = dataPath('audit')): string {
227
+ let files: string[];
228
+ try { files = listMonths(dir).files; } catch (e: any) { if (e.code === 'ENOENT') return GENESIS_HASH; throw e; }
229
+ return tailHash(dir, files);
230
+ }
231
+
232
+ /** Chain head per real dir, shared by every Audit instance in the process. */
233
+ interface Head {
234
+ lastHash: string; lastMonth: string; healthy: boolean;
235
+ /** The file and its size right after our last append/recovery. Anything else on disk = another process wrote. */
236
+ lastWrite: { file: string; size: number } | null;
237
+ /** Planted entries seen (`file:kind`) → whether `onAnomaly` accepted the alert. Logged/counted once, alerted once. */
238
+ planted?: Map<string, boolean>;
239
+ }
240
+ /** Size of `file` itself, not a symlink target (0 if missing or no file). */
241
+ const sizeOf = (file: string): number => {
242
+ if (!file) return 0;
243
+ try { return lstatSync(file).size; } catch (e: any) { if (e.code === 'ENOENT') return 0; throw e; }
244
+ };
245
+ const heads = new Map<string, Head>();
246
+ /** Test-only: forget shared heads, so the next Audit on a dir gets its own state (simulates another process). */
247
+ export function __resetAuditHeadsForTest(): void { heads.clear(); }
248
+
249
+ export class AuditOptions {
250
+ /** Directory holding YYYY-MM.jsonl files. */
251
+ dir: string = dataPath('audit');
252
+ /** ms epoch; injectable for rotation tests. */
253
+ clock: () => number = Date.now;
254
+ maxString: number = 256;
255
+ maxMetaBytes: number = 2048;
256
+ log: Pick<Console, 'info' | 'warn' | 'error'> = console;
257
+ /** A non-regular month entry was found (once per entry per process). Return `false` to be asked again next time it's seen. */
258
+ onAnomaly: (info: AuditAnomaly) => void | boolean = () => {};
259
+ }
260
+
261
+ export class Audit {
262
+ public options: AuditOptions;
263
+ private state: Head;
264
+ private _failures = 0;
265
+
266
+ public constructor(options?: Partial<AuditOptions>) {
267
+ this.options = { ...new AuditOptions(), ...options };
268
+ const key = realDir(this.options.dir);
269
+ this.state = heads.get(key) ?? { lastHash: GENESIS_HASH, lastMonth: '', healthy: true, lastWrite: null };
270
+ heads.set(key, this.state);
271
+ this.recover();
272
+ }
273
+
274
+ /** Failed appends since construct — for health. */
275
+ public get failures(): number { return this._failures; }
276
+ public get head(): string { return this.state.lastHash; }
277
+ /** False while the dir exists but can't be read (appends fail instead of forking from genesis). */
278
+ public get healthy(): boolean { return this.state.healthy; }
279
+
280
+ /** Month entries, sorted; planted ones reported. A missing dir is empty; any other read error is logged and thrown. */
281
+ private scan(): { files: string[]; planted: AuditAnomaly[] } {
282
+ let r: { files: string[]; planted: AuditAnomaly[] };
283
+ try { r = listMonths(this.options.dir); } catch (e: any) {
284
+ if (e.code === 'ENOENT' || e.code === 'ENOTDIR') return { files: [], planted: [] };
285
+ this.options.log.error(`[audit] cannot read ${this.options.dir}: ${e.message}`);
286
+ throw e;
287
+ }
288
+ r.planted.forEach(p => this.reportPlanted(p));
289
+ return r;
290
+ }
291
+
292
+ /** Regular month files only, sorted. */
293
+ private files(): string[] { return this.scan().files; }
294
+
295
+ /** First sight: count + log. Until `onAnomaly` accepts it: alert. */
296
+ private reportPlanted(p: AuditAnomaly): void {
297
+ const seen = (this.state.planted ??= new Map()), key = `${p.file}:${p.kind}`;
298
+ if (!seen.has(key)) {
299
+ this._failures++; seen.set(key, false);
300
+ this.options.log.error(`[audit] ${p.file} in ${p.dir} is a ${p.kind}, not a regular file — ignored, appends to that month refused; remove it as root (chattr -a the dir first)`);
301
+ }
302
+ if (seen.get(key)) return;
303
+ try { seen.set(key, this.options.onAnomaly(p) !== false); } catch (e: any) { this.options.log.error(`[audit] anomaly alert failed: ${e.message}`); }
304
+ }
305
+
306
+ /** Tail of the newest non-empty file → last good hash. Reads backwards from the end only. */
307
+ private recover(): void {
308
+ const s = this.state;
309
+ try {
310
+ const files = this.files();
311
+ const newest = files.length ? path.join(this.options.dir, files[files.length - 1]) : '';
312
+ if (newest && sealTail(newest)) this.options.log.warn(`[audit] sealed partial last line in ${files[files.length - 1]}`);
313
+ // Size first, tail read bounded by it: a line another process appends meanwhile shows up as a size mismatch
314
+ // on our next append (→ recover again) instead of being absorbed unseen.
315
+ const size = sizeOf(newest);
316
+ s.lastHash = tailHash(this.options.dir, files, { file: newest, size }, f => this.options.log.warn(`[audit] skipping unparseable tail line in ${f}`)); s.lastMonth = files.length ? files[files.length - 1].slice(0, 7) : ''; s.healthy = true;
317
+ s.lastWrite = { file: newest, size };
318
+ } catch (e: any) {
319
+ s.healthy = false;
320
+ this.options.log.error(`[audit] chain recovery failed (${this.options.dir}), appends disabled until it succeeds: ${e.message}`);
321
+ }
322
+ }
323
+
324
+ /** Truncate (before any regex — bounds its cost), then redact credential substrings. */
325
+ private str(v: unknown): string | undefined {
326
+ if (v === undefined || v === null) return undefined;
327
+ const s = String(v), max = this.options.maxString;
328
+ return (s.length > max ? `${s.slice(0, max)}…` : s).replace(CRED_RE, '[redacted]');
329
+ }
330
+
331
+ private clean(v: unknown, depth: number): unknown {
332
+ if (v === null || typeof v === 'number' || typeof v === 'boolean') return v;
333
+ if (typeof v === 'string') return this.str(v);
334
+ if (depth >= 4) return '[depth]';
335
+ if (Array.isArray(v)) return v.slice(0, 50).map(x => this.clean(x, depth + 1));
336
+ if (typeof v === 'object') {
337
+ const out: Record<string, unknown> = {};
338
+ for (const [raw, x] of Object.entries(v)) {
339
+ const k = this.str(raw)!; // keys can carry secrets too; the `_truncated.keys` list reuses these
340
+ if (x !== undefined && !isSecretKey(k) && !CONTENT_KEY_RE.test(k)) out[k] = this.clean(x, depth + 1);
341
+ }
342
+ return out;
343
+ }
344
+ return undefined; // functions, symbols, bigint
345
+ }
346
+
347
+ /** Enforce the AuditEvent contract (see type doc). */
348
+ public sanitize(meta: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
349
+ if (!meta || typeof meta !== 'object') return undefined;
350
+ const m = this.clean(meta, 0) as Record<string, unknown>;
351
+ if (Buffer.byteLength(canonical(m)) <= this.options.maxMetaBytes) return m;
352
+ return { _truncated: true, keys: Object.keys(m).slice(0, 20).map(k => k.slice(0, 64)) };
353
+ }
354
+
355
+ /** Take <dir>/.lock (mkdir = atomic). Bounded wait with backoff; breaks a stale lock. False on timeout. */
356
+ private lock(lk: string): boolean {
357
+ const deadline = Date.now() + LOCK_WAIT_MS;
358
+ for (let wait = 0.05; ; wait = Math.min(wait * 2, 5)) {
359
+ try { mkdirSync(lk); return true; } catch (e: any) { if (e.code !== 'EEXIST') throw e; }
360
+ try {
361
+ const age = Date.now() - statSync(lk).mtimeMs;
362
+ if (age > LOCK_STALE_MS) {
363
+ rmdirSync(lk);
364
+ this.options.log.warn(`[audit] broke stale lock ${lk} (${Math.round(age)}ms old)`);
365
+ continue;
366
+ }
367
+ } catch (e: any) { if (e.code !== 'ENOENT') throw e; } // released meanwhile → retry after a short sleep
368
+ if (Date.now() >= deadline) return false;
369
+ sleepSync(wait);
370
+ }
371
+ }
372
+
373
+ /** Append one event. Never throws; returns the written record, or null on failure. */
374
+ public append(event: AuditEvent): AuditRecord | null {
375
+ let file: string | undefined, lk: string | undefined;
376
+ const s = this.state;
377
+ try {
378
+ if (!TYPES.has(event?.type)) throw new Error(`unknown audit type "${event?.type}"`);
379
+ mkdirSync(this.options.dir, { recursive: true });
380
+ const lockPath = auditLockPath(this.options.dir);
381
+ if (!this.lock(lockPath)) throw new Error(`lock ${lockPath} not acquired within ${LOCK_WAIT_MS}ms`);
382
+ lk = lockPath;
383
+ if (!s.healthy) this.recover();
384
+ if (!s.healthy) throw new Error('audit dir unreadable; chain head unknown');
385
+ // Self-sync under the lock: the NEWEST month file isn't the file/size we last left → another process appended
386
+ // or rotated (possibly into a later month than our clock). Re-read the tail before linking.
387
+ const newestName = this.files().at(-1);
388
+ const newest = newestName ? path.join(this.options.dir, newestName) : '';
389
+ if (!s.lastWrite || s.lastWrite.file !== newest || s.lastWrite.size !== sizeOf(newest)) {
390
+ this.recover();
391
+ if (!s.healthy) throw new Error('audit dir unreadable; chain head unknown');
392
+ }
393
+ const now = new Date(this.options.clock());
394
+ const month = [now.toISOString().slice(0, 7), s.lastMonth].sort()[1];
395
+ const target = path.join(this.options.dir, `${month}.jsonl`);
396
+ let st: Stats | undefined;
397
+ try { st = lstatSync(target); } catch (e: any) { if (e.code !== 'ENOENT') throw e; }
398
+ if (st && !st.isFile()) {
399
+ this.reportPlanted({ dir: this.options.dir, file: `${month}.jsonl`, kind: kindOf(st) });
400
+ throw new Error(`${month}.jsonl is a ${kindOf(st)}, not a regular file — refused (writing elsewhere would fork the chain)`);
401
+ }
402
+ file = target;
403
+ const size = st?.size ?? 0;
404
+ const base = { ts: now.toISOString(), type: event.type } as Omit<AuditRecord, 'hash'>; // prevHash set last, for line readability
405
+ const opt = { principal: this.str(event.principal), role: this.str(event.role), sessionId: this.str(event.sessionId), target: this.str(event.target), reason: this.str(event.reason), meta: this.sanitize(event.meta) };
406
+ for (const [k, v] of Object.entries(opt)) if (v !== undefined) (base as any)[k] = v;
407
+ base.prevHash = s.lastHash;
408
+ const rec: AuditRecord = { ...base, hash: hashOf(base) };
409
+ const line = `${JSON.stringify(rec)}\n`;
410
+ appendRegular(file, line);
411
+ s.lastHash = rec.hash; s.lastMonth = month;
412
+ s.lastWrite = { file, size: size + Buffer.byteLength(line) }; // expected, not re-stat'd: a racing writer must still mismatch
413
+ return rec;
414
+ } catch (e: any) {
415
+ this._failures++;
416
+ this.options.log.error(`[audit] append failed (${event?.type}): ${e.message}`);
417
+ if (file) try { sealTail(file); } catch (e2: any) { this.options.log.error(`[audit] could not seal ${file}: ${e2.message}`); }
418
+ return null;
419
+ } finally {
420
+ if (lk) try { rmdirSync(lk); } catch (e: any) { this.options.log.error(`[audit] could not release ${lk}: ${e.message}`); }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Newest first, streaming backwards over ALL month files (no month-name pruning: a skewed clock can put any ts in any
426
+ * file); each record is filtered by its own ts. Cursor is opaque (file + byte offset).
427
+ * Throws (after logging) if the dir or a month file can't be read — never a silently short page. Planted
428
+ * (non-regular) entries are skipped, not thrown: they're alerted and verify() reports them, and throwing would let
429
+ * one planted name (undeletable under +a) blind the audit viewer.
430
+ */
431
+ public query(q: AuditQuery): AuditPage {
432
+ const limit = Math.max(1, Math.min(MAX_QUERY, Math.floor(q.limit) || 1));
433
+ const from = q.from !== undefined ? toIso(q.from) : undefined, to = q.to !== undefined ? toIso(q.to) : undefined;
434
+ const types = q.type === undefined ? undefined : new Set<string>([q.type].flat());
435
+ let cur: { file: string; offset: number } | undefined;
436
+ if (q.cursor) {
437
+ const [file, off] = Buffer.from(q.cursor, 'base64url').toString('utf8').split(':');
438
+ if (!FILE_RE.test(file ?? '') || !/^\d+$/.test(off ?? '')) throw new Error('invalid audit cursor');
439
+ cur = { file, offset: Number(off) };
440
+ }
441
+ const items: AuditRecord[] = [];
442
+ let last: { file: string; start: number } | undefined;
443
+ for (const f of this.files().reverse()) {
444
+ if (cur && f > cur.file) continue;
445
+ try {
446
+ for (const { line, start } of reverseLines(path.join(this.options.dir, f), cur?.file === f ? cur.offset : undefined)) {
447
+ const r = parse(line);
448
+ if (!r || (from && r.ts < from) || (to && r.ts > to) || (types && !types.has(r.type)) || (q.principal !== undefined && r.principal !== q.principal)) continue;
449
+ if (items.length === limit) return { items, nextCursor: Buffer.from(`${last!.file}:${last!.start}`).toString('base64url') };
450
+ items.push(r); last = { file: f, start };
451
+ }
452
+ } catch (e: any) {
453
+ this.options.log.error(`[audit] query cannot read ${f}: ${e.message}`);
454
+ throw e;
455
+ }
456
+ }
457
+ return { items };
458
+ }
459
+
460
+ /** Recompute the chain. No file = all files as one chain; a file = that file, seeded from the previous file's tail. */
461
+ public verify(file?: string): AuditVerify {
462
+ let all: string[], planted: Set<string>;
463
+ try { const s = this.scan(); all = s.files; planted = new Set(s.planted.map(p => p.file)); } catch { return { ok: false, lines: 0, brokenAt: { file: '', line: 0, reason: 'unreadable' } }; }
464
+ let targets = [...all, ...planted].sort(), expected = GENESIS_HASH, lines = 0, f = '';
465
+ try {
466
+ if (file) {
467
+ const base = path.basename(file), i = all.indexOf(base);
468
+ if (planted.has(base)) return { ok: false, lines: 0, brokenAt: { file: base, line: 0, reason: 'not-regular-file' }, planted: [base] };
469
+ if (i === -1) return { ok: false, lines: 0, brokenAt: { file: base, line: 0, reason: 'unparseable' } };
470
+ targets = [base];
471
+ for (let j = i - 1; j >= 0 && expected === GENESIS_HASH; j--) {
472
+ f = all[j];
473
+ for (const { line } of reverseLines(path.join(this.options.dir, f))) { const r = parse(line); if (r) { expected = r.hash; break; } }
474
+ }
475
+ }
476
+ // A planted entry (undeletable under +a) must not mask a real break later in the chain: note it, keep verifying
477
+ // the regular files, and report it only when the chain itself is intact.
478
+ const seen = targets.filter(t => planted.has(t)), withPlanted = seen.length ? { planted: seen } : {};
479
+ for (f of targets) {
480
+ if (planted.has(f)) continue;
481
+ for (const { line, n } of forwardLines(path.join(this.options.dir, f))) {
482
+ lines++;
483
+ const r = parse(line);
484
+ if (!r) return { ok: false, lines, brokenAt: { file: f, line: n, reason: 'unparseable' }, ...withPlanted };
485
+ if (r.prevHash !== expected) return { ok: false, lines, brokenAt: { file: f, line: n, reason: 'prev-hash' }, ...withPlanted };
486
+ const { hash, ...rest } = r;
487
+ if (hashOf(rest) !== hash) return { ok: false, lines, brokenAt: { file: f, line: n, reason: 'hash' }, ...withPlanted };
488
+ expected = hash;
489
+ }
490
+ }
491
+ if (seen.length) return { ok: false, lines, brokenAt: { file: seen[0], line: 0, reason: 'not-regular-file' }, planted: seen };
492
+ return { ok: true, lines };
493
+ } catch (e: any) {
494
+ this.options.log.error(`[audit] verify cannot read ${f}: ${e.message}`);
495
+ return { ok: false, lines, brokenAt: { file: f, line: 0, reason: 'unreadable' } };
496
+ }
497
+ }
498
+ }