UNPKG

@openguardrails/moltguard

Version:

AI agent security plugin for OpenClaw: prompt injection detection, PII sanitization, and monitoring dashboard

306 lines 11.1 kB
/** * EventReporter - Handles batched event reporting to Core. * * Responsibilities: * 1. Queue non-blocking events and flush them in batches (100ms window) * 2. Send blocking events synchronously and return block decisions * 3. Handle network failures gracefully (fail-open) * 4. Truncate large content to avoid timeouts */ import { sanitizeContent } from "./sanitizer.js"; // ============================================================================= // Constants // ============================================================================= /** Maximum content length before truncation (100KB) */ const MAX_CONTENT_LENGTH = 100 * 1024; /** Batch flush interval in ms */ const BATCH_FLUSH_INTERVAL_MS = 100; /** Maximum events per batch */ const MAX_BATCH_SIZE = 50; /** Timeout for Core API calls */ const API_TIMEOUT_MS = 3000; // ============================================================================= // EventReporter Class // ============================================================================= export class EventReporter { config; log; credentials = null; /** Sequence counter per session */ sessionSeq = new Map(); /** Run ID per session */ sessionRunId = new Map(); /** Event queue for batching */ queue = []; /** Flush timer */ flushTimer = null; /** Whether we're currently flushing */ flushing = false; constructor(config, log) { this.config = { coreUrl: config.coreUrl, pluginVersion: config.pluginVersion, timeoutMs: config.timeoutMs ?? API_TIMEOUT_MS, enableBatching: config.enableBatching ?? true, }; this.log = log; } /** Set Core credentials for authenticated API calls */ setCredentials(credentials) { this.credentials = credentials; } /** Set or get run ID for a session */ setRunId(sessionKey, runId) { this.sessionRunId.set(sessionKey, runId); } getRunId(sessionKey) { return this.sessionRunId.get(sessionKey); } /** Clear session state */ clearSession(sessionKey) { this.sessionSeq.delete(sessionKey); this.sessionRunId.delete(sessionKey); } /** * Report an event. For blocking hooks, this is synchronous and may return * a block decision. For non-blocking hooks, this queues the event for batching. */ async report(sessionKey, hookType, data, blocking = false) { if (!this.credentials) { this.log.debug?.(`EventReporter: no credentials, skipping ${hookType}`); return undefined; } // Get next sequence number for this session const seq = this.getNextSeq(sessionKey); // Build the event const event = { seq, hookType, data: this.sanitizeEventData(data), }; // Blocking hooks: send immediately and wait for response if (blocking) { return this.reportSync(sessionKey, event); } // Non-blocking hooks: queue for batching this.queueEvent(sessionKey, event); return undefined; } /** * Send a single event synchronously (for blocking hooks). * Returns a block decision if Core says to block, undefined otherwise. */ async reportSync(sessionKey, event) { const runId = this.sessionRunId.get(sessionKey) ?? "unknown"; const request = { agentId: this.credentials.agentId, sessionKey, runId, events: [event], meta: { pluginVersion: this.config.pluginVersion, clientTimestamp: new Date().toISOString(), }, }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.config.timeoutMs); try { const response = await fetch(`${this.config.coreUrl}/api/v1/events/stream`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.credentials.apiKey}`, }, body: JSON.stringify(request), signal: controller.signal, }); if (!response.ok) { this.log.debug?.(`EventReporter: sync request failed with ${response.status}`); return undefined; // Fail-open } const json = (await response.json()); if (!json.success || !json.data) { return undefined; } // Check for block decision for this event const blockDecision = json.data.blocks?.find((b) => b.seq === event.seq); if (blockDecision) { return { block: true, reason: blockDecision.reason, findings: blockDecision.findings, }; } return undefined; } catch (err) { if (err.name !== "AbortError") { this.log.debug?.(`EventReporter: sync request error: ${err}`); } return undefined; // Fail-open } finally { clearTimeout(timer); } } /** * Queue an event for batched sending. */ queueEvent(sessionKey, event) { this.queue.push({ sessionKey, event }); // Start flush timer if not already running if (!this.flushTimer && this.config.enableBatching) { this.flushTimer = setTimeout(() => { this.flush().catch((err) => { this.log.debug?.(`EventReporter: flush error: ${err}`); }); }, BATCH_FLUSH_INTERVAL_MS); this.flushTimer.unref(); } // Flush immediately if queue is full if (this.queue.length >= MAX_BATCH_SIZE) { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } this.flush().catch((err) => { this.log.debug?.(`EventReporter: flush error: ${err}`); }); } } /** * Flush all queued events to Core. */ async flush() { if (this.flushing || this.queue.length === 0 || !this.credentials) { return; } this.flushing = true; this.flushTimer = null; // Take all queued events const items = this.queue.splice(0, MAX_BATCH_SIZE); // Group by session for efficient sending const bySession = new Map(); for (const item of items) { const events = bySession.get(item.sessionKey) ?? []; events.push(item.event); bySession.set(item.sessionKey, events); } // Send each session's events const promises = []; for (const [sessionKey, events] of bySession) { promises.push(this.sendBatch(sessionKey, events)); } try { await Promise.all(promises); } finally { this.flushing = false; // Re-queue remaining items and restart timer if needed if (this.queue.length > 0 && this.config.enableBatching) { this.flushTimer = setTimeout(() => { this.flush().catch((err) => { this.log.debug?.(`EventReporter: flush error: ${err}`); }); }, BATCH_FLUSH_INTERVAL_MS); this.flushTimer.unref(); } } } /** * Send a batch of events for a single session. */ async sendBatch(sessionKey, events) { if (!this.credentials || events.length === 0) return; const runId = this.sessionRunId.get(sessionKey) ?? "unknown"; const request = { agentId: this.credentials.agentId, sessionKey, runId, events, meta: { pluginVersion: this.config.pluginVersion, clientTimestamp: new Date().toISOString(), }, }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.config.timeoutMs); try { const response = await fetch(`${this.config.coreUrl}/api/v1/events/stream`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.credentials.apiKey}`, }, body: JSON.stringify(request), signal: controller.signal, }); if (!response.ok) { this.log.debug?.(`EventReporter: batch request failed with ${response.status}`); return; } const json = (await response.json()); if (json.success && json.data) { this.log.debug?.(`EventReporter: batch sent ${json.data.processed} events`); } } catch (err) { if (err.name !== "AbortError") { this.log.debug?.(`EventReporter: batch request error: ${err}`); } } finally { clearTimeout(timer); } } /** * Get next sequence number for a session. */ getNextSeq(sessionKey) { const current = this.sessionSeq.get(sessionKey) ?? 0; this.sessionSeq.set(sessionKey, current + 1); return current; } /** * Sanitize event data: truncate large content, remove secrets. */ sanitizeEventData(data) { const result = { ...data }; // Truncate content fields if they exist and are too large const contentFields = ["content", "prompt", "task", "resultSummary", "systemPrompt"]; for (const field of contentFields) { if (field in result) { const value = result[field]; if (typeof value === "string" && value.length > MAX_CONTENT_LENGTH) { result[field] = value.slice(0, MAX_CONTENT_LENGTH); } } } // Sanitize content to remove secrets if ("content" in result && typeof result.content === "string") { const sanitized = sanitizeContent(result.content); result.content = sanitized.sanitized; } if ("prompt" in result && typeof result.prompt === "string") { const sanitized = sanitizeContent(result.prompt); result.prompt = sanitized.sanitized; } if ("task" in result && typeof result.task === "string") { const sanitized = sanitizeContent(result.task); result.task = sanitized.sanitized; } return result; } /** * Stop the reporter and flush remaining events. */ async stop() { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } await this.flush(); } } //# sourceMappingURL=event-reporter.js.map