openclaw-observability 1.0.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/dist/config.d.ts +60 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +140 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1114 -0
- package/dist/index.js.map +1 -0
- package/dist/redaction.d.ts +20 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +93 -0
- package/dist/redaction.js.map +1 -0
- package/dist/security/chain-detector.d.ts +37 -0
- package/dist/security/chain-detector.d.ts.map +1 -0
- package/dist/security/chain-detector.js +187 -0
- package/dist/security/chain-detector.js.map +1 -0
- package/dist/security/rules.d.ts +22 -0
- package/dist/security/rules.d.ts.map +1 -0
- package/dist/security/rules.js +479 -0
- package/dist/security/rules.js.map +1 -0
- package/dist/security/scanner.d.ts +47 -0
- package/dist/security/scanner.d.ts.map +1 -0
- package/dist/security/scanner.js +150 -0
- package/dist/security/scanner.js.map +1 -0
- package/dist/security/types.d.ts +47 -0
- package/dist/security/types.d.ts.map +1 -0
- package/dist/security/types.js +23 -0
- package/dist/security/types.js.map +1 -0
- package/dist/storage/buffer.d.ts +64 -0
- package/dist/storage/buffer.d.ts.map +1 -0
- package/dist/storage/buffer.js +120 -0
- package/dist/storage/buffer.js.map +1 -0
- package/dist/storage/duckdb-local-writer.d.ts +26 -0
- package/dist/storage/duckdb-local-writer.d.ts.map +1 -0
- package/dist/storage/duckdb-local-writer.js +454 -0
- package/dist/storage/duckdb-local-writer.js.map +1 -0
- package/dist/storage/mysql-writer.d.ts +55 -0
- package/dist/storage/mysql-writer.d.ts.map +1 -0
- package/dist/storage/mysql-writer.js +287 -0
- package/dist/storage/mysql-writer.js.map +1 -0
- package/dist/storage/schema.d.ts +13 -0
- package/dist/storage/schema.d.ts.map +1 -0
- package/dist/storage/schema.js +94 -0
- package/dist/storage/schema.js.map +1 -0
- package/dist/storage/writer.d.ts +31 -0
- package/dist/storage/writer.d.ts.map +1 -0
- package/dist/storage/writer.js +7 -0
- package/dist/storage/writer.js.map +1 -0
- package/dist/types.d.ts +72 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +44 -0
- package/dist/types.js.map +1 -0
- package/dist/web/api.d.ts +115 -0
- package/dist/web/api.d.ts.map +1 -0
- package/dist/web/api.js +219 -0
- package/dist/web/api.js.map +1 -0
- package/dist/web/routes.d.ts +20 -0
- package/dist/web/routes.d.ts.map +1 -0
- package/dist/web/routes.js +175 -0
- package/dist/web/routes.js.map +1 -0
- package/dist/web/ui.d.ts +9 -0
- package/dist/web/ui.d.ts.map +1 -0
- package/dist/web/ui.js +1327 -0
- package/dist/web/ui.js.map +1 -0
- package/openclaw.plugin.json +231 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenClaw audit plugin entry point
|
|
4
|
+
* Registers all 24 Plugin Hooks, assembles capture -> buffer -> writer pipeline
|
|
5
|
+
*
|
|
6
|
+
* OpenClaw hooks:
|
|
7
|
+
* Agent: before_model_resolve, before_prompt_build, before_agent_start, agent_end
|
|
8
|
+
* LLM: llm_input, llm_output
|
|
9
|
+
* Tool: before_tool_call, after_tool_call, tool_result_persist
|
|
10
|
+
* Msg: message_received, message_sending, message_sent, before_message_write
|
|
11
|
+
* Ctx: before_compaction, after_compaction, before_reset
|
|
12
|
+
* Session: session_start, session_end
|
|
13
|
+
* Subagent: subagent_spawning, subagent_delivery_target, subagent_spawned, subagent_ended
|
|
14
|
+
* Gateway: gateway_start, gateway_stop
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.activate = activate;
|
|
18
|
+
const config_1 = require("./config");
|
|
19
|
+
const types_1 = require("./types");
|
|
20
|
+
const redaction_1 = require("./redaction");
|
|
21
|
+
const mysql_writer_1 = require("./storage/mysql-writer");
|
|
22
|
+
const duckdb_local_writer_1 = require("./storage/duckdb-local-writer");
|
|
23
|
+
const buffer_1 = require("./storage/buffer");
|
|
24
|
+
const routes_1 = require("./web/routes");
|
|
25
|
+
const scanner_1 = require("./security/scanner");
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Runtime state */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
/** Max map entries (prevent memory leaks) */
|
|
30
|
+
const MAX_MAP_ENTRIES = 1000;
|
|
31
|
+
/** Map entry TTL (5 minutes) */
|
|
32
|
+
const MAP_ENTRY_TTL_MS = 300_000;
|
|
33
|
+
/** Max alert buffer size */
|
|
34
|
+
const MAX_ALERT_BUFFER = 500;
|
|
35
|
+
/** Track start time of each LLM/tool call */
|
|
36
|
+
const runStartTimes = new Map();
|
|
37
|
+
/**
|
|
38
|
+
* Map: requestUrl → usage from SSE final chunk
|
|
39
|
+
* We key by a monotonically increasing request ID since we can't know
|
|
40
|
+
* the sessionId at fetch time.
|
|
41
|
+
*/
|
|
42
|
+
const sseUsageCache = new Map();
|
|
43
|
+
let sseRequestCounter = 0;
|
|
44
|
+
/**
|
|
45
|
+
* Most recent SSE usage entry (LIFO — the llm_output hook fires shortly
|
|
46
|
+
* after the stream completes, so the latest entry is the one we want)
|
|
47
|
+
*/
|
|
48
|
+
let latestSseUsage = null;
|
|
49
|
+
/** Original fetch reference */
|
|
50
|
+
let originalFetch = null;
|
|
51
|
+
/** Whether the fetch interceptor is installed */
|
|
52
|
+
let fetchInterceptorInstalled = false;
|
|
53
|
+
function installFetchInterceptor() {
|
|
54
|
+
if (fetchInterceptorInstalled)
|
|
55
|
+
return;
|
|
56
|
+
originalFetch = globalThis.fetch;
|
|
57
|
+
const origFetch = originalFetch;
|
|
58
|
+
globalThis.fetch = async function patchedFetch(input, init) {
|
|
59
|
+
// Only intercept POST requests with JSON body containing stream: true
|
|
60
|
+
if (!init?.body || init.method?.toUpperCase() !== 'POST') {
|
|
61
|
+
return origFetch(input, init);
|
|
62
|
+
}
|
|
63
|
+
let bodyStr;
|
|
64
|
+
try {
|
|
65
|
+
bodyStr = typeof init.body === 'string' ? init.body : undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Not a string body, skip
|
|
69
|
+
}
|
|
70
|
+
if (!bodyStr) {
|
|
71
|
+
return origFetch(input, init);
|
|
72
|
+
}
|
|
73
|
+
// Check if this is an OpenAI-compatible streaming request
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(bodyStr);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return origFetch(input, init);
|
|
80
|
+
}
|
|
81
|
+
if (!parsed || parsed.stream !== true || !parsed.model || !parsed.messages) {
|
|
82
|
+
return origFetch(input, init);
|
|
83
|
+
}
|
|
84
|
+
// Inject stream_options if not already present
|
|
85
|
+
if (!parsed.stream_options) {
|
|
86
|
+
parsed.stream_options = { include_usage: true };
|
|
87
|
+
init = { ...init, body: JSON.stringify(parsed) };
|
|
88
|
+
}
|
|
89
|
+
// Make the actual request
|
|
90
|
+
const response = await origFetch(input, init);
|
|
91
|
+
// Clone the response so we can read the body without consuming it for OpenClaw
|
|
92
|
+
// We use a TransformStream to tee the body and parse SSE chunks
|
|
93
|
+
if (!response.body) {
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
const requestId = ++sseRequestCounter;
|
|
97
|
+
const reader = response.body.getReader();
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let sseBuffer = '';
|
|
100
|
+
let extractedUsage = null;
|
|
101
|
+
/** Parse a single SSE data line for usage info */
|
|
102
|
+
function tryExtractUsage(dataStr) {
|
|
103
|
+
try {
|
|
104
|
+
const evt = JSON.parse(dataStr);
|
|
105
|
+
if (evt.usage && (evt.usage.prompt_tokens || evt.usage.input_tokens)) {
|
|
106
|
+
const u = evt.usage;
|
|
107
|
+
extractedUsage = {
|
|
108
|
+
input: u.prompt_tokens ?? u.input_tokens ?? 0,
|
|
109
|
+
output: u.completion_tokens ?? u.output_tokens ?? 0,
|
|
110
|
+
cacheRead: u.cache_read_input_tokens ?? 0,
|
|
111
|
+
cacheWrite: u.cache_creation_input_tokens ?? 0,
|
|
112
|
+
total: u.total_tokens ?? 0,
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch { /* ignore parse errors */ }
|
|
118
|
+
}
|
|
119
|
+
/** Process buffered SSE lines */
|
|
120
|
+
function processSseLines() {
|
|
121
|
+
const lines = sseBuffer.split('\n');
|
|
122
|
+
sseBuffer = lines.pop() || ''; // keep last incomplete line
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
if (!line.startsWith('data: '))
|
|
125
|
+
continue;
|
|
126
|
+
const data = line.slice(6).trim();
|
|
127
|
+
if (data === '[DONE]')
|
|
128
|
+
continue;
|
|
129
|
+
tryExtractUsage(data);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Pipe response body through a pass-through ReadableStream
|
|
133
|
+
// that also extracts usage from SSE chunks
|
|
134
|
+
const newBody = new ReadableStream({
|
|
135
|
+
async start(controller) {
|
|
136
|
+
try {
|
|
137
|
+
while (true) {
|
|
138
|
+
const { done, value } = await reader.read();
|
|
139
|
+
if (done) {
|
|
140
|
+
// Process any remaining data in the buffer
|
|
141
|
+
if (sseBuffer.trim())
|
|
142
|
+
processSseLines();
|
|
143
|
+
if (extractedUsage) {
|
|
144
|
+
sseUsageCache.set(requestId, extractedUsage);
|
|
145
|
+
latestSseUsage = extractedUsage;
|
|
146
|
+
}
|
|
147
|
+
controller.close();
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
controller.enqueue(value);
|
|
151
|
+
sseBuffer += decoder.decode(value, { stream: true });
|
|
152
|
+
processSseLines();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
controller.error(err);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
return new Response(newBody, {
|
|
161
|
+
status: response.status,
|
|
162
|
+
statusText: response.statusText,
|
|
163
|
+
headers: response.headers,
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
fetchInterceptorInstalled = true;
|
|
167
|
+
console.log('[audit-duckdb] Fetch interceptor installed for token usage tracking');
|
|
168
|
+
}
|
|
169
|
+
function uninstallFetchInterceptor() {
|
|
170
|
+
if (originalFetch) {
|
|
171
|
+
globalThis.fetch = originalFetch;
|
|
172
|
+
originalFetch = null;
|
|
173
|
+
fetchInterceptorInstalled = false;
|
|
174
|
+
console.log('[audit-duckdb] Fetch interceptor uninstalled');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Pop the most recent SSE usage (consume once per llm_output call)
|
|
179
|
+
*/
|
|
180
|
+
function popLatestSseUsage() {
|
|
181
|
+
const usage = latestSseUsage;
|
|
182
|
+
latestSseUsage = null;
|
|
183
|
+
// Also trim old cache entries
|
|
184
|
+
if (sseUsageCache.size > 100) {
|
|
185
|
+
const keys = Array.from(sseUsageCache.keys()).sort((a, b) => a - b);
|
|
186
|
+
for (let i = 0; i < keys.length - 50; i++) {
|
|
187
|
+
sseUsageCache.delete(keys[i]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return usage;
|
|
191
|
+
}
|
|
192
|
+
const runInputs = new Map();
|
|
193
|
+
/**
|
|
194
|
+
* Periodically clean up stale Map entries to prevent memory leaks when llm_input fires but llm_output does not
|
|
195
|
+
*/
|
|
196
|
+
function cleanupStaleMaps() {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
for (const [key, startTime] of runStartTimes) {
|
|
199
|
+
if (now - startTime > MAP_ENTRY_TTL_MS) {
|
|
200
|
+
runStartTimes.delete(key);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// runInputs has no timestamps; trim oldest entries when exceeding size limit
|
|
204
|
+
if (runInputs.size > MAX_MAP_ENTRIES) {
|
|
205
|
+
const keys = Array.from(runInputs.keys());
|
|
206
|
+
for (let i = 0; i < keys.length - MAX_MAP_ENTRIES; i++) {
|
|
207
|
+
runInputs.delete(keys[i]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// sseUsageCache: trim old entries
|
|
211
|
+
if (sseUsageCache.size > 100) {
|
|
212
|
+
const keys = Array.from(sseUsageCache.keys()).sort((a, b) => a - b);
|
|
213
|
+
for (let i = 0; i < keys.length - 50; i++) {
|
|
214
|
+
sseUsageCache.delete(keys[i]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// sessionContextMap: trim oversized context Map
|
|
218
|
+
if (sessionContextMap.size > MAX_MAP_ENTRIES) {
|
|
219
|
+
const keys = Array.from(sessionContextMap.keys());
|
|
220
|
+
for (let i = 0; i < keys.length - MAX_MAP_ENTRIES; i++) {
|
|
221
|
+
sessionContextMap.delete(keys[i]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// sessionStatsMap: evict oldest entries when exceeding limit
|
|
225
|
+
if (sessionStatsMap.size > MAX_SESSION_STATS) {
|
|
226
|
+
const entries = Array.from(sessionStatsMap.entries());
|
|
227
|
+
entries.sort((a, b) => a[1].lastSeen.getTime() - b[1].lastSeen.getTime());
|
|
228
|
+
const toRemove = entries.length - MAX_SESSION_STATS;
|
|
229
|
+
for (let i = 0; i < toRemove; i++) {
|
|
230
|
+
sessionStatsMap.delete(entries[i][0]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Extract image/media metadata from historyMessages
|
|
236
|
+
* OpenClaw message format:
|
|
237
|
+
* UserMessage.content = string | (TextContent | ImageContent)[]
|
|
238
|
+
* ToolResultMessage.content = (TextContent | ImageContent)[]
|
|
239
|
+
* ImageContent = { type: "image", data: base64string, mimeType: string }
|
|
240
|
+
*/
|
|
241
|
+
function extractMediaMeta(historyMessages) {
|
|
242
|
+
const media = [];
|
|
243
|
+
if (!Array.isArray(historyMessages))
|
|
244
|
+
return media;
|
|
245
|
+
for (const msg of historyMessages) {
|
|
246
|
+
if (!msg || typeof msg !== 'object')
|
|
247
|
+
continue;
|
|
248
|
+
const m = msg;
|
|
249
|
+
const role = m.role;
|
|
250
|
+
const content = m.content;
|
|
251
|
+
// content can be string (plain text) or array (multimodal)
|
|
252
|
+
if (!Array.isArray(content))
|
|
253
|
+
continue;
|
|
254
|
+
const source = role === 'toolResult' ? 'tool' : 'user';
|
|
255
|
+
for (const part of content) {
|
|
256
|
+
if (!part || typeof part !== 'object')
|
|
257
|
+
continue;
|
|
258
|
+
const p = part;
|
|
259
|
+
if (p.type === 'image' && typeof p.data === 'string') {
|
|
260
|
+
const base64Len = p.data.length;
|
|
261
|
+
// base64 encoded size is ~4/3 of original, so multiply by 0.75 to approximate original bytes
|
|
262
|
+
const sizeBytes = Math.round(base64Len * 0.75);
|
|
263
|
+
media.push({
|
|
264
|
+
mimeType: p.mimeType || 'unknown',
|
|
265
|
+
sizeBytes,
|
|
266
|
+
source,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return media;
|
|
272
|
+
}
|
|
273
|
+
const sessionContextMap = new Map();
|
|
274
|
+
/** Most recently active sessionId (ultimate fallback when hook does not carry sessionId) */
|
|
275
|
+
let lastFallbackSessionId = 'unknown';
|
|
276
|
+
/** Get or create session context */
|
|
277
|
+
function getSessionCtx(sessionId) {
|
|
278
|
+
let ctx = sessionContextMap.get(sessionId);
|
|
279
|
+
if (!ctx) {
|
|
280
|
+
ctx = { sessionId, userId: '', modelName: '' };
|
|
281
|
+
sessionContextMap.set(sessionId, ctx);
|
|
282
|
+
}
|
|
283
|
+
return ctx;
|
|
284
|
+
}
|
|
285
|
+
/** Resolve sessionId from hook parameters (priority: event → ctx → fallback) */
|
|
286
|
+
function resolveSessionId(eventSessionId, ctxSessionId) {
|
|
287
|
+
if (eventSessionId && eventSessionId !== 'unknown') {
|
|
288
|
+
lastFallbackSessionId = eventSessionId;
|
|
289
|
+
return eventSessionId;
|
|
290
|
+
}
|
|
291
|
+
if (ctxSessionId && ctxSessionId !== 'unknown') {
|
|
292
|
+
lastFallbackSessionId = ctxSessionId;
|
|
293
|
+
return ctxSessionId;
|
|
294
|
+
}
|
|
295
|
+
return lastFallbackSessionId;
|
|
296
|
+
}
|
|
297
|
+
const sessionStatsMap = new Map();
|
|
298
|
+
/** Max session stats entries */
|
|
299
|
+
const MAX_SESSION_STATS = 200;
|
|
300
|
+
/** Update session statistics and return AuditSession */
|
|
301
|
+
function updateSessionStats(sessionId, modelName, userId, tokens) {
|
|
302
|
+
let stats = sessionStatsMap.get(sessionId);
|
|
303
|
+
if (!stats) {
|
|
304
|
+
stats = {
|
|
305
|
+
firstSeen: new Date(),
|
|
306
|
+
lastSeen: new Date(),
|
|
307
|
+
modelName,
|
|
308
|
+
userId,
|
|
309
|
+
totalActions: 0,
|
|
310
|
+
totalTokens: 0,
|
|
311
|
+
};
|
|
312
|
+
sessionStatsMap.set(sessionId, stats);
|
|
313
|
+
}
|
|
314
|
+
stats.lastSeen = new Date();
|
|
315
|
+
stats.totalActions += 1;
|
|
316
|
+
stats.totalTokens += tokens;
|
|
317
|
+
if (modelName)
|
|
318
|
+
stats.modelName = modelName;
|
|
319
|
+
if (userId)
|
|
320
|
+
stats.userId = userId;
|
|
321
|
+
return {
|
|
322
|
+
sessionId,
|
|
323
|
+
userId: stats.userId,
|
|
324
|
+
modelName: stats.modelName,
|
|
325
|
+
startTime: stats.firstSeen,
|
|
326
|
+
endTime: stats.lastSeen,
|
|
327
|
+
totalActions: stats.totalActions,
|
|
328
|
+
totalTokens: stats.totalTokens,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** Helper to build an AuditAction (auto-fills defaults) */
|
|
332
|
+
function makeAction(sessionId, overrides) {
|
|
333
|
+
const sctx = getSessionCtx(sessionId);
|
|
334
|
+
return {
|
|
335
|
+
sessionId,
|
|
336
|
+
actionType: overrides.actionType,
|
|
337
|
+
actionName: overrides.actionName,
|
|
338
|
+
modelName: overrides.modelName ?? sctx.modelName,
|
|
339
|
+
inputParams: overrides.inputParams ?? null,
|
|
340
|
+
outputResult: overrides.outputResult ?? null,
|
|
341
|
+
promptTokens: overrides.promptTokens ?? null,
|
|
342
|
+
completionTokens: overrides.completionTokens ?? null,
|
|
343
|
+
durationMs: overrides.durationMs ?? null,
|
|
344
|
+
userId: overrides.userId ?? sctx.userId,
|
|
345
|
+
createdAt: overrides.createdAt ?? new Date(),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/* ------------------------------------------------------------------ */
|
|
349
|
+
/* Plugin registration */
|
|
350
|
+
/* ------------------------------------------------------------------ */
|
|
351
|
+
function activate(api) {
|
|
352
|
+
const rawConfig = (api.pluginConfig || api.config || {});
|
|
353
|
+
const config = (0, config_1.resolveConfig)(rawConfig);
|
|
354
|
+
console.log(`[audit-duckdb] Config resolved: mode=${config.mode} ` +
|
|
355
|
+
(config.mode === 'remote'
|
|
356
|
+
? `mysql.host=${config.mysql.host} mysql.database=${config.mysql.database}`
|
|
357
|
+
: `duckdb.path=${config.duckdb.path}`));
|
|
358
|
+
const redactor = new redaction_1.Redactor(config.redaction);
|
|
359
|
+
// Select storage backend based on mode
|
|
360
|
+
let writer;
|
|
361
|
+
if (config.mode === 'remote') {
|
|
362
|
+
writer = new mysql_writer_1.MySQLWriter(config.mysql);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
writer = new duckdb_local_writer_1.DuckDBLocalWriter(config.duckdb);
|
|
366
|
+
}
|
|
367
|
+
const buffer = new buffer_1.AsyncBatchBuffer(config.buffer, (entries) => writer.writeBatch(entries));
|
|
368
|
+
// Install fetch interceptor for token usage tracking
|
|
369
|
+
installFetchInterceptor();
|
|
370
|
+
// Security scanner
|
|
371
|
+
const securityConfig = (0, scanner_1.resolveSecurityConfig)(config.security);
|
|
372
|
+
const securityScanner = new scanner_1.SecurityScanner(securityConfig);
|
|
373
|
+
/** Security alert buffer (batch write) */
|
|
374
|
+
const alertBuffer = [];
|
|
375
|
+
let alertFlushTimer = null;
|
|
376
|
+
let mapCleanupTimer = null;
|
|
377
|
+
// Check whether database can be connected
|
|
378
|
+
// - local mode: always available (local file, zero config)
|
|
379
|
+
// - remote mode: requires user-configured connection info
|
|
380
|
+
const dbConfigured = config.mode === 'local'
|
|
381
|
+
|| (config.mysql.host !== 'localhost' || config.mysql.password !== '');
|
|
382
|
+
if (dbConfigured) {
|
|
383
|
+
// Async background initialization
|
|
384
|
+
writer
|
|
385
|
+
.initialize()
|
|
386
|
+
.then(() => {
|
|
387
|
+
buffer.start();
|
|
388
|
+
// Start security alert flush timer (every 30s)
|
|
389
|
+
alertFlushTimer = setInterval(() => void flushAlerts(), 30000);
|
|
390
|
+
if (alertFlushTimer && typeof alertFlushTimer === 'object' && 'unref' in alertFlushTimer) {
|
|
391
|
+
alertFlushTimer.unref();
|
|
392
|
+
}
|
|
393
|
+
// Start Map cleanup timer (every 60s)
|
|
394
|
+
mapCleanupTimer = setInterval(cleanupStaleMaps, 60000);
|
|
395
|
+
if (mapCleanupTimer && typeof mapCleanupTimer === 'object' && 'unref' in mapCleanupTimer) {
|
|
396
|
+
mapCleanupTimer.unref();
|
|
397
|
+
}
|
|
398
|
+
console.log(`[audit-duckdb] Plugin activated (mode=${config.mode}, security scanner enabled)`);
|
|
399
|
+
})
|
|
400
|
+
.catch((error) => {
|
|
401
|
+
console.error('[audit-duckdb] Failed to initialize database:', error);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
console.log('[audit-duckdb] MySQL not configured yet — skipping database connection. ' +
|
|
406
|
+
'Please configure via Dashboard (Settings → Plugins → audit-duckdb Config) then restart.');
|
|
407
|
+
}
|
|
408
|
+
// Register audit panel Web UI (mounted on Gateway HTTP server)
|
|
409
|
+
if (typeof api.registerHttpRoute === 'function') {
|
|
410
|
+
(0, routes_1.registerAuditRoutes)(api.registerHttpRoute.bind(api), writer);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
console.warn('[audit-duckdb] registerHttpRoute not available — audit UI disabled');
|
|
414
|
+
}
|
|
415
|
+
// ====================== Helper functions ======================
|
|
416
|
+
/** Record action, update session stats, and run security scan */
|
|
417
|
+
function recordAction(action, tokens = 0) {
|
|
418
|
+
void buffer.addAction(action);
|
|
419
|
+
const session = updateSessionStats(action.sessionId, action.modelName, action.userId, tokens);
|
|
420
|
+
void buffer.addSession(session);
|
|
421
|
+
// --- Security scan ---
|
|
422
|
+
if (securityConfig.enabled) {
|
|
423
|
+
try {
|
|
424
|
+
const alerts = securityScanner.scan(action);
|
|
425
|
+
if (alerts.length > 0) {
|
|
426
|
+
// Prevent alertBuffer from growing unbounded
|
|
427
|
+
if (alertBuffer.length + alerts.length > MAX_ALERT_BUFFER) {
|
|
428
|
+
const overflow = alertBuffer.length + alerts.length - MAX_ALERT_BUFFER;
|
|
429
|
+
alertBuffer.splice(0, overflow);
|
|
430
|
+
console.warn(`[audit-security] Alert buffer overflow, dropped ${overflow} oldest alerts`);
|
|
431
|
+
}
|
|
432
|
+
alertBuffer.push(...alerts);
|
|
433
|
+
for (const alert of alerts) {
|
|
434
|
+
const icon = alert.severity === 'critical' ? '🔴' : alert.severity === 'warn' ? '🟡' : 'ℹ️';
|
|
435
|
+
console.log(`[audit-security] ${icon} ${alert.severity.toUpperCase()} ${alert.ruleId}: ${alert.ruleName} — ${alert.finding} (session=${alert.sessionId})`);
|
|
436
|
+
}
|
|
437
|
+
// Flush immediately on CRITICAL alerts
|
|
438
|
+
if (alerts.some(a => a.severity === 'critical')) {
|
|
439
|
+
void flushAlerts();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
console.error('[audit-security] Scan error:', err);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Batch-write security alerts */
|
|
449
|
+
async function flushAlerts() {
|
|
450
|
+
if (alertBuffer.length === 0)
|
|
451
|
+
return;
|
|
452
|
+
const batch = alertBuffer.splice(0, alertBuffer.length);
|
|
453
|
+
try {
|
|
454
|
+
await writer.writeAlerts(batch);
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
console.error('[audit-security] Failed to write alerts:', err);
|
|
458
|
+
// Put back into buffer
|
|
459
|
+
alertBuffer.unshift(...batch);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Safely truncate a string */
|
|
463
|
+
function truncate(s, max = 500) {
|
|
464
|
+
if (typeof s !== 'string')
|
|
465
|
+
return '';
|
|
466
|
+
return s.length > max ? s.substring(0, max) + '…' : s;
|
|
467
|
+
}
|
|
468
|
+
/** Convert bytes to human-readable format */
|
|
469
|
+
function formatBytes(bytes) {
|
|
470
|
+
if (bytes < 1024)
|
|
471
|
+
return `${bytes} B`;
|
|
472
|
+
if (bytes < 1024 * 1024)
|
|
473
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
474
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
475
|
+
}
|
|
476
|
+
// =====================================================================
|
|
477
|
+
// 1. Agent lifecycle
|
|
478
|
+
// =====================================================================
|
|
479
|
+
// before_model_resolve: before model selection
|
|
480
|
+
api.on('before_model_resolve', (event, ctx) => {
|
|
481
|
+
try {
|
|
482
|
+
const e = event;
|
|
483
|
+
const c = ctx;
|
|
484
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
485
|
+
const sctx = getSessionCtx(sid);
|
|
486
|
+
if (c?.agentId)
|
|
487
|
+
sctx.userId = c.agentId;
|
|
488
|
+
recordAction(makeAction(sid, {
|
|
489
|
+
actionType: types_1.ActionType.ModelResolve,
|
|
490
|
+
actionName: 'before_model_resolve',
|
|
491
|
+
userId: c?.agentId,
|
|
492
|
+
inputParams: redactor.redact({ prompt: truncate(e.prompt) }),
|
|
493
|
+
}));
|
|
494
|
+
console.log(`[audit-duckdb] before_model_resolve: session=${sid}`);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
console.error('[audit-duckdb] Error in before_model_resolve:', err);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
// before_prompt_build: before prompt construction
|
|
501
|
+
api.on('before_prompt_build', (event, ctx) => {
|
|
502
|
+
try {
|
|
503
|
+
const e = event;
|
|
504
|
+
const c = ctx;
|
|
505
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
506
|
+
recordAction(makeAction(sid, {
|
|
507
|
+
actionType: types_1.ActionType.PromptBuild,
|
|
508
|
+
actionName: 'before_prompt_build',
|
|
509
|
+
userId: c?.agentId,
|
|
510
|
+
inputParams: redactor.redact({
|
|
511
|
+
prompt: truncate(e.prompt),
|
|
512
|
+
messageCount: Array.isArray(e.messages) ? e.messages.length : 0,
|
|
513
|
+
}),
|
|
514
|
+
}));
|
|
515
|
+
console.log(`[audit-duckdb] before_prompt_build: session=${sid} msgs=${Array.isArray(e.messages) ? e.messages.length : '?'}`);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
console.error('[audit-duckdb] Error in before_prompt_build:', err);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
// before_agent_start: skipped — legacy hook that fires twice
|
|
522
|
+
// and fully overlaps with before_model_resolve + before_prompt_build
|
|
523
|
+
// agent_end: Agent run finished
|
|
524
|
+
api.on('agent_end', (event, ctx) => {
|
|
525
|
+
try {
|
|
526
|
+
const e = event;
|
|
527
|
+
const c = ctx;
|
|
528
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
529
|
+
recordAction(makeAction(sid, {
|
|
530
|
+
actionType: types_1.ActionType.AgentEnd,
|
|
531
|
+
actionName: 'agent_end',
|
|
532
|
+
userId: c?.agentId,
|
|
533
|
+
outputResult: redactor.redact({
|
|
534
|
+
success: e.success,
|
|
535
|
+
error: e.error,
|
|
536
|
+
messageCount: Array.isArray(e.messages) ? e.messages.length : 0,
|
|
537
|
+
}),
|
|
538
|
+
durationMs: e.durationMs ?? null,
|
|
539
|
+
}));
|
|
540
|
+
console.log(`[audit-duckdb] agent_end: session=${sid} success=${e.success} duration=${e.durationMs}ms`);
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
console.error('[audit-duckdb] Error in agent_end:', err);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
// =====================================================================
|
|
547
|
+
// 2. LLM calls
|
|
548
|
+
// =====================================================================
|
|
549
|
+
// llm_input: before LLM call — record start time + cache user input
|
|
550
|
+
api.on('llm_input', (event, ctx) => {
|
|
551
|
+
try {
|
|
552
|
+
const e = event;
|
|
553
|
+
const c = ctx;
|
|
554
|
+
const sid = resolveSessionId(e.sessionId, c?.sessionId);
|
|
555
|
+
const sctx = getSessionCtx(sid);
|
|
556
|
+
// Parse image/media metadata from historyMessages
|
|
557
|
+
const media = extractMediaMeta(e.historyMessages ?? []);
|
|
558
|
+
if (e.runId) {
|
|
559
|
+
runStartTimes.set(e.runId, Date.now());
|
|
560
|
+
runInputs.set(e.runId, {
|
|
561
|
+
prompt: e.prompt,
|
|
562
|
+
imagesCount: e.imagesCount,
|
|
563
|
+
media: media.length > 0 ? media : undefined,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (c?.agentId)
|
|
567
|
+
sctx.userId = c.agentId;
|
|
568
|
+
sctx.modelName = `${e.provider}/${e.model}`;
|
|
569
|
+
console.log(`[audit-duckdb] llm_input: session=${sid} model=${e.provider}/${e.model} runId=${e.runId} images=${e.imagesCount ?? 0} mediaParts=${media.length}`);
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
console.error('[audit-duckdb] Error in llm_input:', err);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
// llm_output: after LLM call — primary audit record point
|
|
576
|
+
api.on('llm_output', (event, ctx) => {
|
|
577
|
+
try {
|
|
578
|
+
const e = event;
|
|
579
|
+
const c = ctx;
|
|
580
|
+
// Calculate duration
|
|
581
|
+
let durationMs = null;
|
|
582
|
+
if (e.runId && runStartTimes.has(e.runId)) {
|
|
583
|
+
durationMs = Date.now() - runStartTimes.get(e.runId);
|
|
584
|
+
runStartTimes.delete(e.runId);
|
|
585
|
+
}
|
|
586
|
+
// Retrieve cached user input
|
|
587
|
+
const cachedInput = e.runId ? runInputs.get(e.runId) : undefined;
|
|
588
|
+
if (e.runId)
|
|
589
|
+
runInputs.delete(e.runId);
|
|
590
|
+
// Token usage: multiple extraction strategies
|
|
591
|
+
const sid = resolveSessionId(e.sessionId, c?.sessionId);
|
|
592
|
+
let promptTokens = e.usage?.input ?? null;
|
|
593
|
+
let completionTokens = e.usage?.output ?? null;
|
|
594
|
+
let cacheRead = e.usage?.cacheRead ?? null;
|
|
595
|
+
let cacheWrite = e.usage?.cacheWrite ?? null;
|
|
596
|
+
// Strategy 2: extract from lastAssistant message (OpenClaw stores usage in assistant message)
|
|
597
|
+
if (promptTokens === null && completionTokens === null && e.lastAssistant) {
|
|
598
|
+
const la = e.lastAssistant;
|
|
599
|
+
const rawUsage = la.usage;
|
|
600
|
+
if (rawUsage) {
|
|
601
|
+
const inp = rawUsage.input ?? rawUsage.inputTokens ?? rawUsage.prompt_tokens ?? rawUsage.promptTokens;
|
|
602
|
+
const out = rawUsage.output ?? rawUsage.outputTokens ?? rawUsage.completion_tokens ?? rawUsage.completionTokens;
|
|
603
|
+
if (typeof inp === 'number' && inp > 0)
|
|
604
|
+
promptTokens = inp;
|
|
605
|
+
if (typeof out === 'number' && out > 0)
|
|
606
|
+
completionTokens = out;
|
|
607
|
+
if (typeof rawUsage.cacheRead === 'number')
|
|
608
|
+
cacheRead = rawUsage.cacheRead;
|
|
609
|
+
if (typeof rawUsage.cacheWrite === 'number')
|
|
610
|
+
cacheWrite = rawUsage.cacheWrite;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Strategy 3: SSE usage captured by fetch interceptor (stream_options injection)
|
|
614
|
+
if (promptTokens === null && completionTokens === null) {
|
|
615
|
+
const sseUsage = popLatestSseUsage();
|
|
616
|
+
if (sseUsage) {
|
|
617
|
+
promptTokens = sseUsage.input || null;
|
|
618
|
+
completionTokens = sseUsage.output || null;
|
|
619
|
+
cacheRead = sseUsage.cacheRead || null;
|
|
620
|
+
cacheWrite = sseUsage.cacheWrite || null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0);
|
|
624
|
+
const modelName = `${e.provider}/${e.model}`;
|
|
625
|
+
// Build inputParams with media metadata
|
|
626
|
+
const inputData = {
|
|
627
|
+
userMessage: cachedInput?.prompt ?? '',
|
|
628
|
+
imagesCount: cachedInput?.imagesCount ?? 0,
|
|
629
|
+
};
|
|
630
|
+
if (cachedInput?.media && cachedInput.media.length > 0) {
|
|
631
|
+
inputData.media = cachedInput.media.map((m) => ({
|
|
632
|
+
mimeType: m.mimeType,
|
|
633
|
+
sizeBytes: m.sizeBytes,
|
|
634
|
+
sizeHuman: formatBytes(m.sizeBytes),
|
|
635
|
+
source: m.source,
|
|
636
|
+
}));
|
|
637
|
+
inputData.mediaTotalBytes = cachedInput.media.reduce((sum, m) => sum + m.sizeBytes, 0);
|
|
638
|
+
}
|
|
639
|
+
const sctx = getSessionCtx(sid);
|
|
640
|
+
if (c?.agentId)
|
|
641
|
+
sctx.userId = c.agentId;
|
|
642
|
+
sctx.modelName = modelName;
|
|
643
|
+
recordAction(makeAction(sid, {
|
|
644
|
+
actionType: types_1.ActionType.Message,
|
|
645
|
+
actionName: `llm_call:${modelName}`,
|
|
646
|
+
modelName,
|
|
647
|
+
inputParams: redactor.redact(inputData),
|
|
648
|
+
outputResult: redactor.redact({
|
|
649
|
+
assistantTexts: e.assistantTexts ?? [],
|
|
650
|
+
}),
|
|
651
|
+
promptTokens,
|
|
652
|
+
completionTokens,
|
|
653
|
+
durationMs,
|
|
654
|
+
userId: c?.agentId,
|
|
655
|
+
}), totalTokens);
|
|
656
|
+
console.log(`[audit-duckdb] llm_output: session=${e.sessionId} model=${e.model} duration=${durationMs}ms tokens=${promptTokens ?? '?'}/${completionTokens ?? '?'} cache_r=${cacheRead ?? '-'} cache_w=${cacheWrite ?? '-'}`);
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
console.error('[audit-duckdb] Error in llm_output:', err);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
// =====================================================================
|
|
663
|
+
// 3. Context management
|
|
664
|
+
// =====================================================================
|
|
665
|
+
// before_compaction: before context compaction
|
|
666
|
+
api.on('before_compaction', (event, ctx) => {
|
|
667
|
+
try {
|
|
668
|
+
const e = event;
|
|
669
|
+
const c = ctx;
|
|
670
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
671
|
+
recordAction(makeAction(sid, {
|
|
672
|
+
actionType: types_1.ActionType.Compaction,
|
|
673
|
+
actionName: 'before_compaction',
|
|
674
|
+
userId: c?.agentId,
|
|
675
|
+
inputParams: redactor.redact({
|
|
676
|
+
messageCount: e.messageCount,
|
|
677
|
+
compactingCount: e.compactingCount,
|
|
678
|
+
tokenCount: e.tokenCount,
|
|
679
|
+
}),
|
|
680
|
+
}));
|
|
681
|
+
console.log(`[audit-duckdb] before_compaction: session=${sid} msgs=${e.messageCount} tokens=${e.tokenCount}`);
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
console.error('[audit-duckdb] Error in before_compaction:', err);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
// after_compaction: after context compaction
|
|
688
|
+
api.on('after_compaction', (event, ctx) => {
|
|
689
|
+
try {
|
|
690
|
+
const e = event;
|
|
691
|
+
const c = ctx;
|
|
692
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
693
|
+
recordAction(makeAction(sid, {
|
|
694
|
+
actionType: types_1.ActionType.Compaction,
|
|
695
|
+
actionName: 'after_compaction',
|
|
696
|
+
userId: c?.agentId,
|
|
697
|
+
outputResult: redactor.redact({
|
|
698
|
+
messageCount: e.messageCount,
|
|
699
|
+
compactedCount: e.compactedCount,
|
|
700
|
+
tokenCount: e.tokenCount,
|
|
701
|
+
}),
|
|
702
|
+
}));
|
|
703
|
+
console.log(`[audit-duckdb] after_compaction: session=${sid} compacted=${e.compactedCount}`);
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
console.error('[audit-duckdb] Error in after_compaction:', err);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
// before_reset: before session reset
|
|
710
|
+
api.on('before_reset', (event, ctx) => {
|
|
711
|
+
try {
|
|
712
|
+
const e = event;
|
|
713
|
+
const c = ctx;
|
|
714
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
715
|
+
recordAction(makeAction(sid, {
|
|
716
|
+
actionType: types_1.ActionType.Reset,
|
|
717
|
+
actionName: 'before_reset',
|
|
718
|
+
userId: c?.agentId,
|
|
719
|
+
inputParams: redactor.redact({
|
|
720
|
+
reason: e.reason,
|
|
721
|
+
messageCount: Array.isArray(e.messages) ? e.messages.length : 0,
|
|
722
|
+
}),
|
|
723
|
+
}));
|
|
724
|
+
console.log(`[audit-duckdb] before_reset: session=${sid} reason=${e.reason}`);
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
console.error('[audit-duckdb] Error in before_reset:', err);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// =====================================================================
|
|
731
|
+
// 4. Message channel
|
|
732
|
+
// =====================================================================
|
|
733
|
+
// message_received: user message arrived
|
|
734
|
+
api.on('message_received', (event, ctx) => {
|
|
735
|
+
try {
|
|
736
|
+
const e = event;
|
|
737
|
+
const c = ctx;
|
|
738
|
+
const sid = lastFallbackSessionId;
|
|
739
|
+
if (sid === 'unknown')
|
|
740
|
+
return; // No valid session yet, skip (content will be recorded in llm_input)
|
|
741
|
+
recordAction(makeAction(sid, {
|
|
742
|
+
actionType: types_1.ActionType.UserMessage,
|
|
743
|
+
actionName: 'message_received',
|
|
744
|
+
modelName: '',
|
|
745
|
+
inputParams: redactor.redact({
|
|
746
|
+
from: e.from,
|
|
747
|
+
content: e.content,
|
|
748
|
+
channelId: c?.channelId,
|
|
749
|
+
metadata: e.metadata,
|
|
750
|
+
}),
|
|
751
|
+
createdAt: new Date(e.timestamp || Date.now()),
|
|
752
|
+
}));
|
|
753
|
+
console.log(`[audit-duckdb] message_received: from=${e.from} channel=${c?.channelId} len=${e.content?.length}`);
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
console.error('[audit-duckdb] Error in message_received:', err);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
// message_sending: before message send (interceptable/modifiable)
|
|
760
|
+
api.on('message_sending', (event, ctx) => {
|
|
761
|
+
try {
|
|
762
|
+
const e = event;
|
|
763
|
+
const c = ctx;
|
|
764
|
+
const sid = lastFallbackSessionId;
|
|
765
|
+
if (sid === 'unknown')
|
|
766
|
+
return;
|
|
767
|
+
recordAction(makeAction(sid, {
|
|
768
|
+
actionType: types_1.ActionType.MsgSending,
|
|
769
|
+
actionName: 'message_sending',
|
|
770
|
+
modelName: '',
|
|
771
|
+
inputParams: redactor.redact({
|
|
772
|
+
to: e.to,
|
|
773
|
+
content: truncate(e.content, 1000),
|
|
774
|
+
channelId: c?.channelId,
|
|
775
|
+
}),
|
|
776
|
+
}));
|
|
777
|
+
console.log(`[audit-duckdb] message_sending: to=${e.to} channel=${c?.channelId}`);
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
console.error('[audit-duckdb] Error in message_sending:', err);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
// message_sent: message sent
|
|
784
|
+
api.on('message_sent', (event, ctx) => {
|
|
785
|
+
try {
|
|
786
|
+
const e = event;
|
|
787
|
+
const c = ctx;
|
|
788
|
+
const sid = lastFallbackSessionId;
|
|
789
|
+
if (sid === 'unknown')
|
|
790
|
+
return;
|
|
791
|
+
recordAction(makeAction(sid, {
|
|
792
|
+
actionType: types_1.ActionType.AssistantMsg,
|
|
793
|
+
actionName: 'message_sent',
|
|
794
|
+
modelName: '',
|
|
795
|
+
outputResult: redactor.redact({
|
|
796
|
+
to: e.to,
|
|
797
|
+
content: e.content,
|
|
798
|
+
success: e.success,
|
|
799
|
+
error: e.error,
|
|
800
|
+
channelId: c?.channelId,
|
|
801
|
+
}),
|
|
802
|
+
}));
|
|
803
|
+
console.log(`[audit-duckdb] message_sent: to=${e.to} success=${e.success} channel=${c?.channelId}`);
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
console.error('[audit-duckdb] Error in message_sent:', err);
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
// before_message_write: skipped — message content already fully recorded in llm_input/llm_output, no additional audit value
|
|
810
|
+
// =====================================================================
|
|
811
|
+
// 5. Tool calls
|
|
812
|
+
// =====================================================================
|
|
813
|
+
// before_tool_call: before tool call — record start time
|
|
814
|
+
api.on('before_tool_call', (event, ctx) => {
|
|
815
|
+
try {
|
|
816
|
+
const e = event;
|
|
817
|
+
const c = ctx;
|
|
818
|
+
// Use toolCallId as timing key to avoid collision with llm_input runId
|
|
819
|
+
// Note: cannot use Date.now() as fallback, otherwise before and after generate inconsistent keys
|
|
820
|
+
const callId = e.toolCallId || c?.toolCallId || '';
|
|
821
|
+
const timingKey = callId ? `tc:${callId}` : `tc:${e.toolName}`;
|
|
822
|
+
runStartTimes.set(timingKey, Date.now());
|
|
823
|
+
// Update per-session context
|
|
824
|
+
const sid = resolveSessionId(undefined, c?.sessionId);
|
|
825
|
+
const sctx = getSessionCtx(sid);
|
|
826
|
+
if (c?.agentId)
|
|
827
|
+
sctx.userId = c.agentId;
|
|
828
|
+
console.log(`[audit-duckdb] before_tool_call: tool=${e.toolName} session=${sid} callId=${callId}`);
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
console.error('[audit-duckdb] Error in before_tool_call:', err);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
// after_tool_call: after tool call — generate audit record
|
|
835
|
+
api.on('after_tool_call', (event, ctx) => {
|
|
836
|
+
try {
|
|
837
|
+
const e = event;
|
|
838
|
+
const c = ctx;
|
|
839
|
+
const toolName = e.toolName;
|
|
840
|
+
// Get sessionId from ctx (event does not have this field)
|
|
841
|
+
const sessionId = resolveSessionId(undefined, c?.sessionId);
|
|
842
|
+
// Calculate duration — use toolCallId as key to not interfere with LLM runId timing
|
|
843
|
+
const callId = e.toolCallId || c?.toolCallId || '';
|
|
844
|
+
const timingKey = callId ? `tc:${callId}` : `tc:${e.toolName}`;
|
|
845
|
+
let durationMs = e.durationMs ?? null;
|
|
846
|
+
if (durationMs === null && runStartTimes.has(timingKey)) {
|
|
847
|
+
durationMs = Date.now() - runStartTimes.get(timingKey);
|
|
848
|
+
}
|
|
849
|
+
runStartTimes.delete(timingKey);
|
|
850
|
+
// BUG FIX: use correct field name params (not parameters/input/args)
|
|
851
|
+
const inputParams = e.params ? redactor.redact(e.params) : null;
|
|
852
|
+
// Detect image content in tool results
|
|
853
|
+
let outputData = null;
|
|
854
|
+
if (e.error) {
|
|
855
|
+
outputData = redactor.redact({ error: e.error });
|
|
856
|
+
}
|
|
857
|
+
else if (e.result !== undefined) {
|
|
858
|
+
const raw = typeof e.result === 'object' && e.result !== null
|
|
859
|
+
? e.result
|
|
860
|
+
: { value: e.result };
|
|
861
|
+
// If tool result contains content array, check for images
|
|
862
|
+
const resultContent = raw.content;
|
|
863
|
+
if (Array.isArray(resultContent)) {
|
|
864
|
+
const toolMedia = [];
|
|
865
|
+
for (const part of resultContent) {
|
|
866
|
+
if (part && typeof part === 'object' && part.type === 'image' && typeof part.data === 'string') {
|
|
867
|
+
toolMedia.push({
|
|
868
|
+
mimeType: part.mimeType || 'unknown',
|
|
869
|
+
sizeBytes: Math.round(part.data.length * 0.75),
|
|
870
|
+
source: 'tool',
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (toolMedia.length > 0) {
|
|
875
|
+
// Don't write full base64 to audit record, only record metadata
|
|
876
|
+
const sanitized = { ...raw };
|
|
877
|
+
sanitized.content = resultContent.map((part) => {
|
|
878
|
+
if (part && typeof part === 'object' && part.type === 'image') {
|
|
879
|
+
return {
|
|
880
|
+
type: 'image',
|
|
881
|
+
mimeType: part.mimeType || 'unknown',
|
|
882
|
+
sizeBytes: Math.round((part.data?.length ?? 0) * 0.75),
|
|
883
|
+
sizeHuman: formatBytes(Math.round((part.data?.length ?? 0) * 0.75)),
|
|
884
|
+
data: '[base64 omitted]',
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
return part;
|
|
888
|
+
});
|
|
889
|
+
sanitized._mediaCount = toolMedia.length;
|
|
890
|
+
sanitized._mediaTotalBytes = toolMedia.reduce((s, m) => s + m.sizeBytes, 0);
|
|
891
|
+
outputData = redactor.redact(sanitized);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
outputData = redactor.redact(raw);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
outputData = redactor.redact(raw);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
recordAction(makeAction(sessionId, {
|
|
902
|
+
actionType: types_1.ActionType.ToolCall,
|
|
903
|
+
actionName: `tool_call:${toolName}`,
|
|
904
|
+
inputParams,
|
|
905
|
+
outputResult: outputData,
|
|
906
|
+
durationMs,
|
|
907
|
+
userId: c?.agentId,
|
|
908
|
+
}));
|
|
909
|
+
console.log(`[audit-duckdb] after_tool_call: tool=${toolName} session=${sessionId} duration=${durationMs}ms`);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
console.error('[audit-duckdb] Error in after_tool_call:', err);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
// tool_result_persist: tool result persistence (sync hook)
|
|
916
|
+
api.on('tool_result_persist', (event, ctx) => {
|
|
917
|
+
try {
|
|
918
|
+
const e = event;
|
|
919
|
+
const c = ctx;
|
|
920
|
+
const name = e.toolName || c?.toolName || 'unknown';
|
|
921
|
+
const sid = lastFallbackSessionId;
|
|
922
|
+
if (sid === 'unknown')
|
|
923
|
+
return;
|
|
924
|
+
recordAction(makeAction(sid, {
|
|
925
|
+
actionType: types_1.ActionType.ToolPersist,
|
|
926
|
+
actionName: `tool_persist:${name}`,
|
|
927
|
+
inputParams: redactor.redact({
|
|
928
|
+
toolName: name,
|
|
929
|
+
toolCallId: e.toolCallId,
|
|
930
|
+
isSynthetic: e.isSynthetic,
|
|
931
|
+
}),
|
|
932
|
+
}));
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
console.error('[audit-duckdb] Error in tool_result_persist:', err);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
// =====================================================================
|
|
939
|
+
// 6. Session lifecycle
|
|
940
|
+
// =====================================================================
|
|
941
|
+
// session_start: session started
|
|
942
|
+
api.on('session_start', (event, ctx) => {
|
|
943
|
+
try {
|
|
944
|
+
const e = event;
|
|
945
|
+
const c = ctx;
|
|
946
|
+
const sid = resolveSessionId(e.sessionId, c?.sessionId);
|
|
947
|
+
const sctx = getSessionCtx(sid);
|
|
948
|
+
if (c?.agentId)
|
|
949
|
+
sctx.userId = c.agentId;
|
|
950
|
+
recordAction(makeAction(sid, {
|
|
951
|
+
actionType: types_1.ActionType.SessionStart,
|
|
952
|
+
actionName: 'session_start',
|
|
953
|
+
userId: c?.agentId,
|
|
954
|
+
inputParams: redactor.redact({
|
|
955
|
+
resumedFrom: e.resumedFrom,
|
|
956
|
+
}),
|
|
957
|
+
}));
|
|
958
|
+
console.log(`[audit-duckdb] session_start: session=${sid} resumed=${e.resumedFrom}`);
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
console.error('[audit-duckdb] Error in session_start:', err);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
// session_end: session ended — force flush
|
|
965
|
+
api.on('session_end', (event, ctx) => {
|
|
966
|
+
try {
|
|
967
|
+
const e = event;
|
|
968
|
+
const c = ctx;
|
|
969
|
+
const sid = resolveSessionId(e.sessionId, c?.sessionId);
|
|
970
|
+
recordAction(makeAction(sid, {
|
|
971
|
+
actionType: types_1.ActionType.SessionEnd,
|
|
972
|
+
actionName: 'session_end',
|
|
973
|
+
userId: c?.agentId,
|
|
974
|
+
outputResult: redactor.redact({
|
|
975
|
+
messageCount: e.messageCount,
|
|
976
|
+
}),
|
|
977
|
+
durationMs: e.durationMs ?? null,
|
|
978
|
+
}));
|
|
979
|
+
// Force flush to ensure session data is not lost
|
|
980
|
+
void buffer.flush();
|
|
981
|
+
// Clean up session context
|
|
982
|
+
sessionContextMap.delete(sid);
|
|
983
|
+
console.log(`[audit-duckdb] session_end: session=${sid} msgs=${e.messageCount} duration=${e.durationMs}ms`);
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
console.error('[audit-duckdb] Error in session_end:', err);
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
// =====================================================================
|
|
990
|
+
// 7. Sub-agents
|
|
991
|
+
// =====================================================================
|
|
992
|
+
// subagent_spawned: sub-agent created
|
|
993
|
+
api.on('subagent_spawned', (event, ctx) => {
|
|
994
|
+
try {
|
|
995
|
+
const e = event;
|
|
996
|
+
const c = ctx;
|
|
997
|
+
const sid = lastFallbackSessionId;
|
|
998
|
+
if (sid === 'unknown')
|
|
999
|
+
return;
|
|
1000
|
+
recordAction(makeAction(sid, {
|
|
1001
|
+
actionType: types_1.ActionType.SubagentSpawn,
|
|
1002
|
+
actionName: `subagent_spawned:${e.agentId}`,
|
|
1003
|
+
inputParams: redactor.redact({
|
|
1004
|
+
childSessionKey: e.childSessionKey,
|
|
1005
|
+
agentId: e.agentId,
|
|
1006
|
+
label: e.label,
|
|
1007
|
+
mode: e.mode,
|
|
1008
|
+
runId: e.runId,
|
|
1009
|
+
threadRequested: e.threadRequested,
|
|
1010
|
+
requesterSessionKey: c?.requesterSessionKey,
|
|
1011
|
+
}),
|
|
1012
|
+
}));
|
|
1013
|
+
console.log(`[audit-duckdb] subagent_spawned: agent=${e.agentId} child=${e.childSessionKey}`);
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
console.error('[audit-duckdb] Error in subagent_spawned:', err);
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
// subagent_ended: sub-agent ended
|
|
1020
|
+
api.on('subagent_ended', (event, ctx) => {
|
|
1021
|
+
try {
|
|
1022
|
+
const e = event;
|
|
1023
|
+
const sid = lastFallbackSessionId;
|
|
1024
|
+
if (sid === 'unknown')
|
|
1025
|
+
return;
|
|
1026
|
+
recordAction(makeAction(sid, {
|
|
1027
|
+
actionType: types_1.ActionType.SubagentEnd,
|
|
1028
|
+
actionName: `subagent_ended:${e.targetKind}`,
|
|
1029
|
+
outputResult: redactor.redact({
|
|
1030
|
+
targetSessionKey: e.targetSessionKey,
|
|
1031
|
+
reason: e.reason,
|
|
1032
|
+
outcome: e.outcome,
|
|
1033
|
+
error: e.error,
|
|
1034
|
+
}),
|
|
1035
|
+
}));
|
|
1036
|
+
console.log(`[audit-duckdb] subagent_ended: target=${e.targetSessionKey} outcome=${e.outcome}`);
|
|
1037
|
+
}
|
|
1038
|
+
catch (err) {
|
|
1039
|
+
console.error('[audit-duckdb] Error in subagent_ended:', err);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
// =====================================================================
|
|
1043
|
+
// 8. Gateway
|
|
1044
|
+
// =====================================================================
|
|
1045
|
+
// gateway_start: gateway started
|
|
1046
|
+
api.on('gateway_start', (event) => {
|
|
1047
|
+
try {
|
|
1048
|
+
const e = event;
|
|
1049
|
+
recordAction(makeAction('system', {
|
|
1050
|
+
actionType: types_1.ActionType.GatewayStart,
|
|
1051
|
+
actionName: 'gateway_start',
|
|
1052
|
+
modelName: '',
|
|
1053
|
+
userId: 'system',
|
|
1054
|
+
inputParams: { port: e.port },
|
|
1055
|
+
}));
|
|
1056
|
+
console.log(`[audit-duckdb] gateway_start: port=${e.port}`);
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
console.error('[audit-duckdb] Error in gateway_start:', err);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
// gateway_stop: gateway stopped
|
|
1063
|
+
api.on('gateway_stop', (event) => {
|
|
1064
|
+
try {
|
|
1065
|
+
const e = event;
|
|
1066
|
+
recordAction(makeAction('system', {
|
|
1067
|
+
actionType: types_1.ActionType.GatewayStop,
|
|
1068
|
+
actionName: 'gateway_stop',
|
|
1069
|
+
modelName: '',
|
|
1070
|
+
userId: 'system',
|
|
1071
|
+
outputResult: { reason: e.reason },
|
|
1072
|
+
}));
|
|
1073
|
+
// Force flush on gateway stop
|
|
1074
|
+
void buffer.flush();
|
|
1075
|
+
console.log(`[audit-duckdb] gateway_stop: reason=${e.reason}`);
|
|
1076
|
+
}
|
|
1077
|
+
catch (err) {
|
|
1078
|
+
console.error('[audit-duckdb] Error in gateway_stop:', err);
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
// =====================================================================
|
|
1082
|
+
// Return deactivate function for cleanup
|
|
1083
|
+
// =====================================================================
|
|
1084
|
+
return {
|
|
1085
|
+
deactivate: async () => {
|
|
1086
|
+
console.log('[audit-duckdb] Deactivating plugin...');
|
|
1087
|
+
// Stop timers
|
|
1088
|
+
if (alertFlushTimer) {
|
|
1089
|
+
clearInterval(alertFlushTimer);
|
|
1090
|
+
alertFlushTimer = null;
|
|
1091
|
+
}
|
|
1092
|
+
if (mapCleanupTimer) {
|
|
1093
|
+
clearInterval(mapCleanupTimer);
|
|
1094
|
+
mapCleanupTimer = null;
|
|
1095
|
+
}
|
|
1096
|
+
await flushAlerts();
|
|
1097
|
+
await buffer.shutdown();
|
|
1098
|
+
await writer.close();
|
|
1099
|
+
// Uninstall fetch interceptor
|
|
1100
|
+
uninstallFetchInterceptor();
|
|
1101
|
+
runStartTimes.clear();
|
|
1102
|
+
runInputs.clear();
|
|
1103
|
+
sseUsageCache.clear();
|
|
1104
|
+
latestSseUsage = null;
|
|
1105
|
+
sessionStatsMap.clear();
|
|
1106
|
+
sessionContextMap.clear();
|
|
1107
|
+
securityScanner.reset();
|
|
1108
|
+
lastFallbackSessionId = 'unknown';
|
|
1109
|
+
console.log('[audit-duckdb] Plugin deactivated');
|
|
1110
|
+
},
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
exports.default = activate;
|
|
1114
|
+
//# sourceMappingURL=index.js.map
|