UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

1,333 lines (1,328 loc) 66.3 kB
import { g as resolveStateDir, m as resolveOAuthDir, o as resolveConfigPath } from "./paths-BDd7_JUB.js"; import { N as normalizeAgentId, c as resolveDefaultAgentId } from "./agent-scope-CrgUOY3f.js"; import { n as runExec } from "./exec-CTo4hK94.js"; import { t as formatCliCommand } from "./command-format-BQK1OIvH.js"; import { D as INCLUDE_KEY, O as MAX_INCLUDE_DEPTH, n as createConfigIO } from "./config-qgIz1lbh.js"; import { n as listChannelPlugins } from "./plugins-D1CxUobm.js"; import { $ as resolveSandboxToolPolicyForAgent, Z as resolveSandboxConfigForAgent, at as resolveToolProfilePolicy } from "./sandbox-BXUfp_qv.js"; import { a as resolveProfile, i as resolveBrowserConfig } from "./server-context-D2cv-pIA.js"; import { m as GATEWAY_CLIENT_NAMES, p as GATEWAY_CLIENT_MODES } from "./message-channel-CQGWXVL4.js"; import { t as GatewayClient } from "./client-zqMhLTAX.js"; import { t as buildGatewayConnectionDetails } from "./call-CPBhMXxo.js"; import { i as readChannelAllowFromStore } from "./pairing-store-BnMngoWQ.js"; import { c as resolveNativeSkillsEnabled, n as isToolAllowedByPolicies, s as resolveNativeCommandsEnabled } from "./pi-tools.policy-DleRi9eC.js"; import { t as resolveChannelDefaultAccountId } from "./helpers-Cw9kFCkw.js"; import { i as resolveGatewayAuth } from "./auth-DK3l201_.js"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; import fs from "node:fs/promises"; import { randomUUID } from "node:crypto"; //#region src/gateway/probe.ts function formatError(err) { if (err instanceof Error) return err.message; return String(err); } async function probeGateway(opts) { const startedAt = Date.now(); const instanceId = randomUUID(); let connectLatencyMs = null; let connectError = null; let close = null; return await new Promise((resolve) => { let settled = false; const settle = (result) => { if (settled) return; settled = true; clearTimeout(timer); client.stop(); resolve({ url: opts.url, ...result }); }; const client = new GatewayClient({ url: opts.url, token: opts.auth?.token, password: opts.auth?.password, clientName: GATEWAY_CLIENT_NAMES.CLI, clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, onConnectError: (err) => { connectError = formatError(err); }, onClose: (code, reason) => { close = { code, reason }; }, onHelloOk: async () => { connectLatencyMs = Date.now() - startedAt; try { const [health, status, presence, configSnapshot] = await Promise.all([ client.request("health"), client.request("status"), client.request("system-presence"), client.request("config.get", {}) ]); settle({ ok: true, connectLatencyMs, error: null, close, health, status, presence: Array.isArray(presence) ? presence : null, configSnapshot }); } catch (err) { settle({ ok: false, connectLatencyMs, error: formatError(err), close, health: null, status: null, presence: null, configSnapshot: null }); } } }); const timer = setTimeout(() => { settle({ ok: false, connectLatencyMs, error: connectError ? `connect failed: ${connectError}` : "timeout", close, health: null, status: null, presence: null, configSnapshot: null }); }, Math.max(250, opts.timeoutMs)); client.start(); }); } //#endregion //#region src/security/windows-acl.ts const INHERIT_FLAGS = new Set([ "I", "OI", "CI", "IO", "NP" ]); const WORLD_PRINCIPALS = new Set([ "everyone", "users", "builtin\\users", "authenticated users", "nt authority\\authenticated users" ]); const TRUSTED_BASE = new Set([ "nt authority\\system", "system", "builtin\\administrators", "creator owner" ]); const WORLD_SUFFIXES = ["\\users", "\\authenticated users"]; const TRUSTED_SUFFIXES = ["\\administrators", "\\system"]; const normalize = (value) => value.trim().toLowerCase(); function resolveWindowsUserPrincipal(env) { const username = env?.USERNAME?.trim() || os.userInfo().username?.trim(); if (!username) return null; const domain = env?.USERDOMAIN?.trim(); return domain ? `${domain}\\${username}` : username; } function buildTrustedPrincipals(env) { const trusted = new Set(TRUSTED_BASE); const principal = resolveWindowsUserPrincipal(env); if (principal) { trusted.add(normalize(principal)); const userOnly = principal.split("\\").at(-1); if (userOnly) trusted.add(normalize(userOnly)); } return trusted; } function classifyPrincipal(principal, env) { const normalized = normalize(principal); if (buildTrustedPrincipals(env).has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s))) return "trusted"; if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s))) return "world"; return "group"; } function rightsFromTokens(tokens) { const upper = tokens.join("").toUpperCase(); const canWrite = upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D"); return { canRead: upper.includes("F") || upper.includes("M") || upper.includes("R"), canWrite }; } function parseIcaclsOutput(output, targetPath) { const entries = []; const normalizedTarget = targetPath.trim(); const lowerTarget = normalizedTarget.toLowerCase(); const quotedTarget = `"${normalizedTarget}"`; const quotedLower = quotedTarget.toLowerCase(); for (const rawLine of output.split(/\r?\n/)) { const line = rawLine.trimEnd(); if (!line.trim()) continue; const trimmed = line.trim(); const lower = trimmed.toLowerCase(); if (lower.startsWith("successfully processed") || lower.startsWith("processed") || lower.startsWith("failed processing") || lower.startsWith("no mapping between account names")) continue; let entry = trimmed; if (lower.startsWith(lowerTarget)) entry = trimmed.slice(normalizedTarget.length).trim(); else if (lower.startsWith(quotedLower)) entry = trimmed.slice(quotedTarget.length).trim(); if (!entry) continue; const idx = entry.indexOf(":"); if (idx === -1) continue; const principal = entry.slice(0, idx).trim(); const rawRights = entry.slice(idx + 1).trim(); const tokens = rawRights.match(/\(([^)]+)\)/g)?.map((token) => token.slice(1, -1).trim()).filter(Boolean) ?? []; if (tokens.some((token) => token.toUpperCase() === "DENY")) continue; const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase())); if (rights.length === 0) continue; const { canRead, canWrite } = rightsFromTokens(rights); entries.push({ principal, rights, rawRights, canRead, canWrite }); } return entries; } function summarizeWindowsAcl(entries, env) { const trusted = []; const untrustedWorld = []; const untrustedGroup = []; for (const entry of entries) { const classification = classifyPrincipal(entry.principal, env); if (classification === "trusted") trusted.push(entry); else if (classification === "world") untrustedWorld.push(entry); else untrustedGroup.push(entry); } return { trusted, untrustedWorld, untrustedGroup }; } async function inspectWindowsAcl(targetPath, opts) { const exec = opts?.exec ?? runExec; try { const { stdout, stderr } = await exec("icacls", [targetPath]); const entries = parseIcaclsOutput(`${stdout}\n${stderr}`.trim(), targetPath); const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env); return { ok: true, entries, trusted, untrustedWorld, untrustedGroup }; } catch (err) { return { ok: false, entries: [], trusted: [], untrustedWorld: [], untrustedGroup: [], error: String(err) }; } } function formatWindowsAclSummary(summary) { if (!summary.ok) return "unknown"; const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup]; if (untrusted.length === 0) return "trusted-only"; return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", "); } function formatIcaclsResetCommand(targetPath, opts) { const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%"; const grant = opts.isDir ? "(OI)(CI)F" : "F"; return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`; } function createIcaclsResetCommand(targetPath, opts) { const user = resolveWindowsUserPrincipal(opts.env); if (!user) return null; const grant = opts.isDir ? "(OI)(CI)F" : "F"; return { command: "icacls", args: [ targetPath, "/inheritance:r", "/grant:r", `${user}:${grant}`, "/grant:r", `SYSTEM:${grant}` ], display: formatIcaclsResetCommand(targetPath, opts) }; } //#endregion //#region src/security/audit-fs.ts async function safeStat(targetPath) { try { const lst = await fs.lstat(targetPath); return { ok: true, isSymlink: lst.isSymbolicLink(), isDir: lst.isDirectory(), mode: typeof lst.mode === "number" ? lst.mode : null, uid: typeof lst.uid === "number" ? lst.uid : null, gid: typeof lst.gid === "number" ? lst.gid : null }; } catch (err) { return { ok: false, isSymlink: false, isDir: false, mode: null, uid: null, gid: null, error: String(err) }; } } async function inspectPathPermissions(targetPath, opts) { const st = await safeStat(targetPath); if (!st.ok) return { ok: false, isSymlink: false, isDir: false, mode: null, bits: null, source: "unknown", worldWritable: false, groupWritable: false, worldReadable: false, groupReadable: false, error: st.error }; const bits = modeBits(st.mode); if ((opts?.platform ?? process.platform) === "win32") { const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec }); if (!acl.ok) return { ok: true, isSymlink: st.isSymlink, isDir: st.isDir, mode: st.mode, bits, source: "unknown", worldWritable: false, groupWritable: false, worldReadable: false, groupReadable: false, error: acl.error }; return { ok: true, isSymlink: st.isSymlink, isDir: st.isDir, mode: st.mode, bits, source: "windows-acl", worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite), groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite), worldReadable: acl.untrustedWorld.some((entry) => entry.canRead), groupReadable: acl.untrustedGroup.some((entry) => entry.canRead), aclSummary: formatWindowsAclSummary(acl) }; } return { ok: true, isSymlink: st.isSymlink, isDir: st.isDir, mode: st.mode, bits, source: "posix", worldWritable: isWorldWritable(bits), groupWritable: isGroupWritable(bits), worldReadable: isWorldReadable(bits), groupReadable: isGroupReadable(bits) }; } function formatPermissionDetail(targetPath, perms) { if (perms.source === "windows-acl") return `${targetPath} acl=${perms.aclSummary ?? "unknown"}`; return `${targetPath} mode=${formatOctal(perms.bits)}`; } function formatPermissionRemediation(params) { if (params.perms.source === "windows-acl") return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env }); return `chmod ${params.posixMode.toString(8).padStart(3, "0")} ${params.targetPath}`; } function modeBits(mode) { if (mode == null) return null; return mode & 511; } function formatOctal(bits) { if (bits == null) return "unknown"; return bits.toString(8).padStart(3, "0"); } function isWorldWritable(bits) { if (bits == null) return false; return (bits & 2) !== 0; } function isGroupWritable(bits) { if (bits == null) return false; return (bits & 16) !== 0; } function isWorldReadable(bits) { if (bits == null) return false; return (bits & 4) !== 0; } function isGroupReadable(bits) { if (bits == null) return false; return (bits & 32) !== 0; } //#endregion //#region src/security/audit-extra.ts const SMALL_MODEL_PARAM_B_MAX = 300; function expandTilde(p, env) { if (!p.startsWith("~")) return p; const home = typeof env.HOME === "string" && env.HOME.trim() ? env.HOME.trim() : null; if (!home) return null; if (p === "~") return home; if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(home, p.slice(2)); return null; } function summarizeGroupPolicy(cfg) { const channels = cfg.channels; if (!channels || typeof channels !== "object") return { open: 0, allowlist: 0, other: 0 }; let open = 0; let allowlist = 0; let other = 0; for (const value of Object.values(channels)) { if (!value || typeof value !== "object") continue; const policy = value.groupPolicy; if (policy === "open") open += 1; else if (policy === "allowlist") allowlist += 1; else other += 1; } return { open, allowlist, other }; } function collectAttackSurfaceSummaryFindings(cfg) { const group = summarizeGroupPolicy(cfg); const elevated = cfg.tools?.elevated?.enabled !== false; const hooksEnabled = cfg.hooks?.enabled === true; const browserEnabled = cfg.browser?.enabled ?? true; return [{ checkId: "summary.attack_surface", severity: "info", title: "Attack surface summary", detail: `groups: open=${group.open}, allowlist=${group.allowlist}\ntools.elevated: ${elevated ? "enabled" : "disabled"}\nhooks: ${hooksEnabled ? "enabled" : "disabled"}\nbrowser control: ${browserEnabled ? "enabled" : "disabled"}` }]; } function isProbablySyncedPath(p) { const s = p.toLowerCase(); return s.includes("icloud") || s.includes("dropbox") || s.includes("google drive") || s.includes("googledrive") || s.includes("onedrive"); } function collectSyncedFolderFindings(params) { const findings = []; if (isProbablySyncedPath(params.stateDir) || isProbablySyncedPath(params.configPath)) findings.push({ checkId: "fs.synced_dir", severity: "warn", title: "State/config path looks like a synced folder", detail: `stateDir=${params.stateDir}, configPath=${params.configPath}. Synced folders (iCloud/Dropbox/OneDrive/Google Drive) can leak tokens and transcripts onto other devices.`, remediation: `Keep OPENCLAW_STATE_DIR on a local-only volume and re-run "${formatCliCommand("openclaw security audit --fix")}".` }); return findings; } function looksLikeEnvRef(value) { const v = value.trim(); return v.startsWith("${") && v.endsWith("}"); } function collectSecretsInConfigFindings(cfg) { const findings = []; const password = typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : ""; if (password && !looksLikeEnvRef(password)) findings.push({ checkId: "config.secrets.gateway_password_in_config", severity: "warn", title: "Gateway password is stored in config", detail: "gateway.auth.password is set in the config file; prefer environment variables for secrets when possible.", remediation: "Prefer OPENCLAW_GATEWAY_PASSWORD (env) and remove gateway.auth.password from disk." }); const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : ""; if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) findings.push({ checkId: "config.secrets.hooks_token_in_config", severity: "info", title: "Hooks token is stored in config", detail: "hooks.token is set in the config file; keep config perms tight and treat it like an API secret." }); return findings; } function collectHooksHardeningFindings(cfg) { const findings = []; if (cfg.hooks?.enabled !== true) return findings; const token = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : ""; if (token && token.length < 24) findings.push({ checkId: "hooks.token_too_short", severity: "warn", title: "Hooks token looks short", detail: `hooks.token is ${token.length} chars; prefer a long random token.` }); const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off" }); const gatewayToken = gatewayAuth.mode === "token" && typeof gatewayAuth.token === "string" && gatewayAuth.token.trim() ? gatewayAuth.token.trim() : null; if (token && gatewayToken && token === gatewayToken) findings.push({ checkId: "hooks.token_reuse_gateway_token", severity: "warn", title: "Hooks token reuses the Gateway token", detail: "hooks.token matches gateway.auth token; compromise of hooks expands blast radius to the Gateway API.", remediation: "Use a separate hooks.token dedicated to hook ingress." }); if ((typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : "") === "/") findings.push({ checkId: "hooks.path_root", severity: "critical", title: "Hooks base path is '/'", detail: "hooks.path='/' would shadow other HTTP endpoints and is unsafe.", remediation: "Use a dedicated path like '/hooks'." }); return findings; } function addModel(models, raw, source) { if (typeof raw !== "string") return; const id = raw.trim(); if (!id) return; models.push({ id, source }); } function collectModels(cfg) { const out = []; addModel(out, cfg.agents?.defaults?.model?.primary, "agents.defaults.model.primary"); for (const f of cfg.agents?.defaults?.model?.fallbacks ?? []) addModel(out, f, "agents.defaults.model.fallbacks"); addModel(out, cfg.agents?.defaults?.imageModel?.primary, "agents.defaults.imageModel.primary"); for (const f of cfg.agents?.defaults?.imageModel?.fallbacks ?? []) addModel(out, f, "agents.defaults.imageModel.fallbacks"); const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : []; for (const agent of list ?? []) { if (!agent || typeof agent !== "object") continue; const id = typeof agent.id === "string" ? agent.id : ""; const model = agent.model; if (typeof model === "string") addModel(out, model, `agents.list.${id}.model`); else if (model && typeof model === "object") { addModel(out, model.primary, `agents.list.${id}.model.primary`); const fallbacks = model.fallbacks; if (Array.isArray(fallbacks)) for (const f of fallbacks) addModel(out, f, `agents.list.${id}.model.fallbacks`); } } return out; } const LEGACY_MODEL_PATTERNS = [ { id: "openai.gpt35", re: /\bgpt-3\.5\b/i, label: "GPT-3.5 family" }, { id: "anthropic.claude2", re: /\bclaude-(instant|2)\b/i, label: "Claude 2/Instant family" }, { id: "openai.gpt4_legacy", re: /\bgpt-4-(0314|0613)\b/i, label: "Legacy GPT-4 snapshots" } ]; const WEAK_TIER_MODEL_PATTERNS = [{ id: "anthropic.haiku", re: /\bhaiku\b/i, label: "Haiku tier (smaller model)" }]; function inferParamBFromIdOrName(text) { const matches = text.toLowerCase().matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g); let best = null; for (const match of matches) { const numRaw = match[1]; if (!numRaw) continue; const value = Number(numRaw); if (!Number.isFinite(value) || value <= 0) continue; if (best === null || value > best) best = value; } return best; } function isGptModel(id) { return /\bgpt-/i.test(id); } function isGpt5OrHigher(id) { return /\bgpt-5(?:\b|[.-])/i.test(id); } function isClaudeModel(id) { return /\bclaude-/i.test(id); } function isClaude45OrHigher(id) { return /\bclaude-[^\s/]*?(?:-4-?5\b|4\.5\b)/i.test(id); } function collectModelHygieneFindings(cfg) { const findings = []; const models = collectModels(cfg); if (models.length === 0) return findings; const weakMatches = /* @__PURE__ */ new Map(); const addWeakMatch = (model, source, reason) => { const key = `${model}@@${source}`; const existing = weakMatches.get(key); if (!existing) { weakMatches.set(key, { model, source, reasons: [reason] }); return; } if (!existing.reasons.includes(reason)) existing.reasons.push(reason); }; for (const entry of models) { for (const pat of WEAK_TIER_MODEL_PATTERNS) if (pat.re.test(entry.id)) { addWeakMatch(entry.id, entry.source, pat.label); break; } if (isGptModel(entry.id) && !isGpt5OrHigher(entry.id)) addWeakMatch(entry.id, entry.source, "Below GPT-5 family"); if (isClaudeModel(entry.id) && !isClaude45OrHigher(entry.id)) addWeakMatch(entry.id, entry.source, "Below Claude 4.5"); } const matches = []; for (const entry of models) for (const pat of LEGACY_MODEL_PATTERNS) if (pat.re.test(entry.id)) { matches.push({ model: entry.id, source: entry.source, reason: pat.label }); break; } if (matches.length > 0) { const lines = matches.slice(0, 12).map((m) => `- ${m.model} (${m.reason}) @ ${m.source}`).join("\n"); const more = matches.length > 12 ? `\n…${matches.length - 12} more` : ""; findings.push({ checkId: "models.legacy", severity: "warn", title: "Some configured models look legacy", detail: "Older/legacy models can be less robust against prompt injection and tool misuse.\n" + lines + more, remediation: "Prefer modern, instruction-hardened models for any bot that can run tools." }); } if (weakMatches.size > 0) { const lines = Array.from(weakMatches.values()).slice(0, 12).map((m) => `- ${m.model} (${m.reasons.join("; ")}) @ ${m.source}`).join("\n"); const more = weakMatches.size > 12 ? `\n…${weakMatches.size - 12} more` : ""; findings.push({ checkId: "models.weak_tier", severity: "warn", title: "Some configured models are below recommended tiers", detail: "Smaller/older models are generally more susceptible to prompt injection and tool misuse.\n" + lines + more, remediation: "Use the latest, top-tier model for any bot with tools or untrusted inboxes. Avoid Haiku tiers; prefer GPT-5+ and Claude 4.5+." }); } return findings; } function extractAgentIdFromSource(source) { return source.match(/^agents\.list\.([^.]*)\./)?.[1] ?? null; } function pickToolPolicy(config) { if (!config) return null; const allow = Array.isArray(config.allow) ? config.allow : void 0; const deny = Array.isArray(config.deny) ? config.deny : void 0; if (!allow && !deny) return null; return { allow, deny }; } function resolveToolPolicies(params) { const policies = []; const profilePolicy = resolveToolProfilePolicy(params.agentTools?.profile ?? params.cfg.tools?.profile); if (profilePolicy) policies.push(profilePolicy); const globalPolicy = pickToolPolicy(params.cfg.tools ?? void 0); if (globalPolicy) policies.push(globalPolicy); const agentPolicy = pickToolPolicy(params.agentTools); if (agentPolicy) policies.push(agentPolicy); if (params.sandboxMode === "all") { const sandboxPolicy = resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? void 0); policies.push(sandboxPolicy); } return policies; } function hasWebSearchKey(cfg, env) { const search = cfg.tools?.web?.search; return Boolean(search?.apiKey || search?.perplexity?.apiKey || env.BRAVE_API_KEY || env.PERPLEXITY_API_KEY || env.OPENROUTER_API_KEY); } function isWebSearchEnabled(cfg, env) { const enabled = cfg.tools?.web?.search?.enabled; if (enabled === false) return false; if (enabled === true) return true; return hasWebSearchKey(cfg, env); } function isWebFetchEnabled(cfg) { if (cfg.tools?.web?.fetch?.enabled === false) return false; return true; } function isBrowserEnabled(cfg) { try { return resolveBrowserConfig(cfg.browser, cfg).enabled; } catch { return true; } } function collectSmallModelRiskFindings(params) { const findings = []; const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel")); if (models.length === 0) return findings; const smallModels = models.map((entry) => { const paramB = inferParamBFromIdOrName(entry.id); if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) return null; return { ...entry, paramB }; }).filter((entry) => Boolean(entry)); if (smallModels.length === 0) return findings; let hasUnsafe = false; const modelLines = []; const exposureSet = /* @__PURE__ */ new Set(); for (const entry of smallModels) { const agentId = extractAgentIdFromSource(entry.source); const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? void 0).mode; const agentTools = agentId && params.cfg.agents?.list ? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools : void 0; const policies = resolveToolPolicies({ cfg: params.cfg, agentTools, sandboxMode, agentId }); const exposed = []; if (isWebSearchEnabled(params.cfg, params.env)) { if (isToolAllowedByPolicies("web_search", policies)) exposed.push("web_search"); } if (isWebFetchEnabled(params.cfg)) { if (isToolAllowedByPolicies("web_fetch", policies)) exposed.push("web_fetch"); } if (isBrowserEnabled(params.cfg)) { if (isToolAllowedByPolicies("browser", policies)) exposed.push("browser"); } for (const tool of exposed) exposureSet.add(tool); const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`; const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]"; const safe = sandboxMode === "all" && exposed.length === 0; if (!safe) hasUnsafe = true; const statusLabel = safe ? "ok" : "unsafe"; modelLines.push(`- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`); } const exposureList = Array.from(exposureSet); const exposureDetail = exposureList.length > 0 ? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.` : "No web/browser tools detected for these models."; findings.push({ checkId: "models.small_params", severity: hasUnsafe ? "critical" : "info", title: "Small models require sandboxing and web tools disabled", detail: `Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` + modelLines.join("\n") + `\n` + exposureDetail + "\nSmall models are not recommended for untrusted inputs.", remediation: "If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode=\"all\") and disable web_search/web_fetch/browser (tools.deny=[\"group:web\",\"browser\"])." }); return findings; } async function collectPluginsTrustFindings(params) { const findings = []; const extensionsDir = path.join(params.stateDir, "extensions"); const st = await safeStat(extensionsDir); if (!st.ok || !st.isDir) return findings; const pluginDirs = (await fs.readdir(extensionsDir, { withFileTypes: true }).catch(() => [])).filter((e) => e.isDirectory()).map((e) => e.name).filter(Boolean); if (pluginDirs.length === 0) return findings; const allow = params.cfg.plugins?.allow; if (!(Array.isArray(allow) && allow.length > 0)) { const hasString = (value) => typeof value === "string" && value.trim().length > 0; const hasAccountStringKey = (account, key) => Boolean(account && typeof account === "object" && hasString(account[key])); const discordConfigured = hasString(params.cfg.channels?.discord?.token) || Boolean(params.cfg.channels?.discord?.accounts && Object.values(params.cfg.channels.discord.accounts).some((a) => hasAccountStringKey(a, "token"))) || hasString(process.env.DISCORD_BOT_TOKEN); const telegramConfigured = hasString(params.cfg.channels?.telegram?.botToken) || hasString(params.cfg.channels?.telegram?.tokenFile) || Boolean(params.cfg.channels?.telegram?.accounts && Object.values(params.cfg.channels.telegram.accounts).some((a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"))) || hasString(process.env.TELEGRAM_BOT_TOKEN); const slackConfigured = hasString(params.cfg.channels?.slack?.botToken) || hasString(params.cfg.channels?.slack?.appToken) || Boolean(params.cfg.channels?.slack?.accounts && Object.values(params.cfg.channels.slack.accounts).some((a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"))) || hasString(process.env.SLACK_BOT_TOKEN) || hasString(process.env.SLACK_APP_TOKEN); const skillCommandsLikelyExposed = discordConfigured && resolveNativeSkillsEnabled({ providerId: "discord", providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills, globalSetting: params.cfg.commands?.nativeSkills }) || telegramConfigured && resolveNativeSkillsEnabled({ providerId: "telegram", providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills, globalSetting: params.cfg.commands?.nativeSkills }) || slackConfigured && resolveNativeSkillsEnabled({ providerId: "slack", providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills, globalSetting: params.cfg.commands?.nativeSkills }); findings.push({ checkId: "plugins.extensions_no_allowlist", severity: skillCommandsLikelyExposed ? "critical" : "warn", title: "Extensions exist but plugins.allow is not set", detail: `Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` + (skillCommandsLikelyExposed ? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk." : ""), remediation: "Set plugins.allow to an explicit list of plugin ids you trust." }); } return findings; } function resolveIncludePath(baseConfigPath, includePath) { return path.normalize(path.isAbsolute(includePath) ? includePath : path.resolve(path.dirname(baseConfigPath), includePath)); } function listDirectIncludes(parsed) { const out = []; const visit = (value) => { if (!value) return; if (Array.isArray(value)) { for (const item of value) visit(item); return; } if (typeof value !== "object") return; const rec = value; const includeVal = rec[INCLUDE_KEY]; if (typeof includeVal === "string") out.push(includeVal); else if (Array.isArray(includeVal)) { for (const item of includeVal) if (typeof item === "string") out.push(item); } for (const v of Object.values(rec)) visit(v); }; visit(parsed); return out; } async function collectIncludePathsRecursive(params) { const visited = /* @__PURE__ */ new Set(); const result = []; const walk = async (basePath, parsed, depth) => { if (depth > MAX_INCLUDE_DEPTH) return; for (const raw of listDirectIncludes(parsed)) { const resolved = resolveIncludePath(basePath, raw); if (visited.has(resolved)) continue; visited.add(resolved); result.push(resolved); const rawText = await fs.readFile(resolved, "utf-8").catch(() => null); if (!rawText) continue; const nestedParsed = (() => { try { return JSON5.parse(rawText); } catch { return null; } })(); if (nestedParsed) await walk(resolved, nestedParsed, depth + 1); } }; await walk(params.configPath, params.parsed, 0); return result; } async function collectIncludeFilePermFindings(params) { const findings = []; if (!params.configSnapshot.exists) return findings; const configPath = params.configSnapshot.path; const includePaths = await collectIncludePathsRecursive({ configPath, parsed: params.configSnapshot.parsed }); if (includePaths.length === 0) return findings; for (const p of includePaths) { const perms = await inspectPathPermissions(p, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (!perms.ok) continue; if (perms.worldWritable || perms.groupWritable) findings.push({ checkId: "fs.config_include.perms_writable", severity: "critical", title: "Config include file is writable by others", detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`, remediation: formatPermissionRemediation({ targetPath: p, perms, isDir: false, posixMode: 384, env: params.env }) }); else if (perms.worldReadable) findings.push({ checkId: "fs.config_include.perms_world_readable", severity: "critical", title: "Config include file is world-readable", detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: p, perms, isDir: false, posixMode: 384, env: params.env }) }); else if (perms.groupReadable) findings.push({ checkId: "fs.config_include.perms_group_readable", severity: "warn", title: "Config include file is group-readable", detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: p, perms, isDir: false, posixMode: 384, env: params.env }) }); } return findings; } async function collectStateDeepFilesystemFindings(params) { const findings = []; const oauthDir = resolveOAuthDir(params.env, params.stateDir); const oauthPerms = await inspectPathPermissions(oauthDir, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (oauthPerms.ok && oauthPerms.isDir) { if (oauthPerms.worldWritable || oauthPerms.groupWritable) findings.push({ checkId: "fs.credentials_dir.perms_writable", severity: "critical", title: "Credentials dir is writable by others", detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`, remediation: formatPermissionRemediation({ targetPath: oauthDir, perms: oauthPerms, isDir: true, posixMode: 448, env: params.env }) }); else if (oauthPerms.groupReadable || oauthPerms.worldReadable) findings.push({ checkId: "fs.credentials_dir.perms_readable", severity: "warn", title: "Credentials dir is readable by others", detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`, remediation: formatPermissionRemediation({ targetPath: oauthDir, perms: oauthPerms, isDir: true, posixMode: 448, env: params.env }) }); } const agentIds = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents?.list.map((a) => a && typeof a === "object" && typeof a.id === "string" ? a.id.trim() : "").filter(Boolean) : []; const defaultAgentId = resolveDefaultAgentId(params.cfg); const ids = Array.from(new Set([defaultAgentId, ...agentIds])).map((id) => normalizeAgentId(id)); for (const agentId of ids) { const agentDir = path.join(params.stateDir, "agents", agentId, "agent"); const authPath = path.join(agentDir, "auth-profiles.json"); const authPerms = await inspectPathPermissions(authPath, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (authPerms.ok) { if (authPerms.worldWritable || authPerms.groupWritable) findings.push({ checkId: "fs.auth_profiles.perms_writable", severity: "critical", title: "auth-profiles.json is writable by others", detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`, remediation: formatPermissionRemediation({ targetPath: authPath, perms: authPerms, isDir: false, posixMode: 384, env: params.env }) }); else if (authPerms.worldReadable || authPerms.groupReadable) findings.push({ checkId: "fs.auth_profiles.perms_readable", severity: "warn", title: "auth-profiles.json is readable by others", detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`, remediation: formatPermissionRemediation({ targetPath: authPath, perms: authPerms, isDir: false, posixMode: 384, env: params.env }) }); } const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); const storePerms = await inspectPathPermissions(storePath, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (storePerms.ok) { if (storePerms.worldReadable || storePerms.groupReadable) findings.push({ checkId: "fs.sessions_store.perms_readable", severity: "warn", title: "sessions.json is readable by others", detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`, remediation: formatPermissionRemediation({ targetPath: storePath, perms: storePerms, isDir: false, posixMode: 384, env: params.env }) }); } } const logFile = typeof params.cfg.logging?.file === "string" ? params.cfg.logging.file.trim() : ""; if (logFile) { const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile; if (expanded) { const logPath = path.resolve(expanded); const logPerms = await inspectPathPermissions(logPath, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (logPerms.ok) { if (logPerms.worldReadable || logPerms.groupReadable) findings.push({ checkId: "fs.log_file.perms_readable", severity: "warn", title: "Log file is readable by others", detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`, remediation: formatPermissionRemediation({ targetPath: logPath, perms: logPerms, isDir: false, posixMode: 384, env: params.env }) }); } } } return findings; } function listGroupPolicyOpen(cfg) { const out = []; const channels = cfg.channels; if (!channels || typeof channels !== "object") return out; for (const [channelId, value] of Object.entries(channels)) { if (!value || typeof value !== "object") continue; const section = value; if (section.groupPolicy === "open") out.push(`channels.${channelId}.groupPolicy`); const accounts = section.accounts; if (accounts && typeof accounts === "object") for (const [accountId, accountVal] of Object.entries(accounts)) { if (!accountVal || typeof accountVal !== "object") continue; if (accountVal.groupPolicy === "open") out.push(`channels.${channelId}.accounts.${accountId}.groupPolicy`); } } return out; } function collectExposureMatrixFindings(cfg) { const findings = []; const openGroups = listGroupPolicyOpen(cfg); if (openGroups.length === 0) return findings; if (cfg.tools?.elevated?.enabled !== false) findings.push({ checkId: "security.exposure.open_groups_with_elevated", severity: "critical", title: "Open groupPolicy with elevated tools enabled", detail: `Found groupPolicy="open" at:\n${openGroups.map((p) => `- ${p}`).join("\n")}\nWith tools.elevated enabled, a prompt injection in those rooms can become a high-impact incident.`, remediation: `Set groupPolicy="allowlist" and keep elevated allowlists extremely tight.` }); return findings; } async function readConfigSnapshotForAudit(params) { return await createConfigIO({ env: params.env, configPath: params.configPath }).readConfigFileSnapshot(); } //#endregion //#region src/security/audit.ts function countBySeverity(findings) { let critical = 0; let warn = 0; let info = 0; for (const f of findings) if (f.severity === "critical") critical += 1; else if (f.severity === "warn") warn += 1; else info += 1; return { critical, warn, info }; } function normalizeAllowFromList(list) { if (!Array.isArray(list)) return []; return list.map((v) => String(v).trim()).filter(Boolean); } function classifyChannelWarningSeverity(message) { const s = message.toLowerCase(); if (s.includes("dms: open") || s.includes("grouppolicy=\"open\"") || s.includes("dmpolicy=\"open\"")) return "critical"; if (s.includes("allows any") || s.includes("anyone can dm") || s.includes("public")) return "critical"; if (s.includes("locked") || s.includes("disabled")) return "info"; return "warn"; } async function collectFilesystemFindings(params) { const findings = []; const stateDirPerms = await inspectPathPermissions(params.stateDir, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (stateDirPerms.ok) { if (stateDirPerms.isSymlink) findings.push({ checkId: "fs.state_dir.symlink", severity: "warn", title: "State dir is a symlink", detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.` }); if (stateDirPerms.worldWritable) findings.push({ checkId: "fs.state_dir.perms_world_writable", severity: "critical", title: "State dir is world-writable", detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your OpenClaw state.`, remediation: formatPermissionRemediation({ targetPath: params.stateDir, perms: stateDirPerms, isDir: true, posixMode: 448, env: params.env }) }); else if (stateDirPerms.groupWritable) findings.push({ checkId: "fs.state_dir.perms_group_writable", severity: "warn", title: "State dir is group-writable", detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your OpenClaw state.`, remediation: formatPermissionRemediation({ targetPath: params.stateDir, perms: stateDirPerms, isDir: true, posixMode: 448, env: params.env }) }); else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) findings.push({ checkId: "fs.state_dir.perms_readable", severity: "warn", title: "State dir is readable by others", detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`, remediation: formatPermissionRemediation({ targetPath: params.stateDir, perms: stateDirPerms, isDir: true, posixMode: 448, env: params.env }) }); } const configPerms = await inspectPathPermissions(params.configPath, { env: params.env, platform: params.platform, exec: params.execIcacls }); if (configPerms.ok) { if (configPerms.isSymlink) findings.push({ checkId: "fs.config.symlink", severity: "warn", title: "Config file is a symlink", detail: `${params.configPath} is a symlink; make sure you trust its target.` }); if (configPerms.worldWritable || configPerms.groupWritable) findings.push({ checkId: "fs.config.perms_writable", severity: "critical", title: "Config file is writable by others", detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`, remediation: formatPermissionRemediation({ targetPath: params.configPath, perms: configPerms, isDir: false, posixMode: 384, env: params.env }) }); else if (configPerms.worldReadable) findings.push({ checkId: "fs.config.perms_world_readable", severity: "critical", title: "Config file is world-readable", detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: params.configPath, perms: configPerms, isDir: false, posixMode: 384, env: params.env }) }); else if (configPerms.groupReadable) findings.push({ checkId: "fs.config.perms_group_readable", severity: "warn", title: "Config file is group-readable", detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: params.configPath, perms: configPerms, isDir: false, posixMode: 384, env: params.env }) }); } return findings; } function collectGatewayConfigFindings(cfg, env) { const findings = []; const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) ? cfg.gateway.trustedProxies : []; const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0; const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0; const hasSharedSecret = auth.mode === "token" && hasToken || auth.mode === "password" && hasPassword; const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve"; const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth; if (bind !== "loopback" && !hasSharedSecret) findings.push({ checkId: "gateway.bind_no_auth", severity: "critical", title: "Gateway binds beyond loopback without auth", detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`, remediation: `Set gateway.auth (token recommended) or bind to loopback.` }); if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) findings.push({ checkId: "gateway.trusted_proxies_missing", severity: "warn", title: "Reverse proxy headers are not trusted", detail: "gateway.bind is loopback and gateway.trustedProxies is empty. If you expose the Control UI through a reverse proxy, configure trusted proxies so local-client checks cannot be spoofed.", remediation: "Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only." }); if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) findings.push({ checkId: "gateway.loopback_no_auth", severity: "critical", title: "Gateway auth missing on loopback", detail: "gateway.bind is loopback but no gateway auth secret is configured. If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.", remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only." }); if (tailscaleMode === "funnel") findings.push({ checkId: "gateway.tailscale_funnel", severity: "critical", title: "Tailscale Funnel exposure enabled", detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`, remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".` }); else if (tailscaleMode === "serve") findings.push({ checkId: "gateway.tailscale_serve", severity: "info", title: "Tailscale Serve exposure enabled", detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).` }); if (cfg.gateway?.controlUi?.allowInsecureAuth === true) findings.push({ checkId: "gateway.control_ui.insecure_auth", severity: "critical", title: "Control UI allows insecure HTTP auth", detail: "gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.", remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost." }); if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) findings.push({ checkId: "gateway.control_ui.device_auth_disabled", severity: "critical", title: "DANGEROUS: Control UI device auth disabled", detail: "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.", remediation: "Disable it unless you are in a short-lived break-glass scenario." }); const token = typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; if (auth.mode === "token" && token && token.length < 24) findings.push({ checkId: "gateway.token_too_short", severity: "warn", title: "Gateway token looks short", detail: `gateway auth token is ${token.length} chars; prefer a long random token.` }); return findings; } function collectBrowserControlFindings(cfg) { const findings = []; let resolved; try { resolved = resolveBrowserConfig(cfg.browser, cfg); } catch (err) { findings.push({ checkId: "browser.control_invalid_config", severity: "warn", title: "Browser control config looks invalid", detail: String(err), remediation: `Fix browser.cdpUrl in ${resolveConfigPath()} and re-run "${formatCliCommand("openclaw security audit --deep")}".` }); return findings; } if (!resolved.enabled) return findings; for (const name of Object.keys(resolved.profiles)) { const profile = resolveProfile(resolved, name); if (!profile || profile.cdpIsLoopback) continue; let url; try { url = new URL(profile.cdpUrl); } catch { continue; } if (url.protocol === "http:") findings.push({ checkId: "browser.remote_cdp_http", severity: "warn", title: "Remote CDP uses HTTP", detail: `browser profile "${name}" uses http CDP (${profile.cdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`, remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.` }); } return findings; } function collectLoggingFindings(cfg) { if (cfg.logging?.redactSensitive !== "off") return []; return [{ checkId: "logging.redact_off", severity: "warn", title: "Tool summary redaction is disabled", detail: `logging.redactSensitive="off" can leak secrets into logs and status output.`, remediation: `Set logging.redactSensitive="tools".` }]; } function collectElevatedFindings(cfg) { const findings = []; const enabled = cfg.tools?.elevated?.enabled; const allowFrom = cfg.tools?.elevated?.allowFrom ?? {}; const anyAllowFromKeys = Object.keys(allowFrom).length > 0; if (enabled === false) return findings; if (!anyAllowFromKeys) return findings; for (const [provider, list] of Object.entries(allowFrom)) { const normalized = normalizeAllowFromList(list); if (normalized.includes("*")) findings.push({ checkId: `tools.elevated.allowFrom.${provider}.wildcard`, severity: "critical", title: "Elevated exec allowlist contains wildcard", detail: `tools.elevated.allowFrom.${provider} includes "*" which effectively approves everyone on that channel for elevated mode.` }); else if (normalized.length > 25) findings.push({ checkId: `tools.elevated.allowFrom.${provider}.large`, severity: "warn", title: "Elevated exec allowlist is large", detail: `tools.elevated.allowFrom.${provider} has ${normalized.length} entries; consider tightening elevated access.` }); } return findings; } async function collectChannelSecurityFindings(params) { const findings = []; const coerceNativeSetting = (value) => { if (value === true) return true; if (value === false) return false; if (value === "auto") return "auto"; }; const warnDmPolicy = async (input) => { const policyPath = input.policyPath