@klarkxy/dsh-safe-auto 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/audit.js ADDED
@@ -0,0 +1,706 @@
1
+ /*
2
+ * Audit store for Safe Auto reviews. Owns the `dsh_safe_auto_audit` domain
3
+ * (per-record layout; one row per reviewed approval request) so the runtime
4
+ * and the ui-host history view share one durable log.
5
+ *
6
+ * Design notes:
7
+ * - No delete API and no auto-expire: finish/execution may update a row in
8
+ * place, but history is never removed.
9
+ * - `put` resolves only after durability and `update` is atomic on the
10
+ * domain's write chain, so finish/execution/merge patches via `update`
11
+ * never race; all writes in one domain serialize on its chain anyway.
12
+ * A local `tail` additionally orders begin->prepare->finish->execution.
13
+ * - A row still `pending` when the domain reopens had its review interrupted
14
+ * by a host restart — it is reported as outcome `interrupted`, never as
15
+ * reviewed. Rows begun in this process honestly stay `pending`.
16
+ * - The reviewer's verdict (`outcome`) and the host's native grant
17
+ * (`approvalState`) are two separate facts. Only an observed native
18
+ * decision confirms a grant, so a row that says the reviewer allowed the
19
+ * call but whose grant was never confirmed is reported as `interrupted`
20
+ * too — a grant nobody confirmed must not read as one. The reviewer's own
21
+ * details (risk, authorization, rationale) are preserved either way.
22
+ * - Every persisted free-text field passes through `auditRedact` as a
23
+ * best-effort second line of defence: the caller already redacts, but a
24
+ * schema-less payload must never leak a raw secret to the medium.
25
+ * - Oversize review payloads are never silently truncated: a field over its
26
+ * bound is replaced by an explicit marker and the redacted snapshot (up to
27
+ * 1 MiB, sha256-tagged) lands in `capture`.
28
+ */
29
+ import { createHash, randomUUID } from 'node:crypto';
30
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
31
+ import { z } from 'zod';
32
+
33
+ export const AUDIT_DOMAIN_NAME = 'dsh_safe_auto_audit';
34
+ export const AUDIT_DOMAIN_VERSION = 1;
35
+
36
+ const MAX_ID = 128;
37
+ const MAX_KEY = 256; // sessionId/agentId/callId
38
+ const MAX_SHORT = 256; // tool/code/risk/authorization/…
39
+ const MAX_LONG = 320 * 1024; // action/environment/input/policy (~320 KiB)
40
+ const MAX_TEXT = 64 * 1024; // rationale/reason/error free text
41
+ const MAX_CAPTURE = 1024 * 1024; // capture snapshot payload
42
+ const MAX_CAPTURE_FIELD = MAX_CAPTURE + 8192; // snapshot + truncation marker
43
+ const MAX_JSON_DEPTH = 6; // nested JSON payloads are parsed this deep
44
+ const MAX_ATTEMPTS = 32;
45
+ const MAX_ATTEMPT_TEXT = MAX_LONG;
46
+ const DEFAULT_LIMIT = 25;
47
+ const MAX_LIMIT = 100;
48
+ const MAX_QUERY = 256;
49
+ const MAX_CURSOR = 512;
50
+
51
+ const bounded = max => z.string().max(max);
52
+ const isoString = () => z.iso.datetime({ offset: true });
53
+
54
+ const triggerSchema = z.object({
55
+ kind: z.enum(['dangerous-command', 'sandbox-escalation', 'approval']),
56
+ rule: bounded(MAX_SHORT).optional(),
57
+ reason: bounded(MAX_TEXT),
58
+ }).strict();
59
+
60
+ const reviewerSchema = z.object({
61
+ provider: bounded(4096).optional(),
62
+ model: bounded(4096).optional(),
63
+ reasoningEffort: bounded(4096).optional(),
64
+ }).strict();
65
+
66
+ const settingsSchema = z.object({
67
+ timeoutMs: z.number().int().nonnegative().optional(),
68
+ outputTokens: z.number().int().nonnegative().optional(),
69
+ maxTranscriptBytes: z.number().int().nonnegative().optional(),
70
+ reviewerPrompt: bounded(MAX_TEXT).optional(),
71
+ }).strict();
72
+
73
+ const attemptSchema = z.object({
74
+ attempt: z.number().int().nonnegative(),
75
+ startedAt: bounded(64),
76
+ finishedAt: bounded(64),
77
+ response: bounded(MAX_ATTEMPT_TEXT).optional(), // raw reviewer reply, redacted
78
+ error: bounded(MAX_TEXT).optional(),
79
+ }).strict();
80
+
81
+ const executionSchema = z.object({
82
+ state: z.enum(['not-observed', 'succeeded', 'failed']),
83
+ completedAt: bounded(64).optional(),
84
+ error: bounded(MAX_TEXT).optional(),
85
+ }).strict();
86
+
87
+ /* The observed native approval decision, separate from the reviewer's verdict. */
88
+ export const APPROVAL_STATES = ['not-observed', 'allowed-once', 'rejected', 'cancelled', 'unavailable'];
89
+ const CONFIRMED_APPROVALS = new Set(['allowed-once', 'rejected', 'cancelled']);
90
+
91
+ const recordSchema = z.object({
92
+ id: bounded(MAX_ID),
93
+ sessionId: bounded(MAX_KEY),
94
+ agentId: bounded(MAX_KEY),
95
+ callId: bounded(MAX_KEY),
96
+ tool: bounded(MAX_SHORT),
97
+ turn: z.number().int().nullable(),
98
+ trigger: triggerSchema,
99
+ // Optional at begin(): the review input/environment/reviewer may not be
100
+ // computed yet — begin persists as early as possible, prepare fills them in.
101
+ action: bounded(MAX_LONG).optional(), // redacted JSON string
102
+ environment: bounded(MAX_LONG).optional(), // redacted JSON string
103
+ input: bounded(MAX_LONG).optional(), // redacted review prompt
104
+ policy: bounded(MAX_LONG).optional(), // system policy text
105
+ reviewer: reviewerSchema.optional(),
106
+ settings: settingsSchema.optional(),
107
+ capture: bounded(MAX_CAPTURE_FIELD).optional(),
108
+ attempts: z.array(attemptSchema).max(MAX_ATTEMPTS).optional(),
109
+ status: z.enum(['pending', 'finished']),
110
+ outcome: z.enum(['pending', 'allowed-once', 'rejected', 'cancelled']),
111
+ // The host's native approval decision, observed after the review. Absent on
112
+ // rows written before this field existed; 'not-observed' means the decision
113
+ // has not been seen (yet).
114
+ approvalState: z.enum(APPROVAL_STATES).optional(),
115
+ code: bounded(MAX_SHORT).optional(),
116
+ risk: bounded(MAX_SHORT).optional(),
117
+ authorization: bounded(MAX_SHORT).optional(), // grant scope, not a credential
118
+ rationale: bounded(MAX_TEXT).optional(),
119
+ durationMs: z.number().nonnegative().optional(),
120
+ startedAt: bounded(64),
121
+ finishedAt: bounded(64).optional(),
122
+ execution: executionSchema,
123
+ }).strict();
124
+
125
+ export const auditDomain = defineDomain({
126
+ name: AUDIT_DOMAIN_NAME,
127
+ version: AUDIT_DOMAIN_VERSION,
128
+ layout: 'per-record',
129
+ tables: { records: domainTable(recordSchema) },
130
+ });
131
+
132
+ /* ------------------------------------------------------------------ *
133
+ * Best-effort audit redaction. Best-effort means: known credential shapes *
134
+ * (sensitive keys, bearer/basic headers, URL credentials, common token *
135
+ * formats, PEM blocks, JWTs) are masked; an unknown secret shape can *
136
+ * survive — the caller's primary redaction stays responsible. *
137
+ * *
138
+ * A key is sensitive when its normalized name matches the list below *
139
+ * (lowercased, `[-_]` folded) or when its word segments name a credential *
140
+ * (`GITHUB_TOKEN`, `DB_PASSWORD`, `openaiApiKey`). Substring matching is *
141
+ * wrong: `outputTokens` is numeric review metadata, not a credential. The *
142
+ * record's own `authorization` field (reviewer grant scope 'high'/'turn') is *
143
+ * kept verbatim by the store, but this redactor does not exempt the key — a *
144
+ * caller that passes a nested `headers.Authorization` must still see it *
145
+ * masked; the store restores the top-level record field after redaction. *
146
+ * ------------------------------------------------------------------ */
147
+ const SENSITIVE_KEYS = new Set([
148
+ 'password', 'passwd', 'pwd', 'passphrase', 'secret', 'apikey', 'apisecret',
149
+ 'token', 'accesstoken', 'authtoken', 'refreshtoken', 'idtoken', 'bearertoken',
150
+ 'credential', 'credentials', 'cookie', 'sessionkey',
151
+ 'privatekey', 'clientsecret', 'accesskey', 'secretkey', 'signingkey', 'sshkey',
152
+ 'authorization', 'xapikey', 'proxyauthorization', 'wwwauthenticate',
153
+ ]);
154
+ const normalizeKey = key => key.toLowerCase().replace(/[-_\s]/g, '');
155
+ const REDACTED = '[REDACTED]';
156
+
157
+ /*
158
+ * Credential words. A key names a credential when one of these words appears
159
+ * in it as a whole word: `GITHUB_TOKEN`, `DB_PASSWORD`, `X-Custom-Token` and
160
+ * `sessionSecret` all carry one. Substring matching would be wrong in both
161
+ * directions — `outputTokens` is numeric review metadata, not a credential,
162
+ * and `secretion` is not a secret — so the rules work on whole words.
163
+ */
164
+ const SECRET_WORDS = new Set([
165
+ 'password', 'passwd', 'pwd', 'passphrase', 'secret', 'token', 'apikey',
166
+ 'credential', 'credentials', 'cookie', 'authorization', 'authenticate',
167
+ ]);
168
+ /*
169
+ * `key` alone is too broad — `sortKey`, `primaryKey` and `cacheKey` are not
170
+ * credentials — so it only counts beside a qualifier that names a credential
171
+ * holder: `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `PRIVATE_KEY`.
172
+ */
173
+ const KEY_QUALIFIERS = new Set([
174
+ 'secret', 'private', 'access', 'api', 'session', 'client', 'server', 'signing',
175
+ 'ssh', 'encryption', 'master', 'auth', 'credential', 'account', 'personal',
176
+ 'user', 'app', 'service', 'storage', 'database', 'db', 'aws', 'azure', 'gcp',
177
+ 'provider', 'bearer', 'refresh',
178
+ ]);
179
+
180
+ /** Split a key into lowercased words across separators and camel-case runs. */
181
+ const keyWords = key => String(key)
182
+ .split(/[^A-Za-z0-9]+/)
183
+ .filter(Boolean)
184
+ .flatMap(part => part.match(/[A-Z]{2,}(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g) ?? [])
185
+ .map(word => word.toLowerCase());
186
+
187
+ /** True when the key names a credential, by exact name or by its word segments. */
188
+ const isSensitiveKey = key => {
189
+ if (SENSITIVE_KEYS.has(normalizeKey(key))) return true;
190
+ const words = keyWords(key);
191
+ if (words.some(word => SECRET_WORDS.has(word))) return true;
192
+ return words.includes('key') && words.some(word => KEY_QUALIFIERS.has(word));
193
+ };
194
+
195
+ function redactString(text) {
196
+ if (typeof text !== 'string' || text.length === 0) return text;
197
+ let out = text;
198
+ // Authorization / proxy headers and bearer-style credentials.
199
+ out = out.replace(/\b(bearer|basic|digest|negotiate)\s+[A-Za-z0-9._~+/=-]{4,}/gi, '$1 ' + REDACTED);
200
+ // Credential key=value pairs, including JSON-quoted keys ("apiKey":"v"):
201
+ // the key name is checked against the credential rules, so 'outputTokens'
202
+ // and ordinary keys survive while 'token'/'apiKey'/'GITHUB_TOKEN' mask.
203
+ // The unquoted value is bounded by its delimiters only — a capped value
204
+ // would mask just a prefix and leave the rest of the secret in the text.
205
+ out = out.replace(/("?)([A-Za-z][A-Za-z0-9_-]{0,60})\1\s*[:=]\s*("((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|[^\s,;&"']+)/g,
206
+ (m, _q, key, _val, dq, sq) => {
207
+ if (!isSensitiveKey(key)) return m;
208
+ if (dq !== undefined) return `"${key}":"${REDACTED}"`;
209
+ if (sq !== undefined) return `'${key}':'${REDACTED}'`;
210
+ return `${key}=${REDACTED}`;
211
+ });
212
+ // URL userinfo: scheme://user:pass@host
213
+ out = out.replace(/([a-zA-Z][a-zA-Z0-9+.-]{1,15}:\/\/)([^/\s:@?#]+):([^/\s@?#]+)@/g, `$1${REDACTED}@`);
214
+ // PEM blocks.
215
+ out = out.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, REDACTED);
216
+ // OpenAI / Anthropic / GitHub / Slack / AWS / Google / JWT-shaped tokens.
217
+ out = out.replace(/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{8,}\b/g, REDACTED);
218
+ out = out.replace(/\b(?:ghp|gho|ghu|ghs|ghr|github_pat|xox[baprs]|glpat|dop_v1|npm|pypi|hf|AIza|ya29|AKIA|ASIA)[A-Za-z0-9_-]{6,}\b/g, REDACTED);
219
+ out = out.replace(/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, REDACTED);
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Best-effort recursive redaction for audit-facing data. Strings are scanned
225
+ * for credential shapes; objects mask values of sensitive keys. Cycle-safe.
226
+ * Exported so the caller can pre-redact records before persistence.
227
+ */
228
+ export function auditRedact(value, depth = 0, seen = new WeakSet()) {
229
+ if (value === null || value === undefined) return value;
230
+ if (typeof value === 'string') {
231
+ // Whole-string JSON payloads (action/environment/input are serialized
232
+ // JSON) get structural redaction so escaped secrets like "apiKey":"a\b"
233
+ // are masked by key, not just by regex. A nested JSON string is parsed
234
+ // too: the regex cannot see a credential that sits inside a quoted
235
+ // value of a non-sensitive key ("command":"token=…"), so the payload is
236
+ // masked by key instead. The depth cap keeps the expansion bounded.
237
+ if (depth <= MAX_JSON_DEPTH && (value.startsWith('{') || value.startsWith('['))) {
238
+ try {
239
+ const parsed = JSON.parse(value);
240
+ if (parsed && typeof parsed === 'object') {
241
+ return JSON.stringify(auditRedact(parsed, depth + 1, seen));
242
+ }
243
+ } catch { /* not JSON — fall through to regex pass */ }
244
+ }
245
+ return redactString(value);
246
+ }
247
+ if (typeof value !== 'object') return value;
248
+ if (depth > 24 || seen.has(value)) return REDACTED;
249
+ seen.add(value);
250
+ if (Array.isArray(value)) return value.map(item => auditRedact(item, depth + 1, seen));
251
+ const out = {};
252
+ for (const [key, item] of Object.entries(value)) {
253
+ if (isSensitiveKey(key) && item !== null && item !== undefined) { out[key] = REDACTED; continue; }
254
+ out[key] = auditRedact(item, depth + 1, seen);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * Redact a record patch while preserving the record's own `authorization`
261
+ * field — the reviewer grant scope, not a credential. Nested authorization
262
+ * headers inside free-text fields are still masked by redactString.
263
+ */
264
+ function redactPatch(patch) {
265
+ const authorization = patch.authorization;
266
+ const out = auditRedact(patch);
267
+ if (authorization !== undefined) out.authorization = authorization;
268
+ return out;
269
+ }
270
+
271
+ /**
272
+ * Serialize an arbitrary capture value to a bounded redacted snapshot string.
273
+ * Strings pass through as-is; objects are JSON-serialized; unserializable
274
+ * values degrade to a placeholder. Snapshots over `maxBytes` are truncated
275
+ * with an explicit `[truncated … sha256=…]` marker — never silently.
276
+ * @returns {string|undefined}
277
+ */
278
+ export function auditedCapture(value, maxBytes = MAX_CAPTURE) {
279
+ if (value === undefined) return undefined;
280
+ let text;
281
+ if (typeof value === 'string') text = value;
282
+ else {
283
+ try { text = JSON.stringify(auditRedact(value)); }
284
+ catch { text = '[unserializable capture]'; }
285
+ }
286
+ text = redactString(text);
287
+ const bytes = Buffer.byteLength(text, 'utf8');
288
+ if (bytes <= maxBytes) return text;
289
+ const sha256 = createHash('sha256').update(text, 'utf8').digest('hex');
290
+ const marker = `\n…[audit capture truncated: original ${bytes} bytes, sha256=${sha256}]`;
291
+ // Byte-bounded truncation at a code-point boundary: slice the UTF-8 buffer,
292
+ // then back off at most 3 bytes — UTF-8 continuation bytes share the
293
+ // 10xxxxxx pattern, so landing mid-sequence is detected cheaply without a
294
+ // per-character O(n²) rescan. A partial surrogate pair at the cut is also
295
+ // impossible here because the boundary is a whole code point.
296
+ const budget = maxBytes - Buffer.byteLength(marker, 'utf8');
297
+ const buf = Buffer.from(text, 'utf8');
298
+ let end = Math.min(buf.length, Math.max(0, budget));
299
+ // The cut splits a code point when the byte AT the cut is a UTF-8
300
+ // continuation (0b10xxxxxx). Walk back over the whole incomplete sequence —
301
+ // its lead byte is 1–4 bytes earlier (0b110xxxxx 2B / 0b1110xxxx 3B /
302
+ // 0b11110xxx 4B) — so the surviving prefix ends on a complete code point
303
+ // and no U+FFFD / stray surrogate is emitted by toString.
304
+ if (end < buf.length && (buf[end] & 0b1100_0000) === 0b1000_0000) {
305
+ // Walk back over the continuation bytes; `end` lands on the sequence's
306
+ // lead byte index and subarray(0, end) excludes the incomplete sequence.
307
+ while (end > 0 && (buf[end] & 0b1100_0000) === 0b1000_0000) end--;
308
+ }
309
+ return buf.subarray(0, Math.max(0, end)).toString('utf8') + marker;
310
+ }
311
+
312
+ /* ------------------------------------------------ validation helpers */
313
+ const invalid = message => { const e = new Error(message); e.code = 'AUDIT_INVALID'; return e; };
314
+
315
+ function boundedString(value, field, max) {
316
+ if (value === undefined) return undefined;
317
+ if (typeof value !== 'string' || value.length > max) throw invalid(`audit: ${field} must be a string of at most ${max} chars`);
318
+ return value;
319
+ }
320
+
321
+ const listPayloadSchema = z.object({
322
+ sessionId: bounded(MAX_SHORT).optional(),
323
+ tool: bounded(MAX_SHORT).optional(),
324
+ outcome: z.enum(['pending', 'interrupted', 'allowed-once', 'rejected', 'cancelled']).optional(),
325
+ risk: bounded(MAX_SHORT).optional(),
326
+ from: isoString().optional(),
327
+ to: isoString().optional(),
328
+ query: bounded(MAX_QUERY).optional(),
329
+ limit: z.number().int().min(1).max(MAX_LIMIT).optional(),
330
+ cursor: bounded(MAX_CURSOR).optional(),
331
+ }).strict();
332
+
333
+ function encodeCursor(row) {
334
+ return Buffer.from(JSON.stringify([row.startedAt, row.id]), 'utf8').toString('base64url');
335
+ }
336
+ function decodeCursor(cursor) {
337
+ let parsed;
338
+ try { parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); }
339
+ catch { throw invalid('audit: invalid cursor'); }
340
+ if (!Array.isArray(parsed) || parsed.length !== 2
341
+ || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string'
342
+ || parsed[0].length > 64 || parsed[1].length > MAX_ID) throw invalid('audit: invalid cursor');
343
+ return { startedAt: parsed[0], id: parsed[1] };
344
+ }
345
+
346
+ /* Stable ordering: startedAt desc, id desc. */
347
+ const byStartedDesc = (a, b) => a.startedAt === b.startedAt
348
+ ? (a.id < b.id ? 1 : -1)
349
+ : (a.startedAt < b.startedAt ? 1 : -1);
350
+ const afterCursor = (row, c) => row.startedAt < c.startedAt
351
+ || (row.startedAt === c.startedAt && row.id < c.id);
352
+
353
+ /** Fields kept out of list summaries: the heavy context/action payloads. */
354
+ const HEAVY = new Set(['action', 'environment', 'input', 'policy', 'capture', 'attempts', 'settings']);
355
+
356
+ /* Fields that may carry oversize content moved into capture. */
357
+ const OVERSIZE_FIELDS = new Set(['action', 'environment', 'input', 'policy']);
358
+
359
+ /**
360
+ * Apply oversize handling to a patch before persistence: any long field over
361
+ * its bound is replaced by an explicit marker and its redacted snapshot (up
362
+ * to 1 MiB, sha256-tagged) is appended to `capture`.
363
+ */
364
+ function foldOversize(patch) {
365
+ const captures = [];
366
+ const next = { ...patch };
367
+ for (const field of OVERSIZE_FIELDS) {
368
+ const value = next[field];
369
+ if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') <= MAX_LONG) continue;
370
+ const bytes = Buffer.byteLength(value, 'utf8');
371
+ const sha256 = createHash('sha256').update(value, 'utf8').digest('hex');
372
+ captures.push(`--- ${field} (${bytes} bytes, sha256=${sha256}) ---\n${auditedCapture(value)}`);
373
+ next[field] = `[${field}: ${bytes} bytes over the ${MAX_LONG}-byte audit field limit; redacted snapshot in capture, sha256=${sha256}]`;
374
+ }
375
+ if (captures.length > 0) {
376
+ const prior = typeof next.capture === 'string' && next.capture.length > 0 ? `${next.capture}\n` : '';
377
+ next.capture = auditedCapture(prior + captures.join('\n'), MAX_CAPTURE_FIELD);
378
+ }
379
+ return next;
380
+ }
381
+
382
+ /**
383
+ * Open the audit store over the given storageDomain service.
384
+ * Minimal table contract the store relies on (see test/audit.test.js for a
385
+ * fake): `get(key)` sync read; `entries()` sync snapshot iterator;
386
+ * `put(key, row)` resolves after durability (a rejection leaves reads
387
+ * unchanged); `update(key, fn)` is an atomic serialized read-modify-write
388
+ * that rejects when the key is missing; `close()` drains queued writes.
389
+ * @param {{open: (spec: typeof auditDomain) => Promise<{table: (name: string) => object, close: () => Promise<void>}>}} storageDomain
390
+ */
391
+ export async function openAudit(storageDomain) {
392
+ if (!storageDomain || typeof storageDomain.open !== 'function') throw invalid('audit: storageDomain.open is required');
393
+ const domain = await storageDomain.open(auditDomain);
394
+ const table = domain.table('records');
395
+ // Rows unfinished when the domain reopened: their review never resolved
396
+ // (host restart mid-review). Report them interrupted, never as reviewed.
397
+ const interrupted = new Set();
398
+ // Rows stored before this process opened the domain whose reviewer verdict
399
+ // was a grant: no native decision confirmed them here, so they must not
400
+ // read as a confirmed grant once the domain is reopened.
401
+ const unconfirmedGrants = new Set();
402
+ for (const [id, row] of table.entries()) {
403
+ if (row?.outcome === 'pending') interrupted.add(id);
404
+ else if (row?.outcome === 'allowed-once' && !CONFIRMED_APPROVALS.has(row.approvalState)) unconfirmedGrants.add(id);
405
+ }
406
+ // Local write tail keeps begin/prepare/finish/execution strictly ordered
407
+ // even under concurrent callers; the domain chain serializes durability.
408
+ let tail = Promise.resolve();
409
+ const enqueue = op => {
410
+ const run = tail.then(op, op);
411
+ tail = run.then(() => {}, () => {});
412
+ return run;
413
+ };
414
+ /*
415
+ * The outcome a reader sees. Two facts are kept apart: `outcome` is the
416
+ * reviewer's verdict, `approvalState` is the native decision the host
417
+ * reported. A pending row that was still unfinished when the domain reopened
418
+ * never resolved ('interrupted'). A grant is only effective when the host
419
+ * confirmed it: an observed rejection or cancellation stands, and a grant
420
+ * nobody confirmed is reported as interrupted. The reviewer's own details
421
+ * (code, risk, authorization, rationale) are preserved either way.
422
+ */
423
+ const reportedOutcome = row => {
424
+ if (row.outcome === 'pending') return interrupted.has(row.id) ? 'interrupted' : 'pending';
425
+ if (row.outcome !== 'allowed-once') return row.outcome;
426
+ if (row.approvalState === 'allowed-once') return 'allowed-once';
427
+ if (row.approvalState === 'rejected' || row.approvalState === 'cancelled') return row.approvalState;
428
+ return unconfirmedGrants.has(row.id) ? 'interrupted' : 'allowed-once';
429
+ };
430
+ /** Apply the reported outcome and its marker to a copy of the record. */
431
+ const decorate = (row, out) => {
432
+ const outcome = reportedOutcome(row);
433
+ if (outcome !== row.outcome) {
434
+ out.outcome = outcome;
435
+ // The reviewer's own verdict stays visible next to the reported one.
436
+ out.reviewOutcome = row.outcome;
437
+ }
438
+ if (row.outcome === 'pending' && interrupted.has(row.id)) out.interrupted = true;
439
+ else if (outcome === 'interrupted' && row.outcome === 'allowed-once') out.unconfirmed = true;
440
+ return out;
441
+ };
442
+ const view = row => {
443
+ if (row === undefined) return undefined;
444
+ // Deep copy: callers must never mutate the authoritative in-memory row.
445
+ const next = { ...structuredClone(row), executionState: row.execution?.state ?? 'not-observed' };
446
+ return decorate(row, next);
447
+ };
448
+ /*
449
+ * List summary. The heavy payloads (action/environment/input/policy/capture/
450
+ * attempts/settings) are dropped from a summary anyway, so only the
451
+ * lightweight fields are collected and copied — cloning the whole row would
452
+ * copy the entire history on every page. The collected fields are copied
453
+ * once, so a caller cannot reach the authoritative row through a summary.
454
+ */
455
+ const summary = row => {
456
+ const light = {};
457
+ for (const [key, value] of Object.entries(row)) if (!HEAVY.has(key)) light[key] = value;
458
+ const out = structuredClone(light);
459
+ out.executionState = row.execution?.state ?? 'not-observed';
460
+ decorate(row, out);
461
+ return out;
462
+ };
463
+ /*
464
+ * Free-text search over the record's fields, in full — the heavy fields are
465
+ * searched as they are stored, never truncated. Each field is searched on
466
+ * its own and the scan short-circuits on the first hit, so a query costs
467
+ * one pass per field instead of one concatenated haystack.
468
+ */
469
+ const SEARCH_VALUES = row => [row.tool, row.callId, row.sessionId, row.agentId, row.trigger?.reason,
470
+ row.code, row.rationale, row.risk, row.outcome, row.action, row.input];
471
+ const matchesNeedle = (row, needle) => {
472
+ for (const value of SEARCH_VALUES(row)) {
473
+ if (typeof value === 'string' && value.toLowerCase().includes(needle)) return true;
474
+ }
475
+ return false;
476
+ };
477
+
478
+ const validateAttempts = attempts => {
479
+ if (!Array.isArray(attempts) || attempts.length > MAX_ATTEMPTS) throw invalid('audit: attempts must be a bounded array');
480
+ for (const a of attempts) {
481
+ if (!a || typeof a !== 'object' || !Number.isInteger(a.attempt) || a.attempt < 0) throw invalid('audit: malformed attempt');
482
+ boundedString(a.startedAt, 'attempts.startedAt', 64);
483
+ boundedString(a.finishedAt, 'attempts.finishedAt', 64);
484
+ boundedString(a.response, 'attempts.response', MAX_ATTEMPT_TEXT);
485
+ boundedString(a.error, 'attempts.error', MAX_TEXT);
486
+ }
487
+ };
488
+ const validatePatch = patch => {
489
+ if (patch.code !== undefined) boundedString(patch.code, 'code', MAX_SHORT);
490
+ if (patch.risk !== undefined) boundedString(patch.risk, 'risk', MAX_SHORT);
491
+ if (patch.authorization !== undefined) boundedString(patch.authorization, 'authorization', MAX_SHORT);
492
+ if (patch.rationale !== undefined) boundedString(patch.rationale, 'rationale', MAX_TEXT);
493
+ if (patch.durationMs !== undefined && (typeof patch.durationMs !== 'number' || !(patch.durationMs >= 0))) {
494
+ throw invalid('audit: durationMs must be a non-negative number');
495
+ }
496
+ if (patch.attempts !== undefined) validateAttempts(patch.attempts);
497
+ // Oversize long fields are folded into capture before the bound check so
498
+ // the check applies to the explicit marker that remains.
499
+ for (const field of OVERSIZE_FIELDS) {
500
+ if (patch[field] !== undefined && typeof patch[field] !== 'string') throw invalid(`audit: ${field} must be a string`);
501
+ }
502
+ if (patch.capture !== undefined && typeof patch.capture !== 'string') throw invalid('audit: capture must be a string');
503
+ if (patch.reviewer !== undefined) reviewerSchema.parse(patch.reviewer);
504
+ if (patch.settings !== undefined) settingsSchema.parse(patch.settings);
505
+ };
506
+ const checkId = id => { if (typeof id !== 'string' || id.length === 0 || id.length > MAX_ID) throw invalid('audit: id is required'); };
507
+
508
+ const store = {
509
+ /** Persist a fresh pending review record as early as possible; resolves with its id. */
510
+ begin(record) {
511
+ return enqueue(async () => {
512
+ const now = new Date().toISOString();
513
+ const id = randomUUID();
514
+ if (!record || typeof record !== 'object') throw invalid('audit: begin requires a record');
515
+ const row = {
516
+ id,
517
+ sessionId: boundedString(record.sessionId, 'sessionId', MAX_KEY),
518
+ agentId: boundedString(record.agentId, 'agentId', MAX_KEY) ?? '',
519
+ callId: boundedString(record.callId, 'callId', MAX_KEY) ?? '',
520
+ tool: boundedString(record.tool, 'tool', MAX_SHORT),
521
+ turn: record.turn === undefined ? null : record.turn,
522
+ trigger: record.trigger,
523
+ action: record.action,
524
+ environment: record.environment,
525
+ input: record.input,
526
+ policy: record.policy,
527
+ reviewer: record.reviewer,
528
+ settings: record.settings,
529
+ capture: record.capture,
530
+ status: 'pending',
531
+ outcome: 'pending',
532
+ approvalState: 'not-observed',
533
+ startedAt: now,
534
+ execution: { state: 'not-observed' },
535
+ };
536
+ if (row.sessionId === undefined) throw invalid('audit: sessionId is required');
537
+ if (row.tool === undefined) throw invalid('audit: tool is required');
538
+ if (row.turn !== null && !Number.isInteger(row.turn)) throw invalid('audit: turn must be an integer or null');
539
+ for (const field of OVERSIZE_FIELDS) {
540
+ if (row[field] !== undefined && typeof row[field] !== 'string') throw invalid(`audit: ${field} must be a string`);
541
+ }
542
+ if (row.capture !== undefined && typeof row.capture !== 'string') throw invalid('audit: capture must be a string');
543
+ const persisted = recordSchema.parse(redactPatch(foldOversize(row)));
544
+ await table.put(id, persisted);
545
+ return id;
546
+ });
547
+ },
548
+
549
+ /**
550
+ * Persist the actual review request before calling the model, and each
551
+ * finished review attempt's raw reply/error before retrying:
552
+ * input/environment/reviewer/capture/action/policy/attempts on a
553
+ * still-pending record. Never sets finishedAt; rejects once the record
554
+ * is finished.
555
+ */
556
+ prepare(id, patch) {
557
+ return enqueue(async () => {
558
+ checkId(id);
559
+ if (!patch || typeof patch !== 'object') throw invalid('audit: prepare requires a patch');
560
+ const allowed = ['input', 'environment', 'reviewer', 'capture', 'action', 'policy', 'attempts'];
561
+ const merged = {};
562
+ for (const key of Object.keys(patch)) {
563
+ if (!allowed.includes(key)) throw invalid(`audit: prepare cannot set ${key}`);
564
+ if (patch[key] !== undefined) merged[key] = patch[key];
565
+ }
566
+ validatePatch(merged);
567
+ await table.update(id, current => {
568
+ if (current.outcome !== 'pending') throw invalid('audit: prepare rejected — record already finished');
569
+ const next = { ...current, ...redactPatch(foldOversize(merged)) };
570
+ recordSchema.parse(next); // validate before returning — never persist a bad row
571
+ return next;
572
+ });
573
+ });
574
+ },
575
+
576
+ /** Merge the review verdict patch into a record; sets finishedAt and attempts. */
577
+ finish(id, patch) {
578
+ return enqueue(async () => {
579
+ checkId(id);
580
+ const outcome = patch?.outcome;
581
+ if (!['allowed-once', 'rejected', 'cancelled'].includes(outcome)) {
582
+ throw invalid('audit: outcome must be allowed-once, rejected or cancelled');
583
+ }
584
+ const allowed = ['code', 'durationMs', 'risk', 'authorization', 'rationale',
585
+ 'input', 'environment', 'reviewer', 'capture', 'attempts', 'settings', 'policy', 'action'];
586
+ const merged = {};
587
+ for (const key of Object.keys(patch)) {
588
+ if (key === 'outcome') continue;
589
+ if (!allowed.includes(key)) throw invalid(`audit: finish cannot set ${key}`);
590
+ if (patch[key] !== undefined) merged[key] = patch[key];
591
+ }
592
+ merged.outcome = outcome;
593
+ validatePatch(merged);
594
+ const finishedAt = new Date().toISOString();
595
+ await table.update(id, current => {
596
+ // Transitions: pending may take any verdict; a finished row may only
597
+ // be re-written to 'cancelled' by the final pre-approval re-check
598
+ // (a granted review invalidated while its durable write was in flight).
599
+ if (current.outcome !== 'pending' && !(current.outcome === 'allowed-once' && outcome === 'cancelled')) {
600
+ throw invalid('audit: cannot finish a record with outcome ' + current.outcome);
601
+ }
602
+ const next = { ...current, ...redactPatch(foldOversize(merged)), status: 'finished', finishedAt };
603
+ recordSchema.parse(next);
604
+ return next;
605
+ });
606
+ });
607
+ },
608
+
609
+ /**
610
+ * Record the native approval decision the host reported for this review.
611
+ * The reviewer's verdict is untouched: `outcome` says what the reviewer
612
+ * decided, `approvalState` says what the host confirmed. Only an observed
613
+ * decision counts as a confirmation, so a row whose grant was never
614
+ * confirmed is reported as interrupted instead of allowed.
615
+ */
616
+ approval(id, result) {
617
+ return enqueue(async () => {
618
+ checkId(id);
619
+ const state = result?.outcome;
620
+ if (!['allowed-once', 'rejected', 'cancelled', 'unavailable'].includes(state)) {
621
+ throw invalid('audit: approval outcome must be allowed-once, rejected, cancelled or unavailable');
622
+ }
623
+ await table.update(id, current => {
624
+ const next = { ...current, approvalState: state };
625
+ recordSchema.parse(next);
626
+ return next;
627
+ });
628
+ });
629
+ },
630
+
631
+ /** Record whether the approved call actually ran afterwards. */
632
+ execution(id, result) {
633
+ return enqueue(async () => {
634
+ checkId(id);
635
+ const state = result?.state;
636
+ if (!['succeeded', 'failed'].includes(state)) throw invalid('audit: execution state must be succeeded or failed');
637
+ const completedAt = result?.completedAt === undefined
638
+ ? new Date().toISOString()
639
+ : boundedString(result.completedAt, 'completedAt', 64);
640
+ const error = boundedString(result?.error, 'error', MAX_TEXT);
641
+ await table.update(id, current => {
642
+ const next = { ...current, execution: { state, completedAt, ...(error ? { error: redactString(error) } : {}) } };
643
+ recordSchema.parse(next);
644
+ return next;
645
+ });
646
+ });
647
+ },
648
+
649
+ /**
650
+ * Synchronous filtered listing, newest first, keyset-paginated so new
651
+ * inserts during iteration never shift page boundaries.
652
+ * @returns {{items: object[], nextCursor: string|undefined, total: number}}
653
+ */
654
+ list(payload = {}) {
655
+ const filter = listPayloadSchema.parse(payload ?? {});
656
+ const limit = filter.limit ?? DEFAULT_LIMIT;
657
+ const cursor = filter.cursor === undefined ? undefined : decodeCursor(filter.cursor);
658
+ const needle = filter.query === undefined ? undefined : filter.query.toLowerCase();
659
+ const fromMs = filter.from === undefined ? undefined : Date.parse(filter.from);
660
+ const toMs = filter.to === undefined ? undefined : Date.parse(filter.to);
661
+ if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) throw invalid('audit: from must not be after to');
662
+ const rows = [];
663
+ let total = 0;
664
+ for (const [, row] of table.entries()) {
665
+ if (!row) continue;
666
+ if (filter.sessionId !== undefined && row.sessionId !== filter.sessionId) continue;
667
+ if (filter.tool !== undefined && row.tool !== filter.tool) continue;
668
+ if (filter.risk !== undefined && row.risk !== filter.risk) continue;
669
+ const startedMs = Date.parse(row.startedAt);
670
+ if (fromMs !== undefined && !(startedMs >= fromMs)) continue;
671
+ if (toMs !== undefined && !(startedMs <= toMs)) continue;
672
+ if (filter.outcome !== undefined && reportedOutcome(row) !== filter.outcome) continue;
673
+ if (needle !== undefined && !matchesNeedle(row, needle)) continue;
674
+ total++;
675
+ rows.push(row);
676
+ }
677
+ rows.sort(byStartedDesc);
678
+ const page = [];
679
+ for (const row of rows) {
680
+ if (page.length >= limit) break;
681
+ if (cursor && !afterCursor(row, cursor)) continue;
682
+ page.push(row);
683
+ }
684
+ const last = page[page.length - 1];
685
+ const hasMore = last !== undefined && rows.some(row => afterCursor(row, { startedAt: last.startedAt, id: last.id }));
686
+ return { items: page.map(summary), nextCursor: hasMore ? encodeCursor(last) : undefined, total };
687
+ },
688
+
689
+ /** Synchronous full record read, including heavy fields. */
690
+ get(payload = {}) {
691
+ if (!payload || typeof payload !== 'object') throw invalid('audit: get requires {id}');
692
+ for (const key of Object.keys(payload)) if (key !== 'id') throw invalid(`audit: unknown get field ${key}`);
693
+ if (typeof payload.id !== 'string' || payload.id.length > MAX_ID) throw invalid('audit: get requires {id}');
694
+ const row = view(table.get(payload.id));
695
+ if (row === undefined) { const e = new Error('audit: record not found'); e.code = 'AUDIT_NOT_FOUND'; throw e; }
696
+ return row;
697
+ },
698
+
699
+ /** Drain queued writes and close the domain. */
700
+ async close() {
701
+ await tail.catch(() => {});
702
+ await domain.close();
703
+ },
704
+ };
705
+ return store;
706
+ }