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