UNPKG

@openguardrails/moltguard

Version:

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

323 lines 13.7 kB
/** * OpenGuardrails plugin configuration and credential management */ import os from "node:os"; import path from "node:path"; import { existsSync, mkdirSync, writeFileSync, unlinkSync, readdirSync } from "node:fs"; import { defaultCoreUrl, envApiKey } from "./env.js"; import { loadTextSync, loadTextSafe, loadJsonSafe } from "./fs-utils.js"; // ============================================================================= // Constants // ============================================================================= export const DEFAULT_CORE_URL = defaultCoreUrl; const CREDENTIALS_DIR = path.join(os.homedir(), ".openclaw/credentials/moltguard"); const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); /** * Load credentials from disk. * If the credentials were issued by a different Core URL, returns null * (credentials from production won't work in dev and vice versa). * * @param configuredCoreUrl - The Core URL from plugin config (openclaw.json). * When provided, credentials are validated against this URL instead of DEFAULT_CORE_URL. */ export function loadCoreCredentials(configuredCoreUrl) { try { if (!existsSync(CREDENTIALS_FILE)) return null; const data = JSON.parse(loadTextSync(CREDENTIALS_FILE)); if (typeof data.apiKey === "string" && typeof data.agentId === "string") { const creds = data; const expectedUrl = configuredCoreUrl ?? DEFAULT_CORE_URL; // Check if credentials match current environment if (creds.coreUrl && creds.coreUrl !== expectedUrl) { // Credentials from a different Core instance - don't use them // Credentials from a different Core instance - skip return null; } return creds; } return null; } catch { return null; } } export function saveCoreCredentials(creds, coreUrl) { if (!existsSync(CREDENTIALS_DIR)) { mkdirSync(CREDENTIALS_DIR, { recursive: true }); } // Save the Core URL with credentials so we know which instance issued them const toSave = { ...creds, coreUrl: coreUrl ?? DEFAULT_CORE_URL }; writeFileSync(CREDENTIALS_FILE, JSON.stringify(toSave, null, 2), "utf-8"); } export function deleteCoreCredentials() { try { if (existsSync(CREDENTIALS_FILE)) { unlinkSync(CREDENTIALS_FILE); return true; } return false; } catch { return false; } } /** @deprecated Use loadCoreCredentials().apiKey instead */ export function loadApiKey() { return loadCoreCredentials()?.apiKey ?? null; } export async function registerWithCore(name, description, coreUrl = DEFAULT_CORE_URL) { const url = coreUrl.replace(/\/+$/, ""); const response = await fetch(`${url}/api/v1/agents/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description }), }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Registration failed: ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}`); } const json = (await response.json()); if (!json.success || !json.agent) { throw new Error(`Registration error: ${json.error ?? "unknown"}`); } const creds = { apiKey: json.agent.api_key, agentId: json.agent.id, claimUrl: json.activate_url ?? "", verificationCode: "", // No longer used coreUrl: url, }; saveCoreCredentials(creds, url); return { credentials: creds, activateUrl: json.activate_url ?? "", loginUrl: json.login_url ?? `${url}/login`, }; } // ============================================================================= // Account Email Polling // ============================================================================= /** * Polls Core `/api/v1/account` to learn the agent's verified email. * Returns `{ email, status }` if the agent is active, null otherwise. */ export async function pollAccountEmail(apiKey, coreUrl = DEFAULT_CORE_URL) { try { const url = coreUrl.replace(/\/+$/, ""); const res = await fetch(`${url}/api/v1/account`, { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!res.ok) return null; const data = (await res.json()); if (data.success && data.email && data.status === "active") { return { email: data.email, status: data.status }; } return null; } catch { return null; } } export const DEFAULT_CONFIG = { enabled: true, blockOnRisk: true, apiKey: envApiKey, timeoutMs: 60000, coreUrl: DEFAULT_CORE_URL, agentName: "OpenClaw Agent", }; // ============================================================================= // Configuration Helpers // ============================================================================= function parseIdentityField(content, field) { const prefix = `- **${field}:**`; const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (trimmed.startsWith(prefix)) { const inline = trimmed.slice(prefix.length).trim(); if (inline) return inline; // value on next line const next = lines[i + 1]?.trim(); if (next && !next.startsWith("-") && !next.startsWith("#")) return next; } } return ""; } /** * Reads the agent's name from ~/.openclaw/workspace/IDENTITY.md. */ function readIdentityName() { try { const identityPath = path.join(os.homedir(), ".openclaw/workspace/IDENTITY.md"); const content = loadTextSync(identityPath); const name = parseIdentityField(content, "Name"); return name || null; } catch { return null; } } /** * Reads the full OpenClaw workspace profile from ~/.openclaw/ to report to the dashboard. * All fields degrade gracefully — missing files produce empty strings/arrays. */ export function readAgentProfile() { const openclawDir = path.join(os.homedir(), ".openclaw"); const result = { emoji: "", creature: "", vibe: "", model: "", provider: "", ownerName: "", skills: [], plugins: [], hooks: [], connectedSystems: [], channels: [], sessionCount: 0, lastActive: null, workspaceFiles: { soul: "", identity: "", user: "", agents: "", tools: "", heartbeat: "" }, bootstrapExists: false, cronJobs: [], }; // ── openclaw.json ────────────────────────────────────────── const config = loadJsonSafe(path.join(openclawDir, "openclaw.json")); let workspacePath = path.join(openclawDir, "workspace"); if (config) { const agentsConfig = config.agents; const defaultModel = agentsConfig?.defaults?.model?.primary ?? ""; if (defaultModel.includes("/")) { const [provider, model] = defaultModel.split("/", 2); result.provider = provider ?? ""; result.model = model ?? ""; } else { result.model = defaultModel; } if (agentsConfig?.defaults?.workspace) { workspacePath = agentsConfig.defaults.workspace; } const pluginsConfig = config.plugins; if (pluginsConfig?.entries) { for (const [name, entry] of Object.entries(pluginsConfig.entries)) { result.plugins.push({ name, enabled: entry?.enabled !== false }); } } const hooksConfig = config.hooks; if (hooksConfig?.internal?.entries) { for (const [name, entry] of Object.entries(hooksConfig.internal.entries)) { result.hooks.push({ name, enabled: entry?.enabled !== false }); } } } // ── Workspace files ───────────────────────────────────────── const identityContent = loadTextSafe(path.join(workspacePath, "IDENTITY.md")); result.workspaceFiles.identity = identityContent; result.workspaceFiles.soul = loadTextSafe(path.join(workspacePath, "SOUL.md")); result.workspaceFiles.user = loadTextSafe(path.join(workspacePath, "USER.md")); result.workspaceFiles.agents = loadTextSafe(path.join(workspacePath, "AGENTS.md")); result.workspaceFiles.tools = loadTextSafe(path.join(workspacePath, "TOOLS.md")); result.workspaceFiles.heartbeat = loadTextSafe(path.join(workspacePath, "HEARTBEAT.md")); result.bootstrapExists = existsSync(path.join(workspacePath, "BOOTSTRAP.md")); // ── Identity fields ───────────────────────────────────────── if (identityContent) { result.emoji = parseIdentityField(identityContent, "Emoji"); result.creature = parseIdentityField(identityContent, "Creature"); result.vibe = parseIdentityField(identityContent, "Vibe"); } if (result.workspaceFiles.user) { result.ownerName = parseIdentityField(result.workspaceFiles.user, "Name") || parseIdentityField(result.workspaceFiles.user, "name"); } // ── Skills ────────────────────────────────────────────────── try { const skillsDir = path.join(workspacePath, "skills"); if (existsSync(skillsDir)) { result.skills = readdirSync(skillsDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { const meta = loadJsonSafe(path.join(skillsDir, d.name, "_meta.json")); return { name: d.name, description: meta?.description }; }); } } catch { /* ignore */ } // ── Connected systems (credential names) ──────────────────── try { const credsDir = path.join(openclawDir, "credentials"); if (existsSync(credsDir)) { result.connectedSystems = readdirSync(credsDir) .filter((f) => f.endsWith(".json")) .map((f) => f.slice(0, -5)); } } catch { /* ignore */ } // ── Sessions (count, lastActive, channels) ────────────────── try { const agentsDir = path.join(openclawDir, "agents"); if (existsSync(agentsDir)) { for (const dir of readdirSync(agentsDir, { withFileTypes: true })) { if (!dir.isDirectory()) continue; const sessionsData = loadJsonSafe(path.join(agentsDir, dir.name, "sessions", "sessions.json")); if (!sessionsData) continue; for (const session of Object.values(sessionsData)) { result.sessionCount++; if (typeof session.updatedAt === "number") { const iso = new Date(session.updatedAt).toISOString(); if (!result.lastActive || iso > result.lastActive) result.lastActive = iso; } if (typeof session.lastChannel === "string" && !result.channels.includes(session.lastChannel)) { result.channels.push(session.lastChannel); } } } } } catch { /* ignore */ } // ── Cron jobs ──────────────────────────────────────────────── try { const raw = loadTextSafe(path.join(openclawDir, "cron", "jobs.json")); if (raw) { const parsed = JSON.parse(raw); result.cronJobs = Array.isArray(parsed) ? parsed : (parsed?.jobs ?? []); } } catch { /* ignore */ } return result; } /** @deprecated use readAgentProfile() */ export function readAgentInfo() { return readAgentProfile(); } /** * Returns file paths that should be watched for changes to trigger a profile re-upload. */ export function getProfileWatchPaths(openclawDir) { const dir = openclawDir ?? path.join(os.homedir(), ".openclaw"); const config = loadJsonSafe(path.join(dir, "openclaw.json")); const agentsConfig = config?.agents; const workspace = agentsConfig?.defaults?.workspace ?? path.join(dir, "workspace"); return [ path.join(dir, "openclaw.json"), path.join(workspace, "IDENTITY.md"), path.join(workspace, "SOUL.md"), path.join(workspace, "USER.md"), path.join(workspace, "AGENTS.md"), path.join(workspace, "TOOLS.md"), path.join(workspace, "HEARTBEAT.md"), path.join(dir, "cron", "jobs.json"), path.join(workspace, "skills"), ]; } export function resolveConfig(config) { const plan = config?.plan; return { enabled: config?.enabled ?? DEFAULT_CONFIG.enabled, blockOnRisk: config?.blockOnRisk ?? DEFAULT_CONFIG.blockOnRisk, apiKey: config?.apiKey ?? DEFAULT_CONFIG.apiKey, timeoutMs: config?.timeoutMs ?? DEFAULT_CONFIG.timeoutMs, coreUrl: (config?.coreUrl ?? DEFAULT_CONFIG.coreUrl).replace(/\/+$/, ""), agentName: config?.agentName ?? readIdentityName() ?? DEFAULT_CONFIG.agentName, plan, }; } //# sourceMappingURL=config.js.map