UNPKG

@openguardrails/moltguard

Version:

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

614 lines 21.3 kB
/** * AI Security Gateway Manager (Version 2) * * Strategy: * - Modify openclaw.json instead of agents star/agent/models.json * - Because ensureOpenClawModelsJson() overwrites models.json with openclaw.json */ import { existsSync, mkdirSync, writeFileSync, unlinkSync, readdirSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import { startGateway as startGatewayServer, stopGateway as stopGatewayServer, isGatewayServerRunning, addActivityListener } from "../gateway/index.js"; import { loadJsonSync } from "./fs-utils.js"; // ============================================================================= // Constants // ============================================================================= const OPENCLAW_DIR = path.join(os.homedir(), ".openclaw"); const OPENCLAW_CONFIG = path.join(OPENCLAW_DIR, "openclaw.json"); const MOLTGUARD_DATA_DIR = path.join(OPENCLAW_DIR, "extensions/moltguard/data"); const GATEWAY_CONFIG = path.join(MOLTGUARD_DATA_DIR, "gateway.json"); const GATEWAY_BACKUP = path.join(MOLTGUARD_DATA_DIR, "gateway-backup.json"); const DEFAULT_GATEWAY_PORT = 53669; const GATEWAY_SERVER_URL = `http://127.0.0.1:${DEFAULT_GATEWAY_PORT}`; /** * Check if a string looks like an API key placeholder (e.g., "VLLM_API_KEY", "OPENAI_API_KEY") */ function isApiKeyPlaceholder(value) { // Placeholder pattern: UPPERCASE_LETTERS with underscores, ending with _API_KEY or _KEY return /^[A-Z][A-Z0-9_]*(_API_KEY|_KEY)$/.test(value); } /** * Load auth-profiles.json for a specific agent */ function loadAuthProfiles(agentId = "main") { const authProfilesPath = path.join(OPENCLAW_DIR, "agents", agentId, "agent", "auth-profiles.json"); try { if (existsSync(authProfilesPath)) { return loadJsonSync(authProfilesPath); } } catch { // Ignore errors } return null; } /** * Resolve API key from auth-profiles.json * @param providerName - Provider name (e.g., "vllm") * @param placeholder - The placeholder value (e.g., "VLLM_API_KEY") * @returns The actual API key, or the placeholder if not found */ function resolveApiKey(providerName, placeholder) { // If not a placeholder, return as-is if (!isApiKeyPlaceholder(placeholder)) { return placeholder; } // Try to load auth profiles from main agent const authProfiles = loadAuthProfiles("main"); if (!authProfiles?.profiles) { return placeholder; } // Look for matching profile (e.g., "vllm:default") const profileKey = `${providerName}:default`; const profile = authProfiles.profiles[profileKey]; if (profile?.type === "api_key" && profile.key) { return profile.key; } // Also try just the provider name const directProfile = authProfiles.profiles[providerName]; if (directProfile?.type === "api_key" && directProfile.key) { return directProfile.key; } return placeholder; } /** * Convert original baseUrl to gateway URL using backend name as identifier * e.g., providerName="vllm" -> http://127.0.0.1:53669/backend/vllm */ function toGatewayUrl(providerName) { return `${GATEWAY_SERVER_URL}/backend/${providerName}`; } /** * Check if a baseUrl is pointing to the gateway */ function isGatewayUrl(baseUrl) { return baseUrl.startsWith(GATEWAY_SERVER_URL); } // ============================================================================= // Gateway Server Management // ============================================================================= let gatewayRunning = false; let dashboardPort = null; let dashboardToken = null; /** * Set dashboard port for activity reporting */ export function setDashboardPort(port) { dashboardPort = port; } /** * Set dashboard session token for authentication */ export function setDashboardToken(token) { dashboardToken = token; } /** * Load dashboard session token from file */ function loadDashboardToken() { const tokenFile = path.join(OPENCLAW_DIR, "credentials", "moltguard", "dashboard-session-token"); try { if (existsSync(tokenFile)) { const data = loadJsonSync(tokenFile); return data.token || null; } } catch { // Ignore errors } return null; } /** * Report gateway activity to dashboard */ async function reportActivity(event) { if (!dashboardPort) { // Dashboard port not set, skip activity report return; // Dashboard not running, skip reporting } // Try to load token if not set if (!dashboardToken) { dashboardToken = loadDashboardToken(); } if (!dashboardToken) { // Dashboard token not available, skip activity report return; } // Report activity silently try { const response = await fetch(`http://127.0.0.1:${dashboardPort}/api/gateway/activity`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${dashboardToken}`, }, body: JSON.stringify(event), }); if (!response.ok) { console.error("[moltguard] Failed to report gateway activity:", response.status); } } catch { // Silently ignore errors - dashboard may not be running } } let activityListenerRegistered = false; /** Optional callback for business reporter gateway activity */ let gatewayActivityCallback = null; /** Set a callback to receive gateway activity events for business reporting */ export function setGatewayActivityCallback(cb) { gatewayActivityCallback = cb; } /** * Check if a port is in use (TCP level check) */ async function isPortInUse(port) { const net = await import("node:net"); return new Promise((resolve) => { const server = net.createServer(); server.once("error", (err) => { resolve(err.code === "EADDRINUSE"); }); server.once("listening", () => { server.close(); resolve(false); }); server.listen(port, "127.0.0.1"); }); } /** * Wait for a port to become available */ async function waitForPortAvailable(port, timeoutMs = 10000) { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (!(await isPortInUse(port))) return true; await new Promise(r => setTimeout(r, 500)); } return false; } /** * Start the gateway server (in-process, embedded mode) */ export async function startGateway() { mkdirSync(MOLTGUARD_DATA_DIR, { recursive: true }); if (!existsSync(GATEWAY_CONFIG)) { const defaultConfig = { port: DEFAULT_GATEWAY_PORT, backends: {}, }; writeFileSync(GATEWAY_CONFIG, JSON.stringify(defaultConfig, null, 2) + "\n", "utf-8"); } // Register activity listener once if (!activityListenerRegistered) { addActivityListener((event) => { // Report asynchronously to avoid blocking gateway reportActivity(event).catch((err) => { console.error("[moltguard] Failed to report activity:", err); }); // Report to business reporter (only sanitize events with actual redactions) if (event.type === "sanitize" && event.redactionCount > 0 && gatewayActivityCallback) { gatewayActivityCallback(event.redactionCount, event.categories); } }); activityListenerRegistered = true; } // Wait for port to become available (old process may still hold it during plugin update) if (await isPortInUse(DEFAULT_GATEWAY_PORT)) { const available = await waitForPortAvailable(DEFAULT_GATEWAY_PORT, 10000); if (!available) { console.error(`[moltguard] Gateway port ${DEFAULT_GATEWAY_PORT} is still in use after waiting`); gatewayRunning = false; return; } } try { // Start in embedded mode (don't call process.exit on errors) startGatewayServer(GATEWAY_CONFIG, true); gatewayRunning = true; } catch (err) { console.error("[moltguard] Failed to start gateway:", err); gatewayRunning = false; } } /** * Restart the gateway server (reload config) */ export async function restartGateway() { // Restarting gateway await stopGatewayServer(); gatewayRunning = false; startGateway(); } /** * Stop the gateway server completely */ export async function stopGateway() { await stopGatewayServer(); gatewayRunning = false; } export function isGatewayRunning() { // Check both our flag and the actual server state return gatewayRunning && isGatewayServerRunning(); } // ============================================================================= // Configuration Management // ============================================================================= /** * Read openclaw.json */ function readOpenClawConfig() { if (!existsSync(OPENCLAW_CONFIG)) { throw new Error("openclaw.json not found"); } try { return loadJsonSync(OPENCLAW_CONFIG); } catch (err) { throw new Error(`Failed to parse openclaw.json: ${err}`); } } /** * Write openclaw.json */ function writeOpenClawConfig(config) { writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2) + "\n", "utf-8"); } /** * Determine backend type from provider config */ function getBackendType(provider) { if (provider.api === "anthropic") return "anthropic"; if (provider.api === "gemini") return "gemini"; if (provider.api === "openai-completions" || provider.api === "openai") return "openai"; // Infer from baseUrl if (provider.baseUrl?.includes("anthropic.com")) return "anthropic"; if (provider.baseUrl?.includes("gemini") || provider.baseUrl?.includes("google")) return "gemini"; return "openai"; // Default } /** * Extract path from URL */ function extractPathFromUrl(urlString) { try { const url = new URL(urlString); return url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname; } catch { return ""; } } /** * Configure gateway with providers */ function configureGateway(providers) { const backends = {}; for (const [name, provider] of Object.entries(providers)) { if (!provider.baseUrl || !provider.apiKey) continue; // Extract path prefix from original baseUrl for routing const pathPrefix = extractPathFromUrl(provider.baseUrl); // Extract model IDs from provider config const models = provider.models ?.map((m) => m.id) .filter((id) => typeof id === "string"); // Resolve API key placeholder (e.g., "VLLM_API_KEY" -> actual key from auth-profiles.json) const resolvedApiKey = resolveApiKey(name, provider.apiKey); if (resolvedApiKey !== provider.apiKey) { // Resolved API key placeholder } backends[name] = { baseUrl: provider.baseUrl, apiKey: resolvedApiKey, type: getBackendType(provider), ...(pathPrefix && { pathPrefix }), ...(models && models.length > 0 && { models }), }; } const gatewayConfig = { port: DEFAULT_GATEWAY_PORT, backends, }; mkdirSync(MOLTGUARD_DATA_DIR, { recursive: true }); writeFileSync(GATEWAY_CONFIG, JSON.stringify(gatewayConfig, null, 2) + "\n", "utf-8"); } /** * Find all agent models.json files */ function findAgentModelsFiles() { const agentsDir = path.join(OPENCLAW_DIR, "agents"); const modelsFiles = []; if (!existsSync(agentsDir)) { return modelsFiles; } // Find all agent directories const entries = readdirSync(agentsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const modelsPath = path.join(agentsDir, entry.name, "agent", "models.json"); if (existsSync(modelsPath)) { modelsFiles.push(modelsPath); } } } return modelsFiles; } /** * Read and parse a models.json file */ function readModelsJson(filePath) { try { return loadJsonSync(filePath); } catch { return null; } } /** * Write a models.json file */ function writeModelsJson(filePath, data) { writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); } /** * Update provider baseUrls in all agent models.json files * Converts each provider's baseUrl to gateway URL while preserving the path */ function updateAgentModelsFiles(backupData) { const modelsFiles = findAgentModelsFiles(); for (const filePath of modelsFiles) { const data = readModelsJson(filePath); if (!data?.providers) continue; const fileBackup = {}; let modified = false; for (const [name, provider] of Object.entries(data.providers)) { // Skip if already pointing to gateway or no baseUrl if (!provider.baseUrl || isGatewayUrl(provider.baseUrl)) { continue; } fileBackup[name] = provider.baseUrl; provider.baseUrl = toGatewayUrl(name); modified = true; } if (modified) { writeModelsJson(filePath, data); backupData[filePath] = { files: [filePath], originalBaseUrls: fileBackup, }; } } } /** * Restore provider baseUrls in agent models.json files from backup */ function restoreAgentModelsFiles(backupData) { const restored = []; for (const [filePath, backup] of Object.entries(backupData)) { if (!existsSync(filePath)) continue; const data = readModelsJson(filePath); if (!data?.providers) continue; let modified = false; for (const [name, originalUrl] of Object.entries(backup.originalBaseUrls)) { if (data.providers[name]) { data.providers[name].baseUrl = originalUrl; modified = true; } } if (modified) { writeModelsJson(filePath, data); restored.push(filePath); } } return restored; } // ============================================================================= // Public API // ============================================================================= /** * Enable AI Security Gateway * Modifies openclaw.json to route all providers through gateway */ export async function enableGateway() { // Check if already enabled if (existsSync(GATEWAY_BACKUP)) { throw new Error("Gateway is already enabled. Disable it first with '/og_sanitize off'"); } // Read openclaw.json const config = readOpenClawConfig(); if (!config.models?.providers) { throw new Error("No providers found in openclaw.json"); } const providers = config.models.providers; const backup = { timestamp: new Date().toISOString(), routedProviders: {}, }; const routedProviders = []; const originalProviders = {}; const skipped = []; // Process each provider for (const [name, provider] of Object.entries(providers)) { // Skip if already pointing to gateway if (provider.baseUrl && isGatewayUrl(provider.baseUrl)) { skipped.push(name); continue; } // Skip if no baseUrl if (!provider.baseUrl) { continue; } // Save original baseUrl (只保存 baseUrl,不保存整个配置) backup.routedProviders[name] = { originalBaseUrl: provider.baseUrl, }; // Save for gateway config originalProviders[name] = { ...provider }; // Modify to point to gateway using backend name as identifier // e.g., vllm -> http://127.0.0.1:53669/backend/vllm provider.baseUrl = toGatewayUrl(name); routedProviders.push(name); } // If all providers are already pointing to gateway, treat as "already enabled" if (routedProviders.length === 0 && skipped.length > 0) { // Create a minimal backup to mark gateway as enabled mkdirSync(MOLTGUARD_DATA_DIR, { recursive: true }); const minimalBackup = { timestamp: new Date().toISOString(), routedProviders: {}, }; // Mark skipped providers as routed (we don't know original URLs) for (const name of skipped) { minimalBackup.routedProviders[name] = { originalBaseUrl: GATEWAY_SERVER_URL, // Can't restore, but mark as managed }; } writeFileSync(GATEWAY_BACKUP, JSON.stringify(minimalBackup, null, 2) + "\n", "utf-8"); // Restart gateway to ensure it's running await restartGateway(); return { providers: skipped, warnings: ["Providers were already pointing to gateway. Gateway is now marked as enabled."], }; } if (routedProviders.length === 0) { throw new Error("No providers found with baseUrl to route through gateway"); } // Configure gateway with original provider URLs configureGateway(originalProviders); // Write modified openclaw.json writeOpenClawConfig(config); // Also update agent models.json files const agentModelsBackup = {}; updateAgentModelsFiles(agentModelsBackup); if (Object.keys(agentModelsBackup).length > 0) { backup.agentModelsBackup = agentModelsBackup; } // Save backup mkdirSync(MOLTGUARD_DATA_DIR, { recursive: true }); writeFileSync(GATEWAY_BACKUP, JSON.stringify(backup, null, 2) + "\n", "utf-8"); const warnings = []; if (skipped.length > 0) { warnings.push(`Skipped providers already pointing to gateway: ${skipped.join(", ")}`); } const modifiedAgentFiles = Object.keys(agentModelsBackup).length; if (modifiedAgentFiles > 0) { warnings.push(`Also updated ${modifiedAgentFiles} agent models.json file(s)`); } // Restart gateway to pick up new config with backends await restartGateway(); return { providers: routedProviders, warnings }; } /** * Disable AI Security Gateway * Restores original provider URLs in openclaw.json (智能恢复) */ export function disableGateway() { if (!existsSync(GATEWAY_BACKUP)) { throw new Error("Gateway not enabled (no backup found)"); } // Read backup const backup = loadJsonSync(GATEWAY_BACKUP); // Read current openclaw.json const config = readOpenClawConfig(); if (!config.models?.providers) { throw new Error("No providers found in openclaw.json"); } const providers = config.models.providers; const restoredProviders = []; const deletedProviders = []; const modifiedProviders = []; // Smart restore: 只恢复 baseUrl,保留其他字段的修改 for (const [name, routeInfo] of Object.entries(backup.routedProviders)) { const provider = providers[name]; if (!provider) { // Provider 被删除了 deletedProviders.push(name); continue; } if (provider.baseUrl && isGatewayUrl(provider.baseUrl)) { // Provider 还指向 gateway,恢复原始 URL provider.baseUrl = routeInfo.originalBaseUrl; restoredProviders.push(name); } else if (provider.baseUrl !== routeInfo.originalBaseUrl) { // Provider 的 baseUrl 被用户改成了其他值(既不是 gateway 也不是原始值) modifiedProviders.push(name); // 不恢复,保留用户的修改 } else { // Provider 的 baseUrl 已经是原始值了,无需恢复 } } // Write restored config writeOpenClawConfig(config); // Restore agent models.json files let restoredAgentFiles = 0; if (backup.agentModelsBackup) { const restored = restoreAgentModelsFiles(backup.agentModelsBackup); restoredAgentFiles = restored.length; } // Delete backup unlinkSync(GATEWAY_BACKUP); const warnings = []; if (deletedProviders.length > 0) { warnings.push(`These providers were deleted and not restored: ${deletedProviders.join(", ")}`); } if (modifiedProviders.length > 0) { warnings.push(`These providers have custom baseUrl (kept as-is): ${modifiedProviders.join(", ")}`); } if (restoredAgentFiles > 0) { warnings.push(`Also restored ${restoredAgentFiles} agent models.json file(s)`); } return { providers: restoredProviders, warnings }; } /** * Get gateway status */ export function getGatewayStatus() { const enabled = existsSync(GATEWAY_BACKUP); const running = isGatewayRunning(); const status = { enabled, running, port: DEFAULT_GATEWAY_PORT, url: GATEWAY_SERVER_URL, providers: [], }; if (enabled && existsSync(GATEWAY_BACKUP)) { const backup = loadJsonSync(GATEWAY_BACKUP); status.providers = Object.keys(backup.routedProviders); } return status; } //# sourceMappingURL=gateway-manager.js.map