import fs from "node:fs/promises"; import path from "node:path"; import JSON5 from "json5"; import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/config.js"; import { createConfigIO } from "../config/config.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js"; import { resolveOAuthDir } from "../config/paths.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { formatOctal, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, safeStat, } from "./audit-fs.js"; export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical"; title: string; detail: string; remediation?: string; }; const SMALL_MODEL_PARAM_B_MAX = 300; function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null { 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: ClawdbotConfig): { open: number; allowlist: number; other: number; } { const channels = cfg.channels as Record | undefined; 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 section = value as Record; const policy = section.groupPolicy; if (policy === "open") open += 1; else if (policy === "allowlist") allowlist += 1; else other += 1; } return { open, allowlist, other }; } export function collectAttackSurfaceSummaryFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const group = summarizeGroupPolicy(cfg); const elevated = cfg.tools?.elevated?.enabled !== false; const hooksEnabled = cfg.hooks?.enabled === true; const browserEnabled = Boolean(cfg.browser?.enabled ?? cfg.browser?.controlUrl); const detail = `groups: open=${group.open}, allowlist=${group.allowlist}` + `\n` + `tools.elevated: ${elevated ? "enabled" : "disabled"}` + `\n` + `hooks: ${hooksEnabled ? "enabled" : "disabled"}` + `\n` + `browser control: ${browserEnabled ? "enabled" : "disabled"}`; return [ { checkId: "summary.attack_surface", severity: "info", title: "Attack surface summary", detail, }, ]; } function isProbablySyncedPath(p: string): boolean { const s = p.toLowerCase(); return ( s.includes("icloud") || s.includes("dropbox") || s.includes("google drive") || s.includes("googledrive") || s.includes("onedrive") ); } export function collectSyncedFolderFindings(params: { stateDir: string; configPath: string; }): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; 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 CLAWDBOT_STATE_DIR on a local-only volume and re-run "${formatCliCommand("clawdbot security audit --fix")}".`, }); } return findings; } function looksLikeEnvRef(value: string): boolean { const v = value.trim(); return v.startsWith("${") && v.endsWith("}"); } export function collectSecretsInConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; 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 CLAWDBOT_GATEWAY_PASSWORD (env) and remove gateway.auth.password from disk.", }); } const browserToken = typeof cfg.browser?.controlToken === "string" ? cfg.browser.controlToken.trim() : ""; if (browserToken && !looksLikeEnvRef(browserToken)) { findings.push({ checkId: "config.secrets.browser_control_token_in_config", severity: "warn", title: "Browser control token is stored in config", detail: "browser.controlToken is set in the config file; prefer environment variables for secrets when possible.", remediation: "Prefer CLAWDBOT_BROWSER_CONTROL_TOKEN (env) and remove browser.controlToken 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; } export function collectHooksHardeningFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; 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.", }); } const browserToken = typeof cfg.browser?.controlToken === "string" && cfg.browser.controlToken.trim() ? cfg.browser.controlToken.trim() : process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || null; if (token && browserToken && token === browserToken) { findings.push({ checkId: "hooks.token_reuse_browser_token", severity: "warn", title: "Hooks token reuses the browser control token", detail: "hooks.token matches browser control token; compromise of hooks may enable browser control endpoints.", remediation: "Use a separate hooks.token dedicated to hook ingress.", }); } const rawPath = typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : ""; if (rawPath === "/") { 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; } type ModelRef = { id: string; source: string }; function addModel(models: ModelRef[], raw: unknown, source: string) { if (typeof raw !== "string") return; const id = raw.trim(); if (!id) return; models.push({ id, source }); } function collectModels(cfg: ClawdbotConfig): ModelRef[] { const out: ModelRef[] = []; 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 as { id?: unknown }).id === "string" ? (agent as { id: string }).id : ""; const model = (agent as { model?: unknown }).model; if (typeof model === "string") { addModel(out, model, `agents.list.${id}.model`); } else if (model && typeof model === "object") { addModel(out, (model as { primary?: unknown }).primary, `agents.list.${id}.model.primary`); const fallbacks = (model as { fallbacks?: unknown }).fallbacks; if (Array.isArray(fallbacks)) { for (const f of fallbacks) addModel(out, f, `agents.list.${id}.model.fallbacks`); } } } return out; } const LEGACY_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [ { 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: Array<{ id: string; re: RegExp; label: string }> = [ { id: "anthropic.haiku", re: /\bhaiku\b/i, label: "Haiku tier (smaller model)" }, ]; function inferParamBFromIdOrName(text: string): number | null { const raw = text.toLowerCase(); const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g); let best: number | null = 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: string): boolean { return /\bgpt-/i.test(id); } function isGpt5OrHigher(id: string): boolean { return /\bgpt-5(?:\b|[.-])/i.test(id); } function isClaudeModel(id: string): boolean { return /\bclaude-/i.test(id); } function isClaude45OrHigher(id: string): boolean { return /\bclaude-[^\s/]*?(?:-4-5\b|4\.5\b)/i.test(id); } export function collectModelHygieneFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const models = collectModels(cfg); if (models.length === 0) return findings; const weakMatches = new Map(); const addWeakMatch = (model: string, source: string, reason: string) => { 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: Array<{ model: string; source: string; reason: string }> = []; 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: string): string | null { const match = source.match(/^agents\.list\.([^.]*)\./); return match?.[1] ?? null; } function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): SandboxToolPolicy | null { if (!config) return null; const allow = Array.isArray(config.allow) ? config.allow : undefined; const deny = Array.isArray(config.deny) ? config.deny : undefined; if (!allow && !deny) return null; return { allow, deny }; } function resolveToolPolicies(params: { cfg: ClawdbotConfig; agentTools?: AgentToolsConfig; sandboxMode?: "off" | "non-main" | "all"; agentId?: string | null; }): SandboxToolPolicy[] { const policies: SandboxToolPolicy[] = []; const profile = params.agentTools?.profile ?? params.cfg.tools?.profile; const profilePolicy = resolveToolProfilePolicy(profile); if (profilePolicy) policies.push(profilePolicy); const globalPolicy = pickToolPolicy(params.cfg.tools ?? undefined); 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 ?? undefined); policies.push(sandboxPolicy); } return policies; } function hasWebSearchKey(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean { 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: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean { const enabled = cfg.tools?.web?.search?.enabled; if (enabled === false) return false; if (enabled === true) return true; return hasWebSearchKey(cfg, env); } function isWebFetchEnabled(cfg: ClawdbotConfig): boolean { const enabled = cfg.tools?.web?.fetch?.enabled; if (enabled === false) return false; return true; } function isBrowserEnabled(cfg: ClawdbotConfig): boolean { try { return resolveBrowserConfig(cfg.browser).enabled; } catch { return true; } } export function collectSmallModelRiskFindings(params: { cfg: ClawdbotConfig; env: NodeJS.ProcessEnv; }): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; 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): entry is { id: string; source: string; paramB: number } => Boolean(entry)); if (smallModels.length === 0) return findings; let hasUnsafe = false; const modelLines: string[] = []; const exposureSet = new Set(); for (const entry of smallModels) { const agentId = extractAgentIdFromSource(entry.source); const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode; const agentTools = agentId && params.cfg.agents?.list ? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools : undefined; const policies = resolveToolPolicies({ cfg: params.cfg, agentTools, sandboxMode, agentId, }); const exposed: string[] = []; 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 + `\n` + "Small 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; } export async function collectPluginsTrustFindings(params: { cfg: ClawdbotConfig; stateDir: string; }): Promise { const findings: SecurityAuditFinding[] = []; const extensionsDir = path.join(params.stateDir, "extensions"); const st = await safeStat(extensionsDir); if (!st.ok || !st.isDir) return findings; const entries = await fs.readdir(extensionsDir, { withFileTypes: true }).catch(() => []); const pluginDirs = entries .filter((e) => e.isDirectory()) .map((e) => e.name) .filter(Boolean); if (pluginDirs.length === 0) return findings; const allow = params.cfg.plugins?.allow; const allowConfigured = Array.isArray(allow) && allow.length > 0; if (!allowConfigured) { const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0; const hasAccountStringKey = (account: unknown, key: string) => Boolean( account && typeof account === "object" && hasString((account as Record)[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: string, includePath: string): string { return path.normalize( path.isAbsolute(includePath) ? includePath : path.resolve(path.dirname(baseConfigPath), includePath), ); } function listDirectIncludes(parsed: unknown): string[] { const out: string[] = []; const visit = (value: unknown) => { if (!value) return; if (Array.isArray(value)) { for (const item of value) visit(item); return; } if (typeof value !== "object") return; const rec = value as Record; 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: { configPath: string; parsed: unknown; }): Promise { const visited = new Set(); const result: string[] = []; const walk = async (basePath: string, parsed: unknown, depth: number): Promise => { 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) as unknown; } catch { return null; } })(); if (nestedParsed) { // eslint-disable-next-line no-await-in-loop await walk(resolved, nestedParsed, depth + 1); } } }; await walk(params.configPath, params.parsed, 0); return result; } export async function collectIncludeFilePermFindings(params: { configSnapshot: ConfigFileSnapshot; }): Promise { const findings: SecurityAuditFinding[] = []; 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) { // eslint-disable-next-line no-await-in-loop const st = await safeStat(p); if (!st.ok) continue; const bits = modeBits(st.mode); if (isWorldWritable(bits) || isGroupWritable(bits)) { findings.push({ checkId: "fs.config_include.perms_writable", severity: "critical", title: "Config include file is writable by others", detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`, remediation: `chmod 600 ${p}`, }); } else if (isWorldReadable(bits)) { findings.push({ checkId: "fs.config_include.perms_world_readable", severity: "critical", title: "Config include file is world-readable", detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`, remediation: `chmod 600 ${p}`, }); } else if (isGroupReadable(bits)) { findings.push({ checkId: "fs.config_include.perms_group_readable", severity: "warn", title: "Config include file is group-readable", detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`, remediation: `chmod 600 ${p}`, }); } } return findings; } export async function collectStateDeepFilesystemFindings(params: { cfg: ClawdbotConfig; env: NodeJS.ProcessEnv; stateDir: string; }): Promise { const findings: SecurityAuditFinding[] = []; const oauthDir = resolveOAuthDir(params.env, params.stateDir); const oauthStat = await safeStat(oauthDir); if (oauthStat.ok && oauthStat.isDir) { const bits = modeBits(oauthStat.mode); if (isWorldWritable(bits) || isGroupWritable(bits)) { findings.push({ checkId: "fs.credentials_dir.perms_writable", severity: "critical", title: "Credentials dir is writable by others", detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`, remediation: `chmod 700 ${oauthDir}`, }); } else if (isGroupReadable(bits) || isWorldReadable(bits)) { findings.push({ checkId: "fs.credentials_dir.perms_readable", severity: "warn", title: "Credentials dir is readable by others", detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`, remediation: `chmod 700 ${oauthDir}`, }); } } 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"); // eslint-disable-next-line no-await-in-loop const authStat = await safeStat(authPath); if (authStat.ok) { const bits = modeBits(authStat.mode); if (isWorldWritable(bits) || isGroupWritable(bits)) { findings.push({ checkId: "fs.auth_profiles.perms_writable", severity: "critical", title: "auth-profiles.json is writable by others", detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`, remediation: `chmod 600 ${authPath}`, }); } else if (isWorldReadable(bits) || isGroupReadable(bits)) { findings.push({ checkId: "fs.auth_profiles.perms_readable", severity: "warn", title: "auth-profiles.json is readable by others", detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`, remediation: `chmod 600 ${authPath}`, }); } } const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); // eslint-disable-next-line no-await-in-loop const storeStat = await safeStat(storePath); if (storeStat.ok) { const bits = modeBits(storeStat.mode); if (isWorldReadable(bits) || isGroupReadable(bits)) { findings.push({ checkId: "fs.sessions_store.perms_readable", severity: "warn", title: "sessions.json is readable by others", detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`, remediation: `chmod 600 ${storePath}`, }); } } } 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 st = await safeStat(logPath); if (st.ok) { const bits = modeBits(st.mode); if (isWorldReadable(bits) || isGroupReadable(bits)) { findings.push({ checkId: "fs.log_file.perms_readable", severity: "warn", title: "Log file is readable by others", detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`, remediation: `chmod 600 ${logPath}`, }); } } } } return findings; } function listGroupPolicyOpen(cfg: ClawdbotConfig): string[] { const out: string[] = []; const channels = cfg.channels as Record | undefined; if (!channels || typeof channels !== "object") return out; for (const [channelId, value] of Object.entries(channels)) { if (!value || typeof value !== "object") continue; const section = value as Record; 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; const acc = accountVal as Record; if (acc.groupPolicy === "open") out.push(`channels.${channelId}.accounts.${accountId}.groupPolicy`); } } } return out; } export function collectExposureMatrixFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const openGroups = listGroupPolicyOpen(cfg); if (openGroups.length === 0) return findings; const elevatedEnabled = cfg.tools?.elevated?.enabled !== false; if (elevatedEnabled) { 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")}\n` + "With 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; } export async function readConfigSnapshotForAudit(params: { env: NodeJS.ProcessEnv; configPath: string; }): Promise { return await createConfigIO({ env: params.env, configPath: params.configPath, }).readConfigFileSnapshot(); }