UNPKG

@openguardrails/moltguard

Version:

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

353 lines 13.7 kB
/** * BusinessReporter - Reports telemetry data to Core's Business Dashboard. * * Only active when the agent's account is on the "business" plan. * Accumulates events and agentic hours locally, then flushes to Core * every 60 seconds via POST /api/v1/business/telemetry. */ import os from "node:os"; import fs from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { networkInterfaces } from "node:os"; import { openclawHome } from "./env.js"; function debugLog(msg) { try { const logPath = path.join(openclawHome, "logs", "moltguard-debug.log"); fs.appendFileSync(logPath, `[${new Date().toISOString()}] [BusinessReporter] ${msg}\n`); } catch { /* ignore */ } } // ============================================================================= // Constants // ============================================================================= /** Flush interval in ms (60 seconds) */ const FLUSH_INTERVAL_MS = 60_000; /** Maximum events to buffer before forced flush */ const MAX_BUFFERED_EVENTS = 500; /** Timeout for Core API calls */ const API_TIMEOUT_MS = 5_000; // ============================================================================= // BusinessReporter Class // ============================================================================= export class BusinessReporter { enabled = false; config; log; credentials = null; ownerName = ""; machineName; machineId; agentName = ""; provider = ""; model = ""; /** Buffered events waiting to be flushed */ pendingEvents = []; /** Accumulated agentic hours since last flush */ hoursAccum = this.emptyAccum(); /** Accumulated scan summaries since last flush */ scanAccum = []; /** Accumulated gateway activity since last flush */ gatewayAccum = this.emptyGatewayAccum(); /** Accumulated secret detections since last flush */ secretAccum = this.emptySecretAccum(); /** Periodic flush timer */ flushInterval = null; /** Whether we're currently flushing */ flushing = false; constructor(config, log) { this.config = config; this.log = log; this.machineName = os.hostname(); this.machineId = generateMachineId(); } // ─── Lifecycle ─────────────────────────────────────────────────── /** * Initialize the reporter. Only enables if the account plan is "business". * Call this after fetching the account info from Core. */ initialize(plan) { if (plan !== "business") { this.log.debug?.(`BusinessReporter: plan is "${plan}", not enabling`); return; } this.enabled = true; this.startPeriodicFlush(); this.log.info(`BusinessReporter: enabled for business plan (machine: ${this.machineName})`); } /** Set Core credentials */ setCredentials(credentials) { this.credentials = credentials; } /** Update agent profile info (called when profile changes) */ setProfile(profile) { if (profile.ownerName !== undefined) this.ownerName = profile.ownerName; if (profile.agentName !== undefined) this.agentName = profile.agentName; if (profile.provider !== undefined) this.provider = profile.provider; if (profile.model !== undefined) this.model = profile.model; } /** Whether the reporter is active */ isEnabled() { return this.enabled; } /** Stop the reporter and flush remaining data */ async stop() { if (this.flushInterval) { clearInterval(this.flushInterval); this.flushInterval = null; } if (this.enabled) { await this.flush(); } this.enabled = false; } // ─── Event Recording ───────────────────────────────────────────── /** Record a tool call */ recordToolCall(toolName, category, durationMs, blocked) { if (!this.enabled) return; this.pendingEvents.push({ type: blocked ? "block" : "tool_call", toolName, category, durationMs, blocked, }); this.hoursAccum.toolCallDurationMs += durationMs; this.hoursAccum.toolCallCount += 1; this.hoursAccum.totalDurationMs += durationMs; if (blocked) this.hoursAccum.blockCount += 1; this.maybeFlush(); } /** Record an LLM call */ recordLlmCall(durationMs, model) { if (!this.enabled) return; if (model) this.model = model; this.hoursAccum.llmDurationMs += durationMs; this.hoursAccum.llmCallCount += 1; this.hoursAccum.totalDurationMs += durationMs; } /** Record a detection event */ recordDetection(riskLevel, blocked, summary) { if (!this.enabled) return; this.pendingEvents.push({ type: "detection", riskLevel, blocked, summary, }); if (riskLevel !== "no_risk" && riskLevel !== "low") { this.hoursAccum.riskEventCount += 1; } if (blocked) this.hoursAccum.blockCount += 1; this.maybeFlush(); } /** Record a session start/end */ recordSession(type, durationMs) { if (!this.enabled) return; this.pendingEvents.push({ type: type === "start" ? "session_start" : "session_end", durationMs, }); if (type === "start") { this.hoursAccum.sessionCount += 1; } if (durationMs) { this.hoursAccum.totalDurationMs += durationMs; } } /** Record a scan result (static or dynamic) */ recordScanResult(scanType, categories, risky) { if (!this.enabled) return; // Find or create accum for this scan type let accum = this.scanAccum.find((s) => s.scanType === scanType); if (!accum) { accum = { scanType, totalScans: 0, riskyScans: 0, categoryCounts: {} }; this.scanAccum.push(accum); } accum.totalScans += 1; if (risky) accum.riskyScans += 1; for (const cat of categories) { accum.categoryCounts[cat] = (accum.categoryCounts[cat] ?? 0) + 1; } } /** Record gateway sanitization activity */ recordGatewayActivity(redactionCount, typeCounts) { if (!this.enabled) return; this.gatewayAccum.totalRequests += 1; this.gatewayAccum.totalRedactions += redactionCount; for (const [k, v] of Object.entries(typeCounts)) { this.gatewayAccum.typeCounts[k] = (this.gatewayAccum.typeCounts[k] ?? 0) + v; } } /** Record secret detection */ recordSecretDetection(typeCounts) { if (!this.enabled) return; const total = Object.values(typeCounts).reduce((a, b) => a + b, 0); this.secretAccum.totalDetections += total; for (const [k, v] of Object.entries(typeCounts)) { this.secretAccum.typeCounts[k] = (this.secretAccum.typeCounts[k] ?? 0) + v; } } // ─── Flush ───────────────────────────────────────────────────── startPeriodicFlush() { if (this.flushInterval) return; this.flushInterval = setInterval(() => { this.flush().catch((err) => { this.log.debug?.(`BusinessReporter: flush error: ${err}`); }); }, FLUSH_INTERVAL_MS); // Don't prevent process exit this.flushInterval.unref(); } maybeFlush() { if (this.pendingEvents.length >= MAX_BUFFERED_EVENTS) { this.flush().catch((err) => { this.log.debug?.(`BusinessReporter: forced flush error: ${err}`); }); } } async flush() { if (this.flushing || !this.enabled || !this.credentials) return; // Check if there's anything to flush const hasEvents = this.pendingEvents.length > 0; const hasHours = this.hoursAccum.totalDurationMs > 0 || this.hoursAccum.toolCallCount > 0 || this.hoursAccum.llmCallCount > 0 || this.hoursAccum.sessionCount > 0; const hasScans = this.scanAccum.length > 0; const hasGateway = this.gatewayAccum.totalRequests > 0; const hasSecrets = this.secretAccum.totalDetections > 0; if (!hasEvents && !hasHours && !hasScans && !hasGateway && !hasSecrets) return; this.flushing = true; // Take current state const events = this.pendingEvents.splice(0); const hours = { ...this.hoursAccum }; this.hoursAccum = this.emptyAccum(); const scans = this.scanAccum.splice(0); const gateway = { ...this.gatewayAccum }; this.gatewayAccum = this.emptyGatewayAccum(); const secrets = { ...this.secretAccum }; this.secretAccum = this.emptySecretAccum(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { const body = { agentId: this.credentials.agentId, ownerName: this.ownerName || undefined, machineName: this.machineName, machineId: this.machineId, agentName: this.agentName || undefined, provider: this.provider || undefined, model: this.model || undefined, events: events.length > 0 ? events : undefined, agenticHours: hasHours ? hours : undefined, heartbeat: true, scanSummary: scans.length > 0 ? scans : undefined, gatewaySummary: hasGateway ? gateway : undefined, secretSummary: hasSecrets ? secrets : undefined, }; debugLog(`flush: POSTing to ${this.config.coreUrl}/api/v1/business/telemetry events=${events.length} hours=${JSON.stringify(hours)}`); const response = await fetch(`${this.config.coreUrl}/api/v1/business/telemetry`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.credentials.apiKey}`, }, body: JSON.stringify(body), signal: controller.signal, }); if (!response.ok) { debugLog(`flush: POST failed with ${response.status}`); this.log.debug?.(`BusinessReporter: telemetry request failed with ${response.status}`); // Put events back on failure (hours are lost to avoid double-counting) this.pendingEvents.unshift(...events); if (this.pendingEvents.length > MAX_BUFFERED_EVENTS) { this.pendingEvents.length = MAX_BUFFERED_EVENTS; // Trim oldest } } else { debugLog(`flush: POST success`); this.log.debug?.(`BusinessReporter: flushed ${events.length} events`); } } catch (err) { debugLog(`flush: POST error: ${err}`); if (err.name !== "AbortError") { this.log.debug?.(`BusinessReporter: telemetry error: ${err}`); } // Put events back on failure this.pendingEvents.unshift(...events); if (this.pendingEvents.length > MAX_BUFFERED_EVENTS) { this.pendingEvents.length = MAX_BUFFERED_EVENTS; } } finally { clearTimeout(timer); this.flushing = false; } } // ─── Helpers ─────────────────────────────────────────────────── emptyAccum() { return { toolCallDurationMs: 0, llmDurationMs: 0, totalDurationMs: 0, toolCallCount: 0, llmCallCount: 0, sessionCount: 0, blockCount: 0, riskEventCount: 0, }; } emptyGatewayAccum() { return { totalRequests: 0, totalRedactions: 0, typeCounts: {} }; } emptySecretAccum() { return { totalDetections: 0, typeCounts: {} }; } } // ============================================================================= // Machine ID Generation // ============================================================================= /** * Generate a stable machine ID from hostname + first MAC address. * This identifies a specific machine across restarts. */ function generateMachineId() { const hostname = os.hostname(); const interfaces = networkInterfaces(); let mac = ""; for (const iface of Object.values(interfaces)) { if (!iface) continue; for (const info of iface) { if (!info.internal && info.mac && info.mac !== "00:00:00:00:00:00") { mac = info.mac; break; } } if (mac) break; } const input = `${hostname}:${mac || "unknown"}`; return createHash("sha256").update(input).digest("hex").slice(0, 16); } //# sourceMappingURL=business-reporter.js.map