openclaw-db9-audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,430 @@
1
+ import type { PluginLogger } from "openclaw/plugin-sdk/core";
2
+ import { Pool } from "pg";
3
+ import type {
4
+ AuditRunRecord,
5
+ TranscriptFileOffset,
6
+ TranscriptSyncBatch,
7
+ TranscriptSyncResult,
8
+ } from "./types.js";
9
+
10
+ function buildSchemaSql(schema: string): string[] {
11
+ return [
12
+ `create schema if not exists ${schema}`,
13
+ `
14
+ create table if not exists ${schema}.schema_version (
15
+ version integer primary key,
16
+ applied_at timestamptz not null default now()
17
+ )
18
+ `,
19
+ `
20
+ create table if not exists ${schema}.audit_sessions (
21
+ session_key text primary key,
22
+ agent_id text not null,
23
+ session_id text,
24
+ created_at timestamptz not null default now(),
25
+ updated_at timestamptz not null default now(),
26
+ last_run_id text,
27
+ message_count integer not null default 0,
28
+ status text not null default 'active',
29
+ meta jsonb not null default '{}'::jsonb
30
+ )
31
+ `,
32
+ `
33
+ create table if not exists ${schema}.audit_runs (
34
+ run_id text primary key,
35
+ session_key text,
36
+ agent_id text,
37
+ started_at timestamptz,
38
+ ended_at timestamptz,
39
+ success boolean,
40
+ duration_ms integer,
41
+ error text,
42
+ trigger text,
43
+ message_provider text,
44
+ stats jsonb not null default '{}'::jsonb,
45
+ created_at timestamptz not null default now(),
46
+ updated_at timestamptz not null default now()
47
+ )
48
+ `,
49
+ `
50
+ create index if not exists audit_runs_session_ended_idx
51
+ on ${schema}.audit_runs(session_key, ended_at desc)
52
+ `,
53
+ `
54
+ create index if not exists audit_runs_agent_ended_idx
55
+ on ${schema}.audit_runs(agent_id, ended_at desc)
56
+ `,
57
+ `
58
+ create table if not exists ${schema}.audit_messages (
59
+ id bigserial primary key,
60
+ session_key text not null,
61
+ agent_id text,
62
+ session_file text not null,
63
+ line_no integer not null,
64
+ run_id text,
65
+ role text,
66
+ source text not null,
67
+ tool_name text,
68
+ tool_call_id text,
69
+ content_text text,
70
+ content_json jsonb,
71
+ raw_json jsonb not null,
72
+ created_at timestamptz,
73
+ inserted_at timestamptz not null default now(),
74
+ unique(session_file, line_no)
75
+ )
76
+ `,
77
+ `
78
+ create index if not exists audit_messages_session_idx
79
+ on ${schema}.audit_messages(session_key, line_no)
80
+ `,
81
+ `
82
+ create index if not exists audit_messages_run_idx
83
+ on ${schema}.audit_messages(run_id, line_no)
84
+ `,
85
+ `
86
+ create table if not exists ${schema}.audit_offsets (
87
+ session_file text primary key,
88
+ agent_id text,
89
+ session_key text,
90
+ last_offset bigint not null default 0,
91
+ last_line_no integer not null default 0,
92
+ updated_at timestamptz not null default now()
93
+ )
94
+ `,
95
+ `insert into ${schema}.schema_version(version) values (1) on conflict (version) do nothing`,
96
+ ];
97
+ }
98
+
99
+ export class Db9AuditPostgres {
100
+ readonly schema: string;
101
+ private readonly pool: Pool;
102
+ private readonly logger?: PluginLogger | undefined;
103
+ private ensureSchemaPromise: Promise<void> | null = null;
104
+ private schemaReady = false;
105
+
106
+ constructor(params: {
107
+ connectionString: string;
108
+ schema: string;
109
+ logger?: PluginLogger | undefined;
110
+ }) {
111
+ this.schema = params.schema;
112
+ this.logger = params.logger;
113
+ this.pool = new Pool({
114
+ connectionString: params.connectionString,
115
+ max: 2,
116
+ idleTimeoutMillis: 30_000,
117
+ connectionTimeoutMillis: 10_000,
118
+ allowExitOnIdle: true,
119
+ });
120
+ }
121
+
122
+ async ping(): Promise<void> {
123
+ await this.pool.query("select 1");
124
+ }
125
+
126
+ async ensureSchema(): Promise<void> {
127
+ if (this.schemaReady) {
128
+ return;
129
+ }
130
+ if (!this.ensureSchemaPromise) {
131
+ this.ensureSchemaPromise = this.runEnsureSchema().catch((error) => {
132
+ this.ensureSchemaPromise = null;
133
+ throw error;
134
+ });
135
+ }
136
+ return await this.ensureSchemaPromise;
137
+ }
138
+
139
+ private async runEnsureSchema(): Promise<void> {
140
+ const client = await this.pool.connect();
141
+ try {
142
+ for (const statement of buildSchemaSql(this.schema)) {
143
+ await client.query(statement);
144
+ }
145
+ this.schemaReady = true;
146
+ } finally {
147
+ client.release();
148
+ }
149
+ }
150
+
151
+ async hasSchemaVersion(): Promise<boolean> {
152
+ const tableResult = await this.pool.query(
153
+ `
154
+ select 1
155
+ from information_schema.tables
156
+ where table_schema = $1
157
+ and table_name = 'schema_version'
158
+ limit 1
159
+ `,
160
+ [this.schema],
161
+ );
162
+ if ((tableResult.rowCount ?? 0) === 0) {
163
+ return false;
164
+ }
165
+ const result = await this.pool.query(
166
+ `select 1 from ${this.schema}.schema_version where version = 1 limit 1`,
167
+ );
168
+ return (result.rowCount ?? 0) > 0;
169
+ }
170
+
171
+ async getOffset(sessionFile: string): Promise<TranscriptFileOffset | null> {
172
+ await this.ensureSchema();
173
+ const result = await this.pool.query(
174
+ `
175
+ select session_file, agent_id, session_key, last_offset, last_line_no
176
+ from ${this.schema}.audit_offsets
177
+ where session_file = $1
178
+ `,
179
+ [sessionFile],
180
+ );
181
+ const row = result.rows[0];
182
+ if (!row) {
183
+ return null;
184
+ }
185
+ return {
186
+ sessionFile: String(row.session_file),
187
+ ...(row.agent_id ? { agentId: String(row.agent_id) } : {}),
188
+ ...(row.session_key ? { sessionKey: String(row.session_key) } : {}),
189
+ lastOffset: Number(row.last_offset),
190
+ lastLineNo: Number(row.last_line_no),
191
+ };
192
+ }
193
+
194
+ async syncTranscriptBatch(batch: TranscriptSyncBatch): Promise<TranscriptSyncResult> {
195
+ await this.ensureSchema();
196
+ const client = await this.pool.connect();
197
+ try {
198
+ await client.query("begin");
199
+
200
+ if (batch.resetExistingFile) {
201
+ const deletedCounts = await client.query(
202
+ `
203
+ select session_key, count(*)::int as count
204
+ from ${this.schema}.audit_messages
205
+ where session_file = $1
206
+ group by session_key
207
+ `,
208
+ [batch.sessionFile],
209
+ );
210
+
211
+ await client.query(`delete from ${this.schema}.audit_messages where session_file = $1`, [
212
+ batch.sessionFile,
213
+ ]);
214
+
215
+ for (const row of deletedCounts.rows) {
216
+ const sessionKey = row.session_key ? String(row.session_key) : "";
217
+ const count = Number(row.count ?? 0);
218
+ if (!sessionKey || count <= 0) {
219
+ continue;
220
+ }
221
+ await client.query(
222
+ `
223
+ update ${this.schema}.audit_sessions
224
+ set message_count = greatest(0, message_count - $2), updated_at = now()
225
+ where session_key = $1
226
+ `,
227
+ [sessionKey, count],
228
+ );
229
+ }
230
+ }
231
+
232
+ let insertedMessages = 0;
233
+ if (batch.messages.length > 0) {
234
+ const values: unknown[] = [];
235
+ const rowsSql = batch.messages
236
+ .map((message, index) => {
237
+ const base = index * 13;
238
+ values.push(
239
+ message.sessionKey,
240
+ message.agentId,
241
+ message.sessionFile,
242
+ message.lineNo,
243
+ message.runId ?? null,
244
+ message.role ?? null,
245
+ message.source,
246
+ message.toolName ?? null,
247
+ message.toolCallId ?? null,
248
+ message.contentText ?? null,
249
+ message.contentJson === undefined ? null : JSON.stringify(message.contentJson),
250
+ JSON.stringify(message.rawJson),
251
+ message.createdAt ?? null,
252
+ );
253
+ return `(
254
+ $${base + 1},
255
+ $${base + 2},
256
+ $${base + 3},
257
+ $${base + 4},
258
+ $${base + 5},
259
+ $${base + 6},
260
+ $${base + 7},
261
+ $${base + 8},
262
+ $${base + 9},
263
+ $${base + 10},
264
+ $${base + 11}::jsonb,
265
+ $${base + 12}::jsonb,
266
+ $${base + 13}
267
+ )`;
268
+ })
269
+ .join(",\n");
270
+
271
+ const result = await client.query(
272
+ `
273
+ insert into ${this.schema}.audit_messages (
274
+ session_key,
275
+ agent_id,
276
+ session_file,
277
+ line_no,
278
+ run_id,
279
+ role,
280
+ source,
281
+ tool_name,
282
+ tool_call_id,
283
+ content_text,
284
+ content_json,
285
+ raw_json,
286
+ created_at
287
+ )
288
+ values ${rowsSql}
289
+ on conflict (session_file, line_no) do nothing
290
+ `,
291
+ values,
292
+ );
293
+ insertedMessages = result.rowCount ?? 0;
294
+ }
295
+
296
+ await client.query(
297
+ `
298
+ insert into ${this.schema}.audit_sessions (
299
+ session_key,
300
+ agent_id,
301
+ session_id,
302
+ updated_at,
303
+ message_count,
304
+ meta
305
+ )
306
+ values ($1, $2, $3, now(), $4, $5::jsonb)
307
+ on conflict (session_key) do update
308
+ set
309
+ agent_id = excluded.agent_id,
310
+ session_id = coalesce(excluded.session_id, ${this.schema}.audit_sessions.session_id),
311
+ updated_at = now(),
312
+ message_count = ${this.schema}.audit_sessions.message_count + excluded.message_count,
313
+ meta = ${this.schema}.audit_sessions.meta || excluded.meta
314
+ `,
315
+ [
316
+ batch.sessionMeta.sessionKey,
317
+ batch.sessionMeta.agentId,
318
+ batch.sessionMeta.sessionId ?? batch.sessionHeader?.id ?? null,
319
+ insertedMessages,
320
+ JSON.stringify(batch.sessionMetaJson ?? {}),
321
+ ],
322
+ );
323
+
324
+ await client.query(
325
+ `
326
+ insert into ${this.schema}.audit_offsets (
327
+ session_file,
328
+ agent_id,
329
+ session_key,
330
+ last_offset,
331
+ last_line_no,
332
+ updated_at
333
+ )
334
+ values ($1, $2, $3, $4, $5, now())
335
+ on conflict (session_file) do update
336
+ set
337
+ agent_id = excluded.agent_id,
338
+ session_key = excluded.session_key,
339
+ last_offset = excluded.last_offset,
340
+ last_line_no = excluded.last_line_no,
341
+ updated_at = now()
342
+ `,
343
+ [
344
+ batch.sessionFile,
345
+ batch.sessionMeta.agentId,
346
+ batch.sessionMeta.sessionKey,
347
+ batch.lastOffset,
348
+ batch.lastLineNo,
349
+ ],
350
+ );
351
+
352
+ await client.query("commit");
353
+ return {
354
+ insertedMessages,
355
+ lastOffset: batch.lastOffset,
356
+ lastLineNo: batch.lastLineNo,
357
+ };
358
+ } catch (error) {
359
+ await client.query("rollback").catch(() => undefined);
360
+ throw error;
361
+ } finally {
362
+ client.release();
363
+ }
364
+ }
365
+
366
+ async upsertRunSummary(run: AuditRunRecord): Promise<void> {
367
+ await this.ensureSchema();
368
+ await this.pool.query(
369
+ `
370
+ insert into ${this.schema}.audit_runs (
371
+ run_id,
372
+ session_key,
373
+ agent_id,
374
+ started_at,
375
+ ended_at,
376
+ success,
377
+ duration_ms,
378
+ error,
379
+ trigger,
380
+ message_provider,
381
+ stats,
382
+ updated_at
383
+ )
384
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, now())
385
+ on conflict (run_id) do update
386
+ set
387
+ session_key = excluded.session_key,
388
+ agent_id = excluded.agent_id,
389
+ started_at = coalesce(excluded.started_at, ${this.schema}.audit_runs.started_at),
390
+ ended_at = coalesce(excluded.ended_at, ${this.schema}.audit_runs.ended_at),
391
+ success = excluded.success,
392
+ duration_ms = excluded.duration_ms,
393
+ error = excluded.error,
394
+ trigger = excluded.trigger,
395
+ message_provider = excluded.message_provider,
396
+ stats = excluded.stats,
397
+ updated_at = now()
398
+ `,
399
+ [
400
+ run.runId,
401
+ run.sessionKey ?? null,
402
+ run.agentId ?? null,
403
+ run.startedAt ?? null,
404
+ run.endedAt ?? null,
405
+ run.success ?? null,
406
+ run.durationMs ?? null,
407
+ run.error ?? null,
408
+ run.trigger ?? null,
409
+ run.messageProvider ?? null,
410
+ JSON.stringify(run.stats),
411
+ ],
412
+ );
413
+
414
+ if (run.sessionKey && run.runId) {
415
+ await this.pool.query(
416
+ `
417
+ update ${this.schema}.audit_sessions
418
+ set last_run_id = $2, updated_at = now()
419
+ where session_key = $1
420
+ `,
421
+ [run.sessionKey, run.runId],
422
+ );
423
+ }
424
+ }
425
+
426
+ async close(): Promise<void> {
427
+ await this.pool.end();
428
+ this.logger?.debug?.("db9-audit: postgres pool closed");
429
+ }
430
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { isRecord, truncateUtf8Bytes } from "./utils.js";
2
+ import type { Db9AuditRedactConfig } from "./types.js";
3
+
4
+ const SENSITIVE_KEY_PARTS = ["token", "secret", "authorization", "cookie", "password"];
5
+
6
+ function isSensitiveKey(key: string): boolean {
7
+ const lowered = key.trim().toLowerCase();
8
+ return SENSITIVE_KEY_PARTS.some((part) => lowered.includes(part));
9
+ }
10
+
11
+ function redactString(value: string, config: Db9AuditRedactConfig): string {
12
+ return truncateUtf8Bytes(value, config.maxFieldBytes);
13
+ }
14
+
15
+ export function redactUnknown(value: unknown, config: Db9AuditRedactConfig): unknown {
16
+ if (!config.enabled) {
17
+ return value;
18
+ }
19
+ if (typeof value === "string") {
20
+ return redactString(value, config);
21
+ }
22
+ if (Array.isArray(value)) {
23
+ return value.map((entry) => redactUnknown(entry, config));
24
+ }
25
+ if (!isRecord(value)) {
26
+ return value;
27
+ }
28
+ const redacted: Record<string, unknown> = {};
29
+ for (const [key, entry] of Object.entries(value)) {
30
+ if (isSensitiveKey(key)) {
31
+ redacted[key] = "[REDACTED]";
32
+ continue;
33
+ }
34
+ redacted[key] = redactUnknown(entry, config);
35
+ }
36
+ return redacted;
37
+ }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ AgentEventPayload,
3
+ PluginHookAgentContext,
4
+ PluginHookAgentEndEvent,
5
+ } from "openclaw/plugin-sdk/core";
6
+ import type { AuditRunRecord } from "./types.js";
7
+ import { coerceInteger, resolveAgentIdFromSessionKey, toIsoString } from "./utils.js";
8
+
9
+ type RunState = {
10
+ runId: string;
11
+ sessionKey?: string | undefined;
12
+ agentId: string;
13
+ startedAt?: number | undefined;
14
+ endedAt?: number | undefined;
15
+ lastSeenAt: number;
16
+ lastSeq: number;
17
+ terminalStream?: string | undefined;
18
+ terminalPhase?: string | undefined;
19
+ terminalError?: string | undefined;
20
+ finalizedAt?: number | undefined;
21
+ };
22
+
23
+ function extractPhase(event: AgentEventPayload): string | undefined {
24
+ const phase = event.data.phase;
25
+ return typeof phase === "string" && phase.trim() ? phase : undefined;
26
+ }
27
+
28
+ function extractEventError(event: AgentEventPayload): string | undefined {
29
+ const error = event.data.error;
30
+ return typeof error === "string" && error.trim() ? error.trim() : undefined;
31
+ }
32
+
33
+ export class Db9AuditRunTracker {
34
+ private readonly runs = new Map<string, RunState>();
35
+
36
+ recordAgentEvent(event: AgentEventPayload): void {
37
+ this.evictOldRuns();
38
+ const existing = this.runs.get(event.runId);
39
+ const sessionKey =
40
+ typeof event.sessionKey === "string" && event.sessionKey.trim() ? event.sessionKey : existing?.sessionKey;
41
+ const phase = extractPhase(event);
42
+ const startedAtValue = coerceInteger(event.data.startedAt);
43
+ const endedAtValue = coerceInteger(event.data.endedAt);
44
+ const next: RunState = existing ?? {
45
+ runId: event.runId,
46
+ agentId: resolveAgentIdFromSessionKey(sessionKey),
47
+ lastSeenAt: event.ts,
48
+ lastSeq: event.seq,
49
+ };
50
+
51
+ next.lastSeenAt = event.ts;
52
+ next.lastSeq = event.seq;
53
+ if (sessionKey) {
54
+ next.sessionKey = sessionKey;
55
+ next.agentId = resolveAgentIdFromSessionKey(sessionKey);
56
+ }
57
+ if (phase === "start" && next.startedAt === undefined) {
58
+ next.startedAt = startedAtValue ?? event.ts;
59
+ }
60
+ if (phase === "end" || phase === "error") {
61
+ next.endedAt = endedAtValue ?? event.ts;
62
+ next.terminalPhase = phase;
63
+ next.terminalStream = event.stream;
64
+ next.terminalError = extractEventError(event);
65
+ }
66
+ this.runs.set(event.runId, next);
67
+ }
68
+
69
+ resolveAgentIdForRun(runId: string, sessionKey?: string | undefined): string {
70
+ const tracked = this.runs.get(runId);
71
+ if (sessionKey?.trim()) {
72
+ return resolveAgentIdFromSessionKey(sessionKey);
73
+ }
74
+ return tracked?.agentId ?? "main";
75
+ }
76
+
77
+ buildRunSummaryFromHook(
78
+ event: PluginHookAgentEndEvent,
79
+ ctx: PluginHookAgentContext,
80
+ ): AuditRunRecord | null {
81
+ this.evictOldRuns();
82
+ const match = this.matchRun(ctx);
83
+ if (!match) {
84
+ return null;
85
+ }
86
+ const endedAt = match.endedAt ?? Date.now();
87
+ match.finalizedAt = Date.now();
88
+
89
+ return {
90
+ runId: match.runId,
91
+ ...(match.sessionKey ? { sessionKey: match.sessionKey } : {}),
92
+ agentId: match.agentId,
93
+ ...(match.startedAt ? { startedAt: toIsoString(match.startedAt) } : {}),
94
+ endedAt: toIsoString(endedAt),
95
+ success: event.success,
96
+ ...(typeof event.durationMs === "number" ? { durationMs: event.durationMs } : {}),
97
+ ...(event.error ? { error: event.error } : match.terminalError ? { error: match.terminalError } : {}),
98
+ ...(ctx.trigger ? { trigger: ctx.trigger } : {}),
99
+ ...(ctx.messageProvider ? { messageProvider: ctx.messageProvider } : {}),
100
+ stats: {
101
+ matchedBy: this.describeMatch(match, ctx),
102
+ messageCount: Array.isArray(event.messages) ? event.messages.length : 0,
103
+ lastSeq: match.lastSeq,
104
+ terminalStream: match.terminalStream,
105
+ terminalPhase: match.terminalPhase,
106
+ },
107
+ };
108
+ }
109
+
110
+ private describeMatch(match: RunState, ctx: PluginHookAgentContext): string {
111
+ if (ctx.sessionKey?.trim() && match.sessionKey === ctx.sessionKey) {
112
+ return "sessionKey";
113
+ }
114
+ if (ctx.agentId?.trim() && match.agentId === ctx.agentId.trim().toLowerCase()) {
115
+ return "agentId";
116
+ }
117
+ return "fallback";
118
+ }
119
+
120
+ private matchRun(ctx: PluginHookAgentContext): RunState | null {
121
+ const candidates = [...this.runs.values()].filter((run) => run.finalizedAt === undefined);
122
+ if (candidates.length === 0) {
123
+ return null;
124
+ }
125
+
126
+ const sessionKey = ctx.sessionKey?.trim();
127
+ if (sessionKey) {
128
+ const matched = candidates
129
+ .filter((run) => run.sessionKey === sessionKey)
130
+ .toSorted((a, b) => b.lastSeenAt - a.lastSeenAt);
131
+ if (matched[0]) {
132
+ return matched[0];
133
+ }
134
+ }
135
+
136
+ const agentId = ctx.agentId?.trim().toLowerCase();
137
+ if (agentId) {
138
+ const matched = candidates
139
+ .filter((run) => run.agentId === agentId)
140
+ .toSorted((a, b) => b.lastSeenAt - a.lastSeenAt);
141
+ if (matched[0]) {
142
+ return matched[0];
143
+ }
144
+ }
145
+
146
+ return candidates.toSorted((a, b) => b.lastSeenAt - a.lastSeenAt)[0] ?? null;
147
+ }
148
+
149
+ private evictOldRuns(): void {
150
+ const cutoff = Date.now() - 6 * 60 * 60 * 1_000;
151
+ for (const [runId, run] of this.runs.entries()) {
152
+ const lastRelevantAt = run.finalizedAt ?? run.lastSeenAt;
153
+ if (lastRelevantAt < cutoff) {
154
+ this.runs.delete(runId);
155
+ }
156
+ }
157
+ }
158
+ }