@openguardrails/moltguard
Version:
AI agent security plugin for OpenClaw: prompt injection detection, PII sanitization, and monitoring dashboard
306 lines • 11.1 kB
JavaScript
/**
* 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