@openguardrails/moltguard
Version:
AI agent security plugin for OpenClaw: prompt injection detection, PII sanitization, and monitoring dashboard
980 lines (979 loc) • 92.2 kB
JavaScript
/**
* OpenGuardrails Plugin for OpenClaw
*
* Responsibilities:
* 1. Load credentials from disk on startup (no network)
* 2. Auto-register on first load (autonomous mode, 500/day quota)
* 3. Detect behavioral anomalies at before_tool_call (block / alert)
* 4. Expose /og_status, /og_upgrade, /og_config commands
*/
// SDK compatibility: try new path first, fall back to old path
// New SDK (>=2.0): openclaw/plugin-sdk/plugin-entry
// Old SDK (<2.0): openclaw/plugin-sdk (deprecated but still works)
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
let definePluginEntry = null;
try {
// Dynamic require to avoid build-time errors on old SDK
const mod = require("openclaw/plugin-sdk/plugin-entry");
definePluginEntry = mod.definePluginEntry;
}
catch {
// Old SDK - definePluginEntry not available, will use direct export
}
import { resolveConfig, loadCoreCredentials, deleteCoreCredentials, registerWithCore, readAgentProfile, getProfileWatchPaths, } from "./agent/config.js";
import { BehaviorDetector, FILE_READ_TOOLS, WEB_FETCH_TOOLS } from "./agent/behavior-detector.js";
import { EventReporter } from "./agent/event-reporter.js";
import { BusinessReporter } from "./agent/business-reporter.js";
import { ConfigSync } from "./agent/config-sync.js";
import { DashboardClient } from "./platform-client/index.js";
import { enableGateway, disableGateway, getGatewayStatus, startGateway, stopGateway, setDashboardPort, setGatewayActivityCallback } from "./agent/gateway-manager.js";
import { FileWatcher } from "./agent/file-watcher.js";
import fs from "node:fs";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { openclawHome } from "./agent/env.js";
import { loadJsonSync } from "./agent/fs-utils.js";
// =============================================================================
// Constants
// =============================================================================
const PLUGIN_ID = "moltguard";
const PLUGIN_NAME = "MoltGuard";
const PLUGIN_VERSION = "6.7.0";
const LOG_PREFIX = `[${PLUGIN_ID}]`;
// =============================================================================
// Debug file logger — writes to openclaw logs dir for agentic hours diagnosis
// =============================================================================
const DEBUG_LOG_PATH = path.join(openclawHome, "logs", "moltguard-debug.log");
function debugLog(msg) {
try {
const ts = new Date().toISOString();
fs.appendFileSync(DEBUG_LOG_PATH, `[${ts}] ${msg}\n`);
}
catch { /* ignore */ }
}
// =============================================================================
// API Helpers
// =============================================================================
/** Infer tool category from tool name for business reporting */
function inferToolCategory(toolName) {
const name = toolName.toLowerCase();
if (FILE_READ_TOOLS.has(toolName) || FILE_READ_TOOLS.has(name))
return "file_read";
if (WEB_FETCH_TOOLS.has(toolName) || WEB_FETCH_TOOLS.has(name))
return "web_fetch";
if (["bash", "shell", "run_command", "execute"].some((t) => name.includes(t)))
return "shell";
if (["write", "edit", "create_file", "delete"].some((t) => name.includes(t)))
return "file_write";
if (name.includes("agent") || name.includes("subagent"))
return "agent";
return "other";
}
/** Mask API key for display: sk-og-abc... */
function maskApiKey(apiKey) {
if (apiKey.length <= 12)
return apiKey;
return `${apiKey.slice(0, 12)}...`;
}
/** Get account status from Core API */
async function getAccountStatus(apiKey, coreUrl) {
try {
const res = await fetch(`${coreUrl}/api/v1/account`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
return { email: null, plan: "free", quotaUsed: 0, quotaTotal: 500, isAutonomous: true, resetAt: null };
}
const data = (await res.json());
if (!data.success) {
return { email: null, plan: "free", quotaUsed: 0, quotaTotal: 500, isAutonomous: true, resetAt: null };
}
return {
email: data.email ?? null,
plan: data.plan ?? "free",
quotaUsed: data.quotaUsed ?? 0,
quotaTotal: data.quotaTotal ?? 100,
isAutonomous: data.isAutonomous ?? !data.email,
resetAt: data.resetAt ?? null,
};
}
catch {
return { email: null, plan: "free", quotaUsed: 0, quotaTotal: 500, isAutonomous: true, resetAt: null };
}
}
/** Validate an API key against Core */
async function validateApiKey(apiKey, coreUrl) {
try {
const res = await fetch(`${coreUrl}/api/v1/account`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
if (res.status === 401) {
return { valid: false, error: "Invalid API key" };
}
return { valid: false, error: `API error: ${res.status}` };
}
const data = (await res.json());
if (!data.success) {
return { valid: false, error: "API returned failure" };
}
return {
valid: true,
agentId: data.agentId,
email: data.email,
plan: data.plan ?? "free",
quotaUsed: data.quotaUsed ?? 0,
quotaTotal: data.quotaTotal ?? 100,
};
}
catch (err) {
return { valid: false, error: `Network error: ${err}` };
}
}
// =============================================================================
// Logger
// =============================================================================
function createLogger(baseLogger) {
return {
info: (msg) => baseLogger.info(`${LOG_PREFIX} ${msg}`),
warn: (msg) => baseLogger.warn(`${LOG_PREFIX} ${msg}`),
error: (msg) => baseLogger.error(`${LOG_PREFIX} ${msg}`),
debug: (msg) => baseLogger.debug?.(`${LOG_PREFIX} ${msg}`),
};
}
// =============================================================================
// Database driver check (libsql)
// =============================================================================
// Note: @libsql/client has native bindings with WASM fallback, no manual setup needed.
// =============================================================================
// Plugin state (module-level — survives plugin re-registration within a process)
// =============================================================================
let globalCoreCredentials = null;
let globalBehaviorDetector = null;
let globalEventReporter = null;
let globalBusinessReporter = null;
let globalConfigSync = null;
let globalDashboardClient = null;
let globalFileWatcher = null;
let dashboardHeartbeatTimer = null;
let profileWatchers = [];
let profileDebounceTimer = null;
let lastRegisterResult = null;
// Track quota exceeded notification (only notify once per session)
let quotaExceededNotified = false;
// Track personal dashboard auto-start state
let personalDashboardStarted = false;
// Track LLM input timestamps per session for duration calculation
const llmInputTimestamps = new Map();
// Track auto-scan state
let autoScanEnabled = false;
// Track current account plan
let currentAccountPlan = "free";
// =============================================================================
// Ensure default config in openclaw.json
// =============================================================================
/**
* Previously wrote default config to openclaw.json on first load.
* Now a no-op — we don't modify openclaw.json automatically.
* Config is optional; defaults are applied in resolveConfig().
*/
function ensureDefaultConfig(_log) {
// no-op: don't write config to openclaw.json on fresh install
}
// =============================================================================
// Profile sync — watches workspace files and re-uploads on change
// =============================================================================
function startProfileSync(log) {
if (profileWatchers.length > 0)
return; // already watching
const paths = getProfileWatchPaths();
const scheduleUpload = () => {
if (profileDebounceTimer)
clearTimeout(profileDebounceTimer);
profileDebounceTimer = setTimeout(() => {
if (!globalDashboardClient?.agentId)
return;
const profile = readAgentProfile();
globalDashboardClient
.updateProfile({
...(globalCoreCredentials?.agentId !== "configured"
? { openclawId: globalCoreCredentials?.agentId }
: {}),
...profile,
})
.then(() => log.debug?.("Dashboard: profile synced"))
.catch((err) => log.debug?.(`Dashboard: profile sync failed — ${err}`));
}, 2000);
profileDebounceTimer.unref();
};
for (const watchPath of paths) {
try {
if (!fs.existsSync(watchPath))
continue;
const watcher = fs.watch(watchPath, { recursive: false }, scheduleUpload);
watcher.unref();
profileWatchers.push(watcher);
}
catch {
// Non-critical — fs.watch may not be available in all environments
}
}
if (profileWatchers.length > 0) {
log.debug?.(`Dashboard: watching ${profileWatchers.length} path(s) for profile changes`);
}
}
// =============================================================================
// Plugin Definition
// =============================================================================
const openClawGuardPlugin = {
id: PLUGIN_ID,
name: PLUGIN_NAME,
description: "Security guard for OpenClaw agents",
register(api) {
const log = createLogger(api.logger);
// ── Start AI Security Gateway (in-process) ────────────────────────
// Gateway runs in the plugin process and is always available.
// Users enable sanitization via /og_sanitize on, which routes agents through it.
// Async: waits for port availability (old process may hold it during plugin update).
startGateway()
.then(() => log.debug?.("AI Security Gateway started"))
.catch((err) => log.error(`Failed to start AI Security Gateway: ${err}`));
// Set dashboard port immediately so gateway can report activity
// (Dashboard will start later, but port is fixed at 53667)
const DASHBOARD_PORT = 53667;
setDashboardPort(DASHBOARD_PORT);
log.debug?.(`Gateway activity reporting enabled on port ${DASHBOARD_PORT}`);
// Ensure openclaw.json has default config (coreUrl) on first load
const pluginConfig = (api.pluginConfig ?? {});
debugLog(`=== PLUGIN REGISTER ===`);
debugLog(`pluginConfig: ${JSON.stringify(pluginConfig)}`);
if (!pluginConfig.coreUrl) {
ensureDefaultConfig(log);
}
const config = resolveConfig(pluginConfig);
const isEnterprise = config.plan === "enterprise";
if (config.enabled === false) {
log.info("Plugin disabled via config");
return;
}
debugLog(`resolved config: plan=${config.plan}, coreUrl=${config.coreUrl}, isEnterprise=${isEnterprise}`);
if (isEnterprise) {
log.info(`Enterprise mode: Core → ${config.coreUrl}`);
}
// ── Local initialization (no network) ────────────────────────
if (!globalBehaviorDetector) {
globalBehaviorDetector = new BehaviorDetector({
coreUrl: config.coreUrl,
assessTimeoutMs: Math.min(config.timeoutMs, 3000),
blockOnRisk: config.blockOnRisk,
pluginVersion: PLUGIN_VERSION,
}, log);
}
if (!globalEventReporter) {
globalEventReporter = new EventReporter({
coreUrl: config.coreUrl,
pluginVersion: PLUGIN_VERSION,
timeoutMs: Math.min(config.timeoutMs, 3000),
}, log);
}
if (!globalCoreCredentials) {
if (config.apiKey) {
globalCoreCredentials = {
apiKey: config.apiKey,
agentId: "configured",
claimUrl: "",
verificationCode: "",
};
globalBehaviorDetector.setCredentials(globalCoreCredentials);
globalEventReporter?.setCredentials(globalCoreCredentials);
log.info("Platform: using configured API key");
}
else {
debugLog(`loadCoreCredentials(${config.coreUrl}) called`);
globalCoreCredentials = loadCoreCredentials(config.coreUrl);
debugLog(`loadCoreCredentials result: ${globalCoreCredentials ? `apiKey=${globalCoreCredentials.apiKey?.slice(0, 10)}... agentId=${globalCoreCredentials.agentId} coreUrl=${globalCoreCredentials.coreUrl}` : "null"}`);
if (globalCoreCredentials) {
globalBehaviorDetector.setCredentials(globalCoreCredentials);
globalEventReporter?.setCredentials(globalCoreCredentials);
const mode = globalCoreCredentials.email ? "human managed" : "autonomous";
log.info(`Platform: active (${mode} mode)`);
}
else {
// Auto-register on first load — agent is immediately usable with autonomous quota
log.info("Platform: auto-registering...");
debugLog(`registerWithCore(${config.agentName}, coreUrl=${config.coreUrl})`);
registerWithCore(config.agentName, "OpenClaw AI Agent secured by OpenGuardrails", config.coreUrl)
.then((result) => {
debugLog(`registerWithCore SUCCESS: agentId=${result.credentials.agentId} apiKey=${result.credentials.apiKey?.slice(0, 10)}...`);
lastRegisterResult = result;
globalCoreCredentials = result.credentials;
globalBehaviorDetector.setCredentials(result.credentials);
globalEventReporter?.setCredentials(result.credentials);
// Start personal dashboard (auto-starts local dashboard and connects to it)
initPersonalDashboard(config.coreUrl);
// Check for business plan features
initBusinessFeatures(config.coreUrl);
// Agent is immediately active
log.info(isEnterprise
? "Platform: registered (enterprise mode, unlimited quota)"
: "Platform: registered (autonomous mode, 500/day quota)");
})
.catch((err) => {
debugLog(`registerWithCore FAILED: ${err}`);
log.warn(`Platform: auto-registration failed — ${err}`);
log.info("Platform: local protections still active");
});
}
}
}
// ── Personal Dashboard auto-start ─────────────────────────────────
// Starts the local dashboard automatically when the plugin loads.
// Data is stored in the plugin's data directory.
async function initPersonalDashboard(coreUrl) {
debugLog(`initPersonalDashboard: called, personalDashboardStarted=${personalDashboardStarted}`);
if (personalDashboardStarted) {
debugLog("initPersonalDashboard: already started, skipping");
return;
}
personalDashboardStarted = true;
// Delay startup to avoid starting in short-lived CLI processes (e.g., openclaw plugins install).
// The unref'd timer won't prevent short-lived processes from exiting, so the dashboard
// never starts in CLI context. In the long-lived gateway daemon, the timer fires normally.
await new Promise(resolve => {
const t = setTimeout(resolve, 5000);
t.unref();
});
try {
const { startLocalDashboard, getPluginDataDir, DASHBOARD_PORT, DevModeError } = await import("./dashboard-launcher.js");
const dataDir = getPluginDataDir();
const result = await startLocalDashboard({
apiKey: globalCoreCredentials?.apiKey ?? "",
agentId: globalCoreCredentials?.agentId ?? "",
coreUrl,
dataDir,
autoStart: true,
});
log.info(`OpenGuardrails dashboard started at ${result.localUrl}`);
// Connect to local dashboard for observation reporting
// Use the session token from startLocalDashboard, not the Core API key
initDashboardClient(result.token, `http://localhost:${DASHBOARD_PORT}`);
}
catch (err) {
// Dev mode or startup failure - silently continue
debugLog(`initPersonalDashboard FAILED: ${err}`);
log.debug?.(`Dashboard auto-start skipped: ${err}`);
}
}
// ── Dashboard client initialization ─────────────────────────────
// Connects to the dashboard for observation reporting.
// Uses the local session token for auth.
function initDashboardClient(sessionToken, dashboardUrl) {
debugLog(`initDashboardClient: dashboardUrl=${dashboardUrl} token=${sessionToken?.slice(0, 8)}...`);
if (globalDashboardClient) {
debugLog("initDashboardClient: already initialized, skipping");
return;
}
if (!dashboardUrl || !sessionToken) {
debugLog("initDashboardClient: missing url or token, skipping");
return;
}
globalDashboardClient = new DashboardClient({
dashboardUrl,
sessionToken,
});
// Register agent then upload full profile (non-blocking)
const profile = readAgentProfile();
globalDashboardClient
.registerAgent({
name: config.agentName,
description: "OpenClaw AI Agent secured by OpenGuardrails",
provider: profile.provider || undefined,
metadata: {
...(globalCoreCredentials?.agentId !== "configured" ? { openclawId: globalCoreCredentials?.agentId } : {}),
...profile,
},
})
.then((result) => {
if (result.success && result.data?.id) {
log.debug?.(`Dashboard: agent registered (${result.data.id})`);
startProfileSync(log);
}
})
.catch((err) => {
log.warn(`Dashboard: registration failed — ${err}`);
});
// Start periodic heartbeat
dashboardHeartbeatTimer = globalDashboardClient.startHeartbeat(60_000);
log.debug?.(`Dashboard: connected to ${dashboardUrl}`);
}
// Start personal dashboard unconditionally (like gateway at line 306).
// Dashboard server starts even without credentials — credentials are optional.
// If registerWithCore() is in-flight, dashboard will start with empty credentials;
// the dashboard server itself doesn't need them to listen.
initPersonalDashboard(config.coreUrl);
// ── Business plan initialization ───────────────────────────────
// Check account plan and initialize BusinessReporter + ConfigSync if business.
async function initBusinessFeatures(coreUrl) {
debugLog(`initBusinessFeatures: called, credentials=${!!globalCoreCredentials}, isEnterprise=${isEnterprise}`);
if (!globalCoreCredentials) {
debugLog("initBusinessFeatures: no credentials, skipping");
return;
}
try {
let plan;
if (isEnterprise) {
// Enterprise mode: always business plan, skip remote check
plan = "business";
}
else {
const status = await getAccountStatus(globalCoreCredentials.apiKey, coreUrl);
plan = status.plan;
}
currentAccountPlan = plan;
debugLog(`initBusinessFeatures: plan=${plan}`);
if (plan !== "business") {
debugLog(`initBusinessFeatures: plan is not business, skipping`);
log.debug?.(`Account plan is "${plan}", business features not enabled`);
return;
}
// Initialize BusinessReporter
if (!globalBusinessReporter) {
globalBusinessReporter = new BusinessReporter({ coreUrl, pluginVersion: PLUGIN_VERSION }, log);
globalBusinessReporter.setCredentials(globalCoreCredentials);
// Set profile from workspace
const profile = readAgentProfile();
globalBusinessReporter.setProfile({
ownerName: profile.ownerName,
agentName: config.agentName,
provider: profile.provider,
model: profile.model,
});
globalBusinessReporter.initialize(plan);
debugLog(`BusinessReporter initialized, enabled=${globalBusinessReporter.isEnabled()}`);
// Wire gateway activity to business reporter
if (globalBusinessReporter.isEnabled()) {
setGatewayActivityCallback((redactionCount, typeCounts) => {
globalBusinessReporter?.recordGatewayActivity(redactionCount, typeCounts);
});
// Wire secret detection to business reporter
globalBehaviorDetector?.setOnSecretDetected((typeCounts) => {
globalBusinessReporter?.recordSecretDetection(typeCounts);
});
}
}
// Initialize ConfigSync
if (!globalConfigSync) {
globalConfigSync = new ConfigSync({
coreUrl,
onUpdate: (bizConfig) => {
log.info(`ConfigSync: received ${bizConfig.policies.length} policies`);
// Future: apply gateway config and policies locally
},
}, log);
globalConfigSync.setCredentials(globalCoreCredentials);
await globalConfigSync.initialize(plan);
}
}
catch (err) {
log.debug?.(`Business features init failed: ${err}`);
}
}
if (globalCoreCredentials) {
initBusinessFeatures(config.coreUrl);
}
// ── Hooks ────────────────────────────────────────────────────
// Capture initial user prompt as intent + inject OpenGuardrails context
api.on("before_agent_start", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? "";
const text = typeof event.prompt === "string" ? event.prompt : JSON.stringify(event.prompt ?? "");
// Set up run ID for this session
const runId = `run-${randomBytes(8).toString("hex")}`;
globalEventReporter?.setRunId(sessionKey, runId);
if (globalBehaviorDetector && event.prompt) {
globalBehaviorDetector.setUserIntent(sessionKey, text);
}
// Report to Core (non-blocking)
globalEventReporter?.report(sessionKey, "before_agent_start", {
timestamp: new Date().toISOString(),
prompt: text,
systemPrompt: event.systemPrompt,
conversationId: event.conversationId,
});
});
// Capture ongoing user messages
api.on("message_received", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? "";
const text = typeof event.content === "string"
? event.content
: Array.isArray(event.content)
? event.content.map((c) => c.text ?? "").join(" ")
: String(event.content ?? "");
if (globalBehaviorDetector && event.from === "user") {
globalBehaviorDetector.setUserIntent(sessionKey, text);
}
// Report to Core (non-blocking)
globalEventReporter?.report(sessionKey, "message_received", {
timestamp: new Date().toISOString(),
from: event.from,
content: text.slice(0, 100000), // Truncate very large content
contentLength: text.length,
});
});
// Clear behavioral state when session ends
api.on("session_end", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? event.sessionId ?? "";
// Report to Core (non-blocking)
globalEventReporter?.report(sessionKey, "session_end", {
timestamp: new Date().toISOString(),
sessionId: event.sessionId ?? sessionKey,
durationMs: event.durationMs,
});
// Report session end to business reporter
globalBusinessReporter?.recordSession("end", event.durationMs);
globalBehaviorDetector?.clearSession(sessionKey);
globalEventReporter?.clearSession(sessionKey);
});
// Core detection hook — may block the tool call
api.on("before_tool_call", async (event, ctx) => {
log.debug?.(`before_tool_call: ${event.toolName}`);
let blocked = false;
let blockReason;
if (globalBehaviorDetector) {
const decision = await globalBehaviorDetector.onBeforeToolCall({ sessionKey: ctx.sessionKey ?? "", agentId: ctx.agentId }, { toolName: event.toolName, params: event.params });
if (decision?.block) {
blocked = true;
blockReason = decision.blockReason;
log.warn(`BLOCKED "${event.toolName}": ${decision.blockReason}`);
}
}
// Report to dashboard (non-blocking)
if (globalDashboardClient?.agentId) {
globalDashboardClient
.reportToolCall({
agentId: globalDashboardClient.agentId,
sessionKey: ctx.sessionKey,
toolName: event.toolName,
params: event.params,
phase: "before",
blocked,
blockReason,
})
.catch((err) => {
log.debug?.(`Dashboard: report failed (before ${event.toolName}) — ${err}`);
});
}
if (blocked) {
// Report blocked tool call to business reporter
globalBusinessReporter?.recordToolCall(event.toolName, inferToolCategory(event.toolName), 0, true);
// Record blocked call for local agentic hours
globalDashboardClient?.recordToolCallDuration(0, true);
return { block: true, blockReason };
}
}, { priority: 100 });
// Scan tool results for content injection before they reach the LLM
// Also append quota exceeded messages when applicable
api.on("tool_result_persist", (event, ctx) => {
log.info(`tool_result_persist triggered: toolName=${event.toolName ?? ctx.toolName ?? "unknown"}`);
if (!globalBehaviorDetector) {
log.debug?.("tool_result_persist: no detector");
return;
}
// Resolve tool name from event, context, or the message itself
const message = event.message;
const msgToolName = message && "toolName" in message ? message.toolName : undefined;
const toolName = event.toolName ?? ctx.toolName ?? msgToolName;
log.debug?.(`tool_result_persist: toolName=${toolName ?? "(none)"} [event=${event.toolName}, ctx=${ctx.toolName}, msg=${msgToolName}]`);
// Check message structure first before consuming quota message
if (!message || !("content" in message) || !Array.isArray(message.content)) {
log.debug?.(`tool_result_persist: message.content not an array (role=${message && "role" in message ? message.role : "?"})`);
// Don't consume quota message if we can't append it
return;
}
// Report to Core (non-blocking)
globalEventReporter?.report(ctx.sessionKey ?? "", "tool_result_persist", {
timestamp: new Date().toISOString(),
toolName,
modified: false,
});
// Local injection scanning removed - all detection handled by Core
return undefined;
}, { priority: 100 });
// Record completed tool for chain history + scan content for injection via Core
api.on("after_tool_call", async (event, ctx) => {
log.debug?.(`after_tool_call: ${event.toolName} (${event.durationMs}ms)`);
if (globalBehaviorDetector) {
globalBehaviorDetector.onAfterToolCall({ sessionKey: ctx.sessionKey ?? "" }, {
toolName: event.toolName,
params: event.params,
result: event.result,
error: event.error,
durationMs: event.durationMs,
});
// Scan ALL tool results for injection via Core (not just file read / web fetch)
if (event.result && !event.error) {
const resultText = typeof event.result === "string"
? event.result
: JSON.stringify(event.result);
// Only scan if content is non-trivial (> 20 chars to avoid noise)
if (resultText.length > 20) {
const scanResult = await globalBehaviorDetector.scanContent(ctx.sessionKey ?? "", event.toolName, resultText);
if (scanResult?.detected) {
log.warn(`Core: injection detected in "${event.toolName}" result: ${scanResult.summary}`);
// Report detection to business reporter
globalBusinessReporter?.recordDetection(scanResult.detected ? "high" : "no_risk", false, scanResult.summary);
// Report dynamic scan result to business reporter
globalBusinessReporter?.recordScanResult("dynamic", scanResult.categories ?? [], true);
// Record risk event for local agentic hours
globalDashboardClient?.recordRiskEvent();
}
// Report detection result to dashboard (non-blocking)
if (scanResult && globalDashboardClient) {
// Calculate sensitivity score from findings confidence
// high=0.9, medium=0.7, low=0.5, take max
const confidenceScores = { high: 0.9, medium: 0.7, low: 0.5 };
const sensitivityScore = scanResult.findings.length > 0
? Math.max(...scanResult.findings.map((f) => confidenceScores[f.confidence] ?? 0.5))
: 0;
globalDashboardClient
.reportDetection({
agentId: globalDashboardClient.agentId || "unknown",
sessionKey: ctx.sessionKey,
toolName: event.toolName,
safe: !scanResult.detected,
categories: scanResult.categories,
findings: scanResult.findings.map((f) => ({
scanner: f.scanner,
name: f.name,
matchedText: f.matchedText,
confidence: f.confidence,
})),
sensitivityScore,
latencyMs: scanResult.latency_ms,
})
.catch((err) => {
log.debug?.(`Dashboard: detection report failed — ${err}`);
});
}
}
}
}
// Report to dashboard (non-blocking)
if (globalDashboardClient?.agentId) {
globalDashboardClient
.reportToolCall({
agentId: globalDashboardClient.agentId,
sessionKey: ctx.sessionKey,
toolName: event.toolName,
params: event.params,
phase: "after",
result: event.error ? undefined : "ok",
error: event.error,
durationMs: event.durationMs,
})
.catch((err) => {
log.debug?.(`Dashboard: report failed (after ${event.toolName}) — ${err}`);
});
}
// Report tool call to business reporter (with duration and category)
debugLog(`after_tool_call: tool=${event.toolName} durationMs=${event.durationMs} dashboardClient=${!!globalDashboardClient} businessReporter=${!!globalBusinessReporter} businessEnabled=${globalBusinessReporter?.isEnabled()}`);
globalBusinessReporter?.recordToolCall(event.toolName, inferToolCategory(event.toolName), event.durationMs ?? 0, false);
// Record tool call duration for local agentic hours
globalDashboardClient?.recordToolCallDuration(event.durationMs ?? 0);
});
// ── New Hooks (18 additional hooks for complete context) ────
// Note: Many of these hooks may not be in the OpenClaw SDK types yet.
// We use type assertions to register them, and they'll work at runtime
// when/if OpenClaw supports them.
const apiAny = api;
// Agent lifecycle: agent_end
apiAny.on("agent_end", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "agent_end", {
timestamp: new Date().toISOString(),
reason: event?.reason ?? "unknown",
error: event?.error,
durationMs: event?.durationMs,
});
});
// Session lifecycle: session_start
apiAny.on("session_start", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
const sessionId = event?.sessionId ?? sessionKey;
// Set up run ID if not already set
if (!globalEventReporter?.getRunId(sessionKey)) {
const runId = `run-${randomBytes(8).toString("hex")}`;
globalEventReporter?.setRunId(sessionKey, runId);
}
globalEventReporter?.report(sessionKey, "session_start", {
timestamp: new Date().toISOString(),
sessionId,
isNew: event?.isNew ?? true,
});
// Report session start to business reporter
debugLog(`session_start: sessionKey=${sessionKey} dashboardClient=${!!globalDashboardClient} businessReporter=${!!globalBusinessReporter}`);
globalBusinessReporter?.recordSession("start");
// Record session start for local agentic hours
globalDashboardClient?.recordSessionStart();
});
// Model resolution: before_model_resolve
apiAny.on("before_model_resolve", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "before_model_resolve", {
timestamp: new Date().toISOString(),
requestedModel: event?.model ?? event?.requestedModel ?? "unknown",
});
});
// Prompt building: before_prompt_build
apiAny.on("before_prompt_build", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "before_prompt_build", {
timestamp: new Date().toISOString(),
messageCount: event?.messageCount ?? event?.messages?.length ?? 0,
tokenEstimate: event?.tokenEstimate,
});
});
// LLM input: llm_input (critical for context)
apiAny.on("llm_input", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
// Track timestamp for LLM duration calculation (OpenClaw may not provide latencyMs)
llmInputTimestamps.set(sessionKey, Date.now());
const content = typeof event?.content === "string"
? event.content
: JSON.stringify(event?.messages ?? event?.content ?? "");
globalEventReporter?.report(sessionKey, "llm_input", {
timestamp: new Date().toISOString(),
model: event?.model ?? "unknown",
content: content.slice(0, 100000), // Truncate very large content
contentLength: content.length,
messageCount: event?.messages?.length ?? 1,
tokenCount: event?.tokenCount,
systemPrompt: event?.systemPrompt,
});
});
// LLM output: llm_output (critical for context)
apiAny.on("llm_output", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
const content = typeof event?.content === "string"
? event.content
: JSON.stringify(event?.content ?? "");
// Compute LLM duration: prefer event-provided, fall back to our own timing
const inputTs = llmInputTimestamps.get(sessionKey);
const llmDuration = event?.latencyMs ?? event?.durationMs ?? (inputTs ? Date.now() - inputTs : 0);
if (inputTs)
llmInputTimestamps.delete(sessionKey);
globalEventReporter?.report(sessionKey, "llm_output", {
timestamp: new Date().toISOString(),
model: event?.model ?? "unknown",
content: content.slice(0, 100000),
contentLength: content.length,
streamed: event?.streamed ?? false,
tokenUsage: event?.usage ?? event?.tokenUsage,
latencyMs: llmDuration,
stopReason: event?.stopReason ?? event?.stop_reason,
});
// Report LLM call to business reporter
debugLog(`llm_output: model=${event?.model} latencyMs=${event?.latencyMs} durationMs=${event?.durationMs} computed=${llmDuration} dashboardClient=${!!globalDashboardClient} businessReporter=${!!globalBusinessReporter}`);
if (llmDuration > 0) {
globalBusinessReporter?.recordLlmCall(llmDuration, event?.model);
// Record LLM duration for local agentic hours
globalDashboardClient?.recordLlmDuration(llmDuration);
}
});
// Message sending: message_sending (blocking - can modify/cancel)
// Note: This hook IS in the SDK, but we need special handling for the return type
api.on("message_sending", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? "";
const content = typeof event.content === "string"
? event.content
: JSON.stringify(event.content ?? "");
// Report to Core (non-blocking for now - blocking would require SDK support)
globalEventReporter?.report(sessionKey, "message_sending", {
timestamp: new Date().toISOString(),
to: event.to ?? "user",
content: content.slice(0, 100000),
contentLength: content.length,
}, false);
});
// Message sent: message_sent
api.on("message_sent", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "message_sent", {
timestamp: new Date().toISOString(),
to: event.to ?? "user",
success: true,
durationMs: event.durationMs,
});
});
// Before message write: before_message_write (blocking)
apiAny.on("before_message_write", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
const content = typeof event?.content === "string"
? event.content
: JSON.stringify(event?.message ?? event?.content ?? "");
const decision = await globalEventReporter?.report(sessionKey, "before_message_write", {
timestamp: new Date().toISOString(),
filePath: event?.filePath ?? event?.path ?? "unknown",
content: content.slice(0, 100000),
contentLength: content.length,
}, true);
if (decision?.block) {
return { block: true, blockReason: decision.reason };
}
});
// Compaction: before_compaction
api.on("before_compaction", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "before_compaction", {
timestamp: new Date().toISOString(),
messageCount: event.messageCount ?? 0,
tokenEstimate: event.tokenEstimate,
reason: event.reason ?? "auto",
});
});
// Compaction: after_compaction
api.on("after_compaction", async (event, ctx) => {
const sessionKey = ctx.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "after_compaction", {
timestamp: new Date().toISOString(),
messageCount: event.messageCount ?? 0,
removedCount: event.removedCount ?? 0,
tokenEstimate: event.tokenEstimate,
});
});
// Reset: before_reset
apiAny.on("before_reset", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "before_reset", {
timestamp: new Date().toISOString(),
reason: event?.reason ?? "unknown",
messageCount: event?.messageCount ?? 0,
});
});
// Subagent: subagent_spawning (blocking - critical for security)
apiAny.on("subagent_spawning", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
const task = typeof event?.task === "string"
? event.task
: typeof event?.prompt === "string"
? event.prompt
: JSON.stringify(event?.task ?? event?.prompt ?? "");
const decision = await globalEventReporter?.report(sessionKey, "subagent_spawning", {
timestamp: new Date().toISOString(),
subagentId: event?.subagentId ?? event?.id ?? "unknown",
subagentType: event?.subagentType ?? event?.type ?? "unknown",
task: task.slice(0, 100000),
taskLength: task.length,
parentContext: event?.parentContext,
}, true);
if (decision?.block) {
log.warn(`BLOCKED subagent spawn: ${decision.reason}`);
return { block: true, blockReason: decision.reason };
}
});
// Subagent: subagent_delivery_target
apiAny.on("subagent_delivery_target", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "subagent_delivery_target", {
timestamp: new Date().toISOString(),
subagentId: event?.subagentId ?? event?.id ?? "unknown",
targetType: event?.targetType ?? event?.type ?? "unknown",
targetDetails: event?.targetDetails ?? event?.details,
});
});
// Subagent: subagent_spawned
apiAny.on("subagent_spawned", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "subagent_spawned", {
timestamp: new Date().toISOString(),
subagentId: event?.subagentId ?? event?.id ?? "unknown",
subagentType: event?.subagentType ?? event?.type ?? "unknown",
success: event?.success ?? true,
error: event?.error,
});
});
// Subagent: subagent_ended
apiAny.on("subagent_ended", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "subagent_ended", {
timestamp: new Date().toISOString(),
subagentId: event?.subagentId ?? event?.id ?? "unknown",
reason: event?.reason ?? "unknown",
resultSummary: event?.resultSummary ?? event?.result,
error: event?.error,
durationMs: event?.durationMs,
});
});
// Gateway: gateway_start
apiAny.on("gateway_start", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "gateway_start", {
timestamp: new Date().toISOString(),
port: event?.port ?? 0,
url: event?.url ?? "",
});
});
// Gateway: gateway_stop
apiAny.on("gateway_stop", async (event, ctx) => {
const sessionKey = ctx?.sessionKey ?? "";
globalEventReporter?.report(sessionKey, "gateway_stop", {
timestamp: new Date().toISOString(),
reason: event?.reason ?? "unknown",
error: event?.error,
});
});
// ── Commands ─────────────────────────────────────────────────
api.registerCommand({
name: "og_status",
description: "Show MoltGuard status, API key, and quota",
requireAuth: true,
handler: async () => {
const creds = globalCoreCredentials;
if (!creds) {
return {
text: [
"**MoltGuard Status**",
"",
"- Status: Not registered (will auto-register on first use)",
"- Local protection: Active",
].join("\n"),
};
}
// Get live quota status from Core (skip in enterprise mode)
const status = isEnterprise
? { email: "", plan: "enterprise", quotaUsed: 0, quotaTotal: 999_999_999, isAutonomous: false, resetAt: "" }
: await getAccountStatus(creds.apiKey, config.coreUrl);
const mode = status.isAutonomous ? "autonomous" : "human managed";
const quotaDisplay = `${status.quotaUsed}/${status.quotaTotal}/day`;
const lines = [
"**MoltGuard Status**",
"",
`- API Key: ${maskApiKey(creds.apiKey)}`,
`- Agent ID: ${creds.agentId}`,
`- Email: ${status.email || "(not set)"}`,
`- Plan: ${isEnterprise ? "enterprise" : status.plan}`,
`- Quota: ${isEnterprise ? "unlimited" : quotaDisplay}${!isEnterprise && status.resetAt ? " (resets at UTC 0:00)" : ""}`,
`- Mode: ${isEnterprise ? "enterprise" : mode}`,
...(isEnterprise ? [`- Core: ${config.coreUrl}`] : []),
`- blockOnRisk: ${config.blockOnRisk}`,
"",
"Commands:",
...(isEnterprise ? [] : [
"- /og_core — Open Core portal to upgrade plan",
"- /og_claim — Show agent info for claiming",
]),
"- /og_config — Configure API key",
];
return { text: lines.join("\n") };
},
});
api.registerCommand({
name: "og_config",
description: "Show how to configure API key for cross-machine sharing",
requireAuth: true,
handler: async () => {
// Show configuration instructions
// Note: OpenClaw commands don't support arguments directly.
// Users configure API key via openclaw.json or environment variable.
return {
text: [