From f17c978f5c7ce78babd12081ded8b1346e854ed2 Mon Sep 17 00:00:00 2001 From: max <40643627+quotentiroler@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:22:29 -0800 Subject: [PATCH] refactor(security,config): split oversized files (#13182) refactor(security,config): split oversized files using dot-naming convention - audit-extra.ts (1,199 LOC) -> barrel (31) + sync (559) + async (668) - schema.ts (1,114 LOC) -> schema (353) + field-metadata (729) - Add tmp-refactoring-strategy.md documenting Wave 1-4 plan PR #13182 --- src/config/schema.field-metadata.ts | 738 +++++++++++++++ src/security/audit-extra.async.ts | 720 +++++++++++++++ src/security/audit-extra.sync.ts | 618 +++++++++++++ src/security/audit-extra.ts | 1335 +-------------------------- tmp-refactoring-strategy.md | 275 ++++++ 5 files changed, 2381 insertions(+), 1305 deletions(-) create mode 100644 src/config/schema.field-metadata.ts create mode 100644 src/security/audit-extra.async.ts create mode 100644 src/security/audit-extra.sync.ts create mode 100644 tmp-refactoring-strategy.md diff --git a/src/config/schema.field-metadata.ts b/src/config/schema.field-metadata.ts new file mode 100644 index 00000000000..96fdb5325f1 --- /dev/null +++ b/src/config/schema.field-metadata.ts @@ -0,0 +1,738 @@ +export const GROUP_LABELS: Record = { + wizard: "Wizard", + update: "Update", + diagnostics: "Diagnostics", + logging: "Logging", + gateway: "Gateway", + nodeHost: "Node Host", + agents: "Agents", + tools: "Tools", + bindings: "Bindings", + audio: "Audio", + models: "Models", + messages: "Messages", + commands: "Commands", + session: "Session", + cron: "Cron", + hooks: "Hooks", + ui: "UI", + browser: "Browser", + talk: "Talk", + channels: "Messaging Channels", + skills: "Skills", + plugins: "Plugins", + discovery: "Discovery", + presence: "Presence", + voicewake: "Voice Wake", +}; + +export const GROUP_ORDER: Record = { + wizard: 20, + update: 25, + diagnostics: 27, + gateway: 30, + nodeHost: 35, + agents: 40, + tools: 50, + bindings: 55, + audio: 60, + models: 70, + messages: 80, + commands: 85, + session: 90, + cron: 100, + hooks: 110, + ui: 120, + browser: 130, + talk: 140, + channels: 150, + skills: 200, + plugins: 205, + discovery: 210, + presence: 220, + voicewake: 230, + logging: 900, +}; + +export const FIELD_LABELS: Record = { + "meta.lastTouchedVersion": "Config Last Touched Version", + "meta.lastTouchedAt": "Config Last Touched At", + "update.channel": "Update Channel", + "update.checkOnStart": "Update Check on Start", + "diagnostics.enabled": "Diagnostics Enabled", + "diagnostics.flags": "Diagnostics Flags", + "diagnostics.otel.enabled": "OpenTelemetry Enabled", + "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", + "diagnostics.otel.protocol": "OpenTelemetry Protocol", + "diagnostics.otel.headers": "OpenTelemetry Headers", + "diagnostics.otel.serviceName": "OpenTelemetry Service Name", + "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", + "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", + "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", + "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", + "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", + "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", + "diagnostics.cacheTrace.filePath": "Cache Trace File Path", + "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", + "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", + "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", + "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.skills": "Agent Skill Filter", + "gateway.remote.url": "Remote Gateway URL", + "gateway.remote.sshTarget": "Remote Gateway SSH Target", + "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", + "gateway.remote.token": "Remote Gateway Token", + "gateway.remote.password": "Remote Gateway Password", + "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", + "gateway.auth.token": "Gateway Token", + "gateway.auth.password": "Gateway Password", + "tools.media.image.enabled": "Enable Image Understanding", + "tools.media.image.maxBytes": "Image Understanding Max Bytes", + "tools.media.image.maxChars": "Image Understanding Max Chars", + "tools.media.image.prompt": "Image Understanding Prompt", + "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", + "tools.media.image.attachments": "Image Understanding Attachment Policy", + "tools.media.image.models": "Image Understanding Models", + "tools.media.image.scope": "Image Understanding Scope", + "tools.media.models": "Media Understanding Shared Models", + "tools.media.concurrency": "Media Understanding Concurrency", + "tools.media.audio.enabled": "Enable Audio Understanding", + "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", + "tools.media.audio.maxChars": "Audio Understanding Max Chars", + "tools.media.audio.prompt": "Audio Understanding Prompt", + "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", + "tools.media.audio.language": "Audio Understanding Language", + "tools.media.audio.attachments": "Audio Understanding Attachment Policy", + "tools.media.audio.models": "Audio Understanding Models", + "tools.media.audio.scope": "Audio Understanding Scope", + "tools.media.video.enabled": "Enable Video Understanding", + "tools.media.video.maxBytes": "Video Understanding Max Bytes", + "tools.media.video.maxChars": "Video Understanding Max Chars", + "tools.media.video.prompt": "Video Understanding Prompt", + "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", + "tools.media.video.attachments": "Video Understanding Attachment Policy", + "tools.media.video.models": "Video Understanding Models", + "tools.media.video.scope": "Video Understanding Scope", + "tools.links.enabled": "Enable Link Understanding", + "tools.links.maxLinks": "Link Understanding Max Links", + "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", + "tools.links.models": "Link Understanding Models", + "tools.links.scope": "Link Understanding Scope", + "tools.profile": "Tool Profile", + "tools.alsoAllow": "Tool Allowlist Additions", + "agents.list[].tools.profile": "Agent Tool Profile", + "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", + "tools.byProvider": "Tool Policy by Provider", + "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", + "tools.exec.applyPatch.enabled": "Enable apply_patch", + "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", + "tools.exec.notifyOnExit": "Exec Notify On Exit", + "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", + "tools.exec.host": "Exec Host", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", + "tools.exec.node": "Exec Node Binding", + "tools.exec.pathPrepend": "Exec PATH Prepend", + "tools.exec.safeBins": "Exec Safe Bins", + "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", + "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", + "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", + "tools.message.crossContext.marker.enabled": "Cross-Context Marker", + "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", + "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", + "tools.message.broadcast.enabled": "Enable Message Broadcast", + "tools.web.search.enabled": "Enable Web Search Tool", + "tools.web.search.provider": "Web Search Provider", + "tools.web.search.apiKey": "Brave Search API Key", + "tools.web.search.maxResults": "Web Search Max Results", + "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", + "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", + "tools.web.fetch.enabled": "Enable Web Fetch Tool", + "tools.web.fetch.maxChars": "Web Fetch Max Chars", + "tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)", + "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", + "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", + "tools.web.fetch.userAgent": "Web Fetch User-Agent", + "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.root": "Control UI Assets Root", + "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", + "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", + "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", + "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", + "gateway.reload.mode": "Config Reload Mode", + "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.browser.mode": "Gateway Node Browser Mode", + "gateway.nodes.browser.node": "Gateway Node Browser Pin", + "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.denyCommands": "Gateway Node Denylist", + "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", + "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", + "skills.load.watch": "Watch Skills", + "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", + "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", + "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", + "agents.defaults.memorySearch": "Memory Search", + "agents.defaults.memorySearch.enabled": "Enable Memory Search", + "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Memory Search Session Index (Experimental)", + "agents.defaults.memorySearch.provider": "Memory Search Provider", + "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", + "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", + "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", + "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", + "agents.defaults.memorySearch.model": "Memory Search Model", + "agents.defaults.memorySearch.fallback": "Memory Search Fallback", + "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", + "agents.defaults.memorySearch.store.path": "Memory Search Index Path", + "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", + "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", + "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", + "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", + "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", + "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", + "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", + "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", + "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", + "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", + "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", + "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Memory Search Hybrid Candidate Multiplier", + "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", + "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", + memory: "Memory", + "memory.backend": "Memory Backend", + "memory.citations": "Memory Citations Mode", + "memory.qmd.command": "QMD Binary", + "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", + "memory.qmd.paths": "QMD Extra Paths", + "memory.qmd.paths.path": "QMD Path", + "memory.qmd.paths.pattern": "QMD Path Pattern", + "memory.qmd.paths.name": "QMD Path Name", + "memory.qmd.sessions.enabled": "QMD Session Indexing", + "memory.qmd.sessions.exportDir": "QMD Session Export Directory", + "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", + "memory.qmd.update.interval": "QMD Update Interval", + "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", + "memory.qmd.update.onBoot": "QMD Update on Startup", + "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", + "memory.qmd.update.embedInterval": "QMD Embed Interval", + "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", + "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", + "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", + "memory.qmd.limits.maxResults": "QMD Max Results", + "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", + "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", + "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", + "memory.qmd.scope": "QMD Surface Scope", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", + "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", + "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", + "auth.cooldowns.failureWindowHours": "Failover Window (hours)", + "agents.defaults.models": "Models", + "agents.defaults.model.primary": "Primary Model", + "agents.defaults.model.fallbacks": "Model Fallbacks", + "agents.defaults.imageModel.primary": "Image Model", + "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", + "agents.defaults.humanDelay.mode": "Human Delay Mode", + "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", + "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.cliBackends": "CLI Backends", + "commands.native": "Native Commands", + "commands.nativeSkills": "Native Skill Commands", + "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", + "commands.config": "Allow /config", + "commands.debug": "Allow /debug", + "commands.restart": "Allow Restart", + "commands.useAccessGroups": "Use Access Groups", + "commands.ownerAllowFrom": "Command Owners", + "commands.allowFrom": "Command Access Allowlist", + "ui.seamColor": "Accent Color", + "ui.assistant.name": "Assistant Name", + "ui.assistant.avatar": "Assistant Avatar", + "browser.evaluateEnabled": "Browser Evaluate Enabled", + "browser.snapshotDefaults": "Browser Snapshot Defaults", + "browser.snapshotDefaults.mode": "Browser Snapshot Mode", + "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", + "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", + "session.dmScope": "DM Session Scope", + "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", + "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", + "talk.apiKey": "Talk API Key", + "channels.whatsapp": "WhatsApp", + "channels.telegram": "Telegram", + "channels.telegram.customCommands": "Telegram Custom Commands", + "channels.discord": "Discord", + "channels.slack": "Slack", + "channels.mattermost": "Mattermost", + "channels.signal": "Signal", + "channels.imessage": "iMessage", + "channels.bluebubbles": "BlueBubbles", + "channels.msteams": "MS Teams", + "channels.telegram.botToken": "Telegram Bot Token", + "channels.telegram.dmPolicy": "Telegram DM Policy", + "channels.telegram.streamMode": "Telegram Draft Stream Mode", + "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", + "channels.telegram.retry.attempts": "Telegram Retry Attempts", + "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", + "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", + "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", + "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", + "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", + "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", + "channels.signal.dmPolicy": "Signal DM Policy", + "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.discord.dm.policy": "Discord DM Policy", + "channels.discord.retry.attempts": "Discord Retry Attempts", + "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", + "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", + "channels.discord.retry.jitter": "Discord Retry Jitter", + "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", + "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", + "channels.discord.pluralkit.token": "Discord PluralKit Token", + "channels.slack.dm.policy": "Slack DM Policy", + "channels.slack.allowBots": "Slack Allow Bot Messages", + "channels.discord.token": "Discord Bot Token", + "channels.slack.botToken": "Slack Bot Token", + "channels.slack.appToken": "Slack App Token", + "channels.slack.userToken": "Slack User Token", + "channels.slack.userTokenReadOnly": "Slack User Token Read Only", + "channels.slack.thread.historyScope": "Slack Thread History Scope", + "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", + "channels.mattermost.botToken": "Mattermost Bot Token", + "channels.mattermost.baseUrl": "Mattermost Base URL", + "channels.mattermost.chatmode": "Mattermost Chat Mode", + "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", + "channels.mattermost.requireMention": "Mattermost Require Mention", + "channels.signal.account": "Signal Account", + "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].skills": "Agent Skill Filter", + "agents.list[].identity.avatar": "Agent Avatar", + "discovery.mdns.mode": "mDNS Discovery Mode", + "plugins.enabled": "Enable Plugins", + "plugins.allow": "Plugin Allowlist", + "plugins.deny": "Plugin Denylist", + "plugins.load.paths": "Plugin Load Paths", + "plugins.slots": "Plugin Slots", + "plugins.slots.memory": "Memory Plugin", + "plugins.entries": "Plugin Entries", + "plugins.entries.*.enabled": "Plugin Enabled", + "plugins.entries.*.config": "Plugin Config", + "plugins.installs": "Plugin Install Records", + "plugins.installs.*.source": "Plugin Install Source", + "plugins.installs.*.spec": "Plugin Install Spec", + "plugins.installs.*.sourcePath": "Plugin Install Source Path", + "plugins.installs.*.installPath": "Plugin Install Path", + "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.installedAt": "Plugin Install Time", +}; + +export const FIELD_HELP: Record = { + "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", + "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", + "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', + "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", + "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", + "gateway.remote.tlsFingerprint": + "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", + "gateway.remote.sshTarget": + "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", + "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "agents.list.*.skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].identity.avatar": + "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "discovery.mdns.mode": + 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', + "gateway.auth.token": + "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", + "gateway.auth.password": "Required for Tailscale funnel.", + "gateway.controlUi.basePath": + "Optional URL prefix where the Control UI is served (e.g. /openclaw).", + "gateway.controlUi.root": + "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", + "gateway.controlUi.allowedOrigins": + "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", + "gateway.controlUi.allowInsecureAuth": + "Allow Control UI auth over insecure HTTP (token-only; not recommended).", + "gateway.controlUi.dangerouslyDisableDeviceAuth": + "DANGEROUS. Disable Control UI device identity checks (token/password only).", + "gateway.http.endpoints.chatCompletions.enabled": + "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", + "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', + "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.nodes.browser.mode": + 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', + "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", + "gateway.nodes.allowCommands": + "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", + "gateway.nodes.denyCommands": + "Commands to block even if present in node claims or default allowlist.", + "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", + "nodeHost.browserProxy.allowProfiles": + "Optional allowlist of browser profile names exposed via the node proxy.", + "diagnostics.flags": + 'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".', + "diagnostics.cacheTrace.enabled": + "Log cache trace snapshots for embedded agent runs (default: false).", + "diagnostics.cacheTrace.filePath": + "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", + "diagnostics.cacheTrace.includeMessages": + "Include full message payloads in trace output (default: true).", + "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", + "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", + "tools.exec.applyPatch.enabled": + "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", + "tools.exec.applyPatch.allowModels": + 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', + "tools.exec.notifyOnExit": + "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", + "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", + "tools.exec.safeBins": + "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.message.allowCrossContextSend": + "Legacy override: allow cross-context sends across all providers.", + "tools.message.crossContext.allowWithinProvider": + "Allow sends to other channels within the same provider (default: true).", + "tools.message.crossContext.allowAcrossProviders": + "Allow sends across different providers (default: false).", + "tools.message.crossContext.marker.enabled": + "Add a visible origin marker when sending cross-context (default: true).", + "tools.message.crossContext.marker.prefix": + 'Text prefix for cross-context markers (supports "{channel}").', + "tools.message.crossContext.marker.suffix": + 'Text suffix for cross-context markers (supports "{channel}").', + "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", + "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", + "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", + "tools.web.search.maxResults": "Default number of results to return (1-10).", + "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", + "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.perplexity.apiKey": + "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", + "tools.web.search.perplexity.baseUrl": + "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", + "tools.web.search.perplexity.model": + 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", + "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", + "tools.web.fetch.maxCharsCap": + "Hard cap for web_fetch maxChars (applies to config and tool calls).", + "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", + "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", + "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", + "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", + "tools.web.fetch.readability": + "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", + "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", + "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.fetch.firecrawl.baseUrl": + "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", + "tools.web.fetch.firecrawl.onlyMainContent": + "When true, Firecrawl returns only the main content (default: true).", + "tools.web.fetch.firecrawl.maxAgeMs": + "Firecrawl maxAge (ms) for cached results when supported by the API.", + "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", + "channels.slack.allowBots": + "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.slack.thread.historyScope": + 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', + "channels.slack.thread.inheritParent": + "If true, Slack thread sessions inherit the parent channel transcript (default: false).", + "channels.mattermost.botToken": + "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", + "channels.mattermost.baseUrl": + "Base URL for your Mattermost server (e.g., https://chat.example.com).", + "channels.mattermost.chatmode": + 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").', + "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).', + "channels.mattermost.requireMention": + "Require @mention in channels before responding (default: true).", + "auth.profiles": "Named auth profiles (provider + mode + optional email).", + "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", + "auth.cooldowns.billingBackoffHours": + "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", + "auth.cooldowns.billingBackoffHoursByProvider": + "Optional per-provider overrides for billing backoff (hours).", + "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", + "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", + "agents.defaults.bootstrapMaxChars": + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.repoRoot": + "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", + "agents.defaults.envelopeTimezone": + 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', + "agents.defaults.envelopeTimestamp": + 'Include absolute timestamps in message envelopes ("on" or "off").', + "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', + "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", + "agents.defaults.memorySearch": + "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", + "agents.defaults.memorySearch.sources": + 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', + "agents.defaults.memorySearch.extraPaths": + "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Enable experimental session transcript indexing for memory search (default: false).", + "agents.defaults.memorySearch.provider": + 'Embedding provider ("openai", "gemini", "voyage", or "local").', + "agents.defaults.memorySearch.remote.baseUrl": + "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", + "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", + "agents.defaults.memorySearch.remote.headers": + "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", + "agents.defaults.memorySearch.remote.batch.enabled": + "Enable batch API for memory embeddings (OpenAI/Gemini/Voyage; default: false).", + "agents.defaults.memorySearch.remote.batch.wait": + "Wait for batch completion when indexing (default: true).", + "agents.defaults.memorySearch.remote.batch.concurrency": + "Max concurrent embedding batch jobs for memory indexing (default: 2).", + "agents.defaults.memorySearch.remote.batch.pollIntervalMs": + "Polling interval in ms for batch status (default: 2000).", + "agents.defaults.memorySearch.remote.batch.timeoutMinutes": + "Timeout in minutes for batch indexing (default: 60).", + "agents.defaults.memorySearch.local.modelPath": + "Local GGUF model path or hf: URI (node-llama-cpp).", + "agents.defaults.memorySearch.fallback": + 'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").', + "agents.defaults.memorySearch.store.path": + "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", + "agents.defaults.memorySearch.store.vector.enabled": + "Enable sqlite-vec extension for vector search (default: true).", + "agents.defaults.memorySearch.store.vector.extensionPath": + "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", + "agents.defaults.memorySearch.query.hybrid.enabled": + "Enable hybrid BM25 + vector search for memory (default: true).", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": + "Weight for vector similarity when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.textWeight": + "Weight for BM25 text relevance when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Multiplier for candidate pool size (default: 4).", + "agents.defaults.memorySearch.cache.enabled": + "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", + memory: "Memory backend configuration (global).", + "memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).', + "memory.citations": 'Default citation behavior ("auto", "on", or "off").', + "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", + "memory.qmd.includeDefaultMemory": + "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", + "memory.qmd.paths": + "Additional directories/files to index with QMD (path + optional glob pattern).", + "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", + "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", + "memory.qmd.paths.name": + "Optional stable name for the QMD collection (default derived from path).", + "memory.qmd.sessions.enabled": + "Enable QMD session transcript indexing (experimental, default: false).", + "memory.qmd.sessions.exportDir": + "Override directory for sanitized session exports before indexing.", + "memory.qmd.sessions.retentionDays": + "Retention window for exported sessions before pruning (default: unlimited).", + "memory.qmd.update.interval": + "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", + "memory.qmd.update.debounceMs": + "Minimum delay between successive QMD refresh runs (default: 15000).", + "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", + "memory.qmd.update.waitForBootSync": + "Block startup until the boot QMD refresh finishes (default: false).", + "memory.qmd.update.embedInterval": + "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", + "memory.qmd.update.commandTimeoutMs": + "Timeout for QMD maintenance commands like collection list/add (default: 30000).", + "memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).", + "memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).", + "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", + "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", + "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", + "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", + "memory.qmd.scope": + "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", + "agents.defaults.memorySearch.cache.maxEntries": + "Optional cap on cached embeddings (best-effort).", + "agents.defaults.memorySearch.sync.onSearch": + "Lazy sync: schedule a reindex on search after changes.", + "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": + "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": + "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", + "plugins.enabled": "Enable plugin/extension loading (default: true).", + "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", + "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", + "plugins.load.paths": "Additional plugin files or directories to load.", + "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", + "plugins.slots.memory": + 'Select the active memory plugin by id, or "none" to disable memory plugins.', + "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", + "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", + "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", + "plugins.installs": + "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', + "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", + "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", + "plugins.installs.*.installPath": + "Resolved install directory (usually ~/.openclaw/extensions/).", + "plugins.installs.*.version": "Version recorded at install time (if available).", + "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", + "agents.list.*.identity.avatar": + "Agent avatar (workspace-relative path, http(s) URL, or data URI).", + "agents.defaults.model.primary": "Primary model (provider/model).", + "agents.defaults.model.fallbacks": + "Ordered fallback models (provider/model). Used when the primary model fails.", + "agents.defaults.imageModel.primary": + "Optional image model (provider/model) used when the primary model lacks image input.", + "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", + "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', + "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", + "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", + "commands.native": + "Register native commands with channels that support it (Discord/Slack/Telegram).", + "commands.nativeSkills": + "Register native skill commands (user-invocable skills) with channels that support it.", + "commands.text": "Allow text command parsing (slash commands only).", + "commands.bash": + "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", + "commands.bashForegroundMs": + "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", + "commands.config": "Allow /config chat command to read/write config on disk (default: false).", + "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", + "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", + "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "commands.ownerAllowFrom": + "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", + "commands.allowFrom": + 'Per-provider allowlist restricting who can use slash commands. If set, overrides the channel\'s allowFrom for command authorization. Use \'*\' key for global default; provider-specific keys (e.g. \'discord\') override the global. Example: { "*": ["user1"], "discord": ["user:123"] }.', + "session.dmScope": + 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', + "session.identityLinks": + "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", + "channels.telegram.configWrites": + "Allow Telegram to write config in response to channel events/commands (default: true).", + "channels.slack.configWrites": + "Allow Slack to write config in response to channel events/commands (default: true).", + "channels.mattermost.configWrites": + "Allow Mattermost to write config in response to channel events/commands (default: true).", + "channels.discord.configWrites": + "Allow Discord to write config in response to channel events/commands (default: true).", + "channels.whatsapp.configWrites": + "Allow WhatsApp to write config in response to channel events/commands (default: true).", + "channels.signal.configWrites": + "Allow Signal to write config in response to channel events/commands (default: true).", + "channels.imessage.configWrites": + "Allow iMessage to write config in response to channel events/commands (default: true).", + "channels.msteams.configWrites": + "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", + "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', + "channels.discord.commands.nativeSkills": + 'Override native skill commands for Discord (bool or "auto").', + "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', + "channels.telegram.commands.nativeSkills": + 'Override native skill commands for Telegram (bool or "auto").', + "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', + "channels.slack.commands.nativeSkills": + 'Override native skill commands for Slack (bool or "auto").', + "session.agentToAgent.maxPingPongTurns": + "Max reply-back turns between requester and target (0–5).", + "channels.telegram.customCommands": + "Additional Telegram bot menu commands (merged with native; conflicts ignored).", + "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "messages.inbound.debounceMs": + "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", + "channels.telegram.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', + "channels.telegram.streamMode": + "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", + "channels.telegram.draftChunk.minChars": + 'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).', + "channels.telegram.draftChunk.maxChars": + 'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).', + "channels.telegram.draftChunk.breakPreference": + "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", + "channels.telegram.retry.attempts": + "Max retry attempts for outbound Telegram API calls (default: 3).", + "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", + "channels.telegram.retry.maxDelayMs": + "Maximum retry delay cap in ms for Telegram outbound calls.", + "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.network.autoSelectFamily": + "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", + "channels.telegram.timeoutSeconds": + "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.whatsapp.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', + "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", + "channels.whatsapp.debounceMs": + "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", + "channels.signal.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', + "channels.imessage.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', + "channels.bluebubbles.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', + "channels.discord.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', + "channels.discord.retry.attempts": + "Max retry attempts for outbound Discord API calls (default: 3).", + "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", + "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", + "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", + "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.intents.presence": + "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", + "channels.discord.intents.guildMembers": + "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", + "channels.discord.pluralkit.enabled": + "Resolve PluralKit proxied messages and treat system members as distinct senders.", + "channels.discord.pluralkit.token": + "Optional PluralKit token for resolving private systems or members.", + "channels.slack.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', +}; + +export const FIELD_PLACEHOLDERS: Record = { + "gateway.remote.url": "ws://host:18789", + "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", + "gateway.remote.sshTarget": "user@host", + "gateway.controlUi.basePath": "/openclaw", + "gateway.controlUi.root": "dist/control-ui", + "gateway.controlUi.allowedOrigins": "https://control.example.com", + "channels.mattermost.baseUrl": "https://chat.example.com", + "agents.list[].identity.avatar": "avatars/openclaw.png", +}; + +export const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; + +export function isSensitivePath(path: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +} diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts new file mode 100644 index 00000000000..a20edd6dcde --- /dev/null +++ b/src/security/audit-extra.async.ts @@ -0,0 +1,720 @@ +/** + * Asynchronous security audit collector functions. + * + * These functions perform I/O (filesystem, config reads) to detect security issues. + */ +import JSON5 from "json5"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; +import type { ExecFn } from "./windows-acl.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { loadWorkspaceSkillEntries } from "../agents/skills.js"; +import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { resolveNativeSkillsEnabled } from "../config/commands.js"; +import { createConfigIO } from "../config/config.js"; +import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { + formatPermissionDetail, + formatPermissionRemediation, + inspectPathPermissions, + safeStat, +} from "./audit-fs.js"; +import { scanDirectoryWithSummary, type SkillScanFinding } from "./skill-scanner.js"; + +export type SecurityAuditFinding = { + checkId: string; + severity: "info" | "warn" | "critical"; + title: string; + detail: string; + remediation?: string; +}; + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +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 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); + } 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; +} + +function isPathInside(basePath: string, candidatePath: string): boolean { + const base = path.resolve(basePath); + const candidate = path.resolve(candidatePath); + const rel = path.relative(base, candidate); + return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel)); +} + +function extensionUsesSkippedScannerPath(entry: string): boolean { + const segments = entry.split(/[\\/]+/).filter(Boolean); + return segments.some( + (segment) => + segment === "node_modules" || + (segment.startsWith(".") && segment !== "." && segment !== ".."), + ); +} + +async function readPluginManifestExtensions(pluginPath: string): Promise { + const manifestPath = path.join(pluginPath, "package.json"); + const raw = await fs.readFile(manifestPath, "utf-8").catch(() => ""); + if (!raw.trim()) { + return []; + } + + const parsed = JSON.parse(raw) as Partial< + Record + > | null; + const extensions = parsed?.[MANIFEST_KEY]?.extensions; + if (!Array.isArray(extensions)) { + return []; + } + return extensions.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); +} + +function listWorkspaceDirs(cfg: OpenClawConfig): string[] { + const dirs = new Set(); + const list = cfg.agents?.list; + if (Array.isArray(list)) { + for (const entry of list) { + if (entry && typeof entry === "object" && typeof entry.id === "string") { + dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); + } + } + } + dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); + return [...dirs]; +} + +function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): string { + return findings + .map((finding) => { + const relPath = path.relative(rootDir, finding.file); + const filePath = + relPath && relPath !== "." && !relPath.startsWith("..") + ? relPath + : path.basename(finding.file); + const normalizedPath = filePath.replaceAll("\\", "/"); + return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`; + }) + .join("\n"); +} + +// -------------------------------------------------------------------------- +// Exported collectors +// -------------------------------------------------------------------------- + +export async function collectPluginsTrustFindings(params: { + cfg: OpenClawConfig; + 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; +} + +export async function collectIncludeFilePermFindings(params: { + configSnapshot: ConfigFileSnapshot; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + execIcacls?: ExecFn; +}): 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 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: 0o600, + 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: 0o600, + 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: 0o600, + env: params.env, + }), + }); + } + } + + return findings; +} + +export async function collectStateDeepFilesystemFindings(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + platform?: NodeJS.Platform; + execIcacls?: ExecFn; +}): Promise { + const findings: SecurityAuditFinding[] = []; + 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: 0o700, + 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: 0o700, + 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"); + // eslint-disable-next-line no-await-in-loop + 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: 0o600, + 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: 0o600, + env: params.env, + }), + }); + } + } + + const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); + // eslint-disable-next-line no-await-in-loop + 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: 0o600, + 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: 0o600, + env: params.env, + }), + }); + } + } + } + } + + return findings; +} + +export async function readConfigSnapshotForAudit(params: { + env: NodeJS.ProcessEnv; + configPath: string; +}): Promise { + return await createConfigIO({ + env: params.env, + configPath: params.configPath, + }).readConfigFileSnapshot(); +} + +export async function collectPluginsCodeSafetyFindings(params: { + 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((err) => { + findings.push({ + checkId: "plugins.code_safety.scan_failed", + severity: "warn", + title: "Plugin extensions directory scan failed", + detail: `Static code scan could not list extensions directory: ${String(err)}`, + remediation: + "Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.", + }); + return []; + }); + const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + + for (const pluginName of pluginDirs) { + const pluginPath = path.join(extensionsDir, pluginName); + const extensionEntries = await readPluginManifestExtensions(pluginPath).catch(() => []); + const forcedScanEntries: string[] = []; + const escapedEntries: string[] = []; + + for (const entry of extensionEntries) { + const resolvedEntry = path.resolve(pluginPath, entry); + if (!isPathInside(pluginPath, resolvedEntry)) { + escapedEntries.push(entry); + continue; + } + if (extensionUsesSkippedScannerPath(entry)) { + findings.push({ + checkId: "plugins.code_safety.entry_path", + severity: "warn", + title: `Plugin "${pluginName}" entry path is hidden or node_modules`, + detail: `Extension entry "${entry}" points to a hidden or node_modules path. Deep code scan will cover this entry explicitly, but review this path choice carefully.`, + remediation: "Prefer extension entrypoints under normal source paths like dist/ or src/.", + }); + } + forcedScanEntries.push(resolvedEntry); + } + + if (escapedEntries.length > 0) { + findings.push({ + checkId: "plugins.code_safety.entry_escape", + severity: "critical", + title: `Plugin "${pluginName}" has extension entry path traversal`, + detail: `Found extension entries that escape the plugin directory:\n${escapedEntries.map((entry) => ` - ${entry}`).join("\n")}`, + remediation: + "Update the plugin manifest so all openclaw.extensions entries stay inside the plugin directory.", + }); + } + + const summary = await scanDirectoryWithSummary(pluginPath, { + includeFiles: forcedScanEntries, + }).catch((err) => { + findings.push({ + checkId: "plugins.code_safety.scan_failed", + severity: "warn", + title: `Plugin "${pluginName}" code scan failed`, + detail: `Static code scan could not complete: ${String(err)}`, + remediation: + "Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.", + }); + return null; + }); + if (!summary) { + continue; + } + + if (summary.critical > 0) { + const criticalFindings = summary.findings.filter((f) => f.severity === "critical"); + const details = formatCodeSafetyDetails(criticalFindings, pluginPath); + + findings.push({ + checkId: "plugins.code_safety", + severity: "critical", + title: `Plugin "${pluginName}" contains dangerous code patterns`, + detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s):\n${details}`, + remediation: + "Review the plugin source code carefully before use. If untrusted, remove the plugin from your OpenClaw extensions state directory.", + }); + } else if (summary.warn > 0) { + const warnFindings = summary.findings.filter((f) => f.severity === "warn"); + const details = formatCodeSafetyDetails(warnFindings, pluginPath); + + findings.push({ + checkId: "plugins.code_safety", + severity: "warn", + title: `Plugin "${pluginName}" contains suspicious code patterns`, + detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s):\n${details}`, + remediation: `Review the flagged code to ensure it is intentional and safe.`, + }); + } + } + + return findings; +} + +export async function collectInstalledSkillsCodeSafetyFindings(params: { + cfg: OpenClawConfig; + stateDir: string; +}): Promise { + const findings: SecurityAuditFinding[] = []; + const pluginExtensionsDir = path.join(params.stateDir, "extensions"); + const scannedSkillDirs = new Set(); + const workspaceDirs = listWorkspaceDirs(params.cfg); + + for (const workspaceDir of workspaceDirs) { + const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); + for (const entry of entries) { + if (entry.skill.source === "openclaw-bundled") { + continue; + } + + const skillDir = path.resolve(entry.skill.baseDir); + if (isPathInside(pluginExtensionsDir, skillDir)) { + // Plugin code is already covered by plugins.code_safety checks. + continue; + } + if (scannedSkillDirs.has(skillDir)) { + continue; + } + scannedSkillDirs.add(skillDir); + + const skillName = entry.skill.name; + const summary = await scanDirectoryWithSummary(skillDir).catch((err) => { + findings.push({ + checkId: "skills.code_safety.scan_failed", + severity: "warn", + title: `Skill "${skillName}" code scan failed`, + detail: `Static code scan could not complete for ${skillDir}: ${String(err)}`, + remediation: + "Check file permissions and skill layout, then rerun `openclaw security audit --deep`.", + }); + return null; + }); + if (!summary) { + continue; + } + + if (summary.critical > 0) { + const criticalFindings = summary.findings.filter( + (finding) => finding.severity === "critical", + ); + const details = formatCodeSafetyDetails(criticalFindings, skillDir); + findings.push({ + checkId: "skills.code_safety", + severity: "critical", + title: `Skill "${skillName}" contains dangerous code patterns`, + detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`, + remediation: `Review the skill source code before use. If untrusted, remove "${skillDir}".`, + }); + } else if (summary.warn > 0) { + const warnFindings = summary.findings.filter((finding) => finding.severity === "warn"); + const details = formatCodeSafetyDetails(warnFindings, skillDir); + findings.push({ + checkId: "skills.code_safety", + severity: "warn", + title: `Skill "${skillName}" contains suspicious code patterns`, + detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`, + remediation: "Review flagged lines to ensure the behavior is intentional and safe.", + }); + } + } + } + + return findings; +} diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts new file mode 100644 index 00000000000..0cb9fab21c4 --- /dev/null +++ b/src/security/audit-extra.sync.ts @@ -0,0 +1,618 @@ +/** + * Synchronous security audit collector functions. + * + * These functions analyze config-based security properties without I/O. + */ +import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; +import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; +import { + resolveSandboxConfigForAgent, + resolveSandboxToolPolicyForAgent, +} from "../agents/sandbox.js"; +import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; +import { resolveBrowserConfig } from "../browser/config.js"; +import { formatCliCommand } from "../cli/command-format.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; + +export type SecurityAuditFinding = { + checkId: string; + severity: "info" | "warn" | "critical"; + title: string; + detail: string; + remediation?: string; +}; + +const SMALL_MODEL_PARAM_B_MAX = 300; + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +function summarizeGroupPolicy(cfg: OpenClawConfig): { + 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 }; +} + +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") + ); +} + +function looksLikeEnvRef(value: string): boolean { + const v = value.trim(); + return v.startsWith("${") && v.endsWith("}"); +} + +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: OpenClawConfig): 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 { + // Match claude-*-4-5+, claude-*-45+, claude-*4.5+, or future 5.x+ majors. + return /\bclaude-[^\s/]*?(?:-4-?(?:[5-9]|[1-9]\d)\b|4\.(?:[5-9]|[1-9]\d)\b|-[5-9](?:\b|[.-]))/i.test( + id, + ); +} + +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: OpenClawConfig; + 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: OpenClawConfig, 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: OpenClawConfig, 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: OpenClawConfig): boolean { + const enabled = cfg.tools?.web?.fetch?.enabled; + if (enabled === false) { + return false; + } + return true; +} + +function isBrowserEnabled(cfg: OpenClawConfig): boolean { + try { + return resolveBrowserConfig(cfg.browser, cfg).enabled; + } catch { + return true; + } +} + +function listGroupPolicyOpen(cfg: OpenClawConfig): 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; +} + +// -------------------------------------------------------------------------- +// Exported collectors +// -------------------------------------------------------------------------- + +export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const group = summarizeGroupPolicy(cfg); + const elevated = cfg.tools?.elevated?.enabled !== false; + const hooksEnabled = cfg.hooks?.enabled === true; + const browserEnabled = cfg.browser?.enabled ?? true; + + 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, + }, + ]; +} + +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 OPENCLAW_STATE_DIR on a local-only volume and re-run "${formatCliCommand("openclaw security audit --fix")}".`, + }); + } + return findings; +} + +export function collectSecretsInConfigFindings(cfg: OpenClawConfig): 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 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; +} + +export function collectHooksHardeningFindings(cfg: OpenClawConfig): 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 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; +} + +export function collectModelHygieneFindings(cfg: OpenClawConfig): 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; +} + +export function collectSmallModelRiskFindings(params: { + cfg: OpenClawConfig; + 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 function collectExposureMatrixFindings(cfg: OpenClawConfig): 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; +} diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 9688374d1c5..634c51cbdb4 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -1,1305 +1,30 @@ -import JSON5 from "json5"; -import fs from "node:fs/promises"; -import path from "node:path"; -import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; -import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; -import type { AgentToolsConfig } from "../config/types.tools.js"; -import type { ExecFn } from "./windows-acl.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxToolPolicyForAgent, -} from "../agents/sandbox.js"; -import { loadWorkspaceSkillEntries } from "../agents/skills.js"; -import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; -import { resolveBrowserConfig } from "../browser/config.js"; -import { formatCliCommand } from "../cli/command-format.js"; -import { MANIFEST_KEY } from "../compat/legacy-names.js"; -import { resolveNativeSkillsEnabled } from "../config/commands.js"; -import { createConfigIO } from "../config/config.js"; -import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js"; -import { resolveOAuthDir } from "../config/paths.js"; -import { resolveGatewayAuth } from "../gateway/auth.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { - formatPermissionDetail, - formatPermissionRemediation, - inspectPathPermissions, - safeStat, -} from "./audit-fs.js"; -import { scanDirectoryWithSummary, type SkillScanFinding } from "./skill-scanner.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: OpenClawConfig): { - 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: OpenClawConfig): SecurityAuditFinding[] { - const group = summarizeGroupPolicy(cfg); - const elevated = cfg.tools?.elevated?.enabled !== false; - const hooksEnabled = cfg.hooks?.enabled === true; - const browserEnabled = cfg.browser?.enabled ?? true; - - 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 OPENCLAW_STATE_DIR on a local-only volume and re-run "${formatCliCommand("openclaw security audit --fix")}".`, - }); - } - return findings; -} - -function looksLikeEnvRef(value: string): boolean { - const v = value.trim(); - return v.startsWith("${") && v.endsWith("}"); -} - -export function collectSecretsInConfigFindings(cfg: OpenClawConfig): 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 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; -} - -export function collectHooksHardeningFindings(cfg: OpenClawConfig): 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 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: OpenClawConfig): 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 { - // Match claude-*-4-5+, claude-*-45+, claude-*4.5+, or future 5.x+ majors. - // Examples that should match: - // claude-opus-4-5, claude-opus-4-6, claude-opus-45, claude-4.6, claude-sonnet-5 - return /\bclaude-[^\s/]*?(?:-4-?(?:[5-9]|[1-9]\d)\b|4\.(?:[5-9]|[1-9]\d)\b|-[5-9](?:\b|[.-]))/i.test( - id, - ); -} - -export function collectModelHygieneFindings(cfg: OpenClawConfig): 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: OpenClawConfig; - 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: OpenClawConfig, 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: OpenClawConfig, 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: OpenClawConfig): boolean { - const enabled = cfg.tools?.web?.fetch?.enabled; - if (enabled === false) { - return false; - } - return true; -} - -function isBrowserEnabled(cfg: OpenClawConfig): boolean { - try { - return resolveBrowserConfig(cfg.browser, cfg).enabled; - } catch { - return true; - } -} - -export function collectSmallModelRiskFindings(params: { - cfg: OpenClawConfig; - 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: OpenClawConfig; - 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); - } 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; - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - execIcacls?: ExecFn; -}): 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 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: 0o600, - 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: 0o600, - 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: 0o600, - env: params.env, - }), - }); - } - } - - return findings; -} - -export async function collectStateDeepFilesystemFindings(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - stateDir: string; - platform?: NodeJS.Platform; - execIcacls?: ExecFn; -}): Promise { - const findings: SecurityAuditFinding[] = []; - 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: 0o700, - 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: 0o700, - 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"); - // eslint-disable-next-line no-await-in-loop - 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: 0o600, - 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: 0o600, - env: params.env, - }), - }); - } - } - - const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); - // eslint-disable-next-line no-await-in-loop - 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: 0o600, - 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: 0o600, - env: params.env, - }), - }); - } - } - } - } - - return findings; -} - -function listGroupPolicyOpen(cfg: OpenClawConfig): 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: OpenClawConfig): 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(); -} - -function isPathInside(basePath: string, candidatePath: string): boolean { - const base = path.resolve(basePath); - const candidate = path.resolve(candidatePath); - const rel = path.relative(base, candidate); - return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel)); -} - -function extensionUsesSkippedScannerPath(entry: string): boolean { - const segments = entry.split(/[\\/]+/).filter(Boolean); - return segments.some( - (segment) => - segment === "node_modules" || - (segment.startsWith(".") && segment !== "." && segment !== ".."), - ); -} - -async function readPluginManifestExtensions(pluginPath: string): Promise { - const manifestPath = path.join(pluginPath, "package.json"); - const raw = await fs.readFile(manifestPath, "utf-8").catch(() => ""); - if (!raw.trim()) { - return []; - } - - const parsed = JSON.parse(raw) as Partial< - Record - > | null; - const extensions = parsed?.[MANIFEST_KEY]?.extensions; - if (!Array.isArray(extensions)) { - return []; - } - return extensions.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - -function listWorkspaceDirs(cfg: OpenClawConfig): string[] { - const dirs = new Set(); - const list = cfg.agents?.list; - if (Array.isArray(list)) { - for (const entry of list) { - if (entry && typeof entry === "object" && typeof entry.id === "string") { - dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); - } - } - } - dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); - return [...dirs]; -} - -function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): string { - return findings - .map((finding) => { - const relPath = path.relative(rootDir, finding.file); - const filePath = - relPath && relPath !== "." && !relPath.startsWith("..") - ? relPath - : path.basename(finding.file); - const normalizedPath = filePath.replaceAll("\\", "/"); - return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`; - }) - .join("\n"); -} - -export async function collectPluginsCodeSafetyFindings(params: { - 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((err) => { - findings.push({ - checkId: "plugins.code_safety.scan_failed", - severity: "warn", - title: "Plugin extensions directory scan failed", - detail: `Static code scan could not list extensions directory: ${String(err)}`, - remediation: - "Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.", - }); - return []; - }); - const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); - - for (const pluginName of pluginDirs) { - const pluginPath = path.join(extensionsDir, pluginName); - const extensionEntries = await readPluginManifestExtensions(pluginPath).catch(() => []); - const forcedScanEntries: string[] = []; - const escapedEntries: string[] = []; - - for (const entry of extensionEntries) { - const resolvedEntry = path.resolve(pluginPath, entry); - if (!isPathInside(pluginPath, resolvedEntry)) { - escapedEntries.push(entry); - continue; - } - if (extensionUsesSkippedScannerPath(entry)) { - findings.push({ - checkId: "plugins.code_safety.entry_path", - severity: "warn", - title: `Plugin "${pluginName}" entry path is hidden or node_modules`, - detail: `Extension entry "${entry}" points to a hidden or node_modules path. Deep code scan will cover this entry explicitly, but review this path choice carefully.`, - remediation: "Prefer extension entrypoints under normal source paths like dist/ or src/.", - }); - } - forcedScanEntries.push(resolvedEntry); - } - - if (escapedEntries.length > 0) { - findings.push({ - checkId: "plugins.code_safety.entry_escape", - severity: "critical", - title: `Plugin "${pluginName}" has extension entry path traversal`, - detail: `Found extension entries that escape the plugin directory:\n${escapedEntries.map((entry) => ` - ${entry}`).join("\n")}`, - remediation: - "Update the plugin manifest so all openclaw.extensions entries stay inside the plugin directory.", - }); - } - - const summary = await scanDirectoryWithSummary(pluginPath, { - includeFiles: forcedScanEntries, - }).catch((err) => { - findings.push({ - checkId: "plugins.code_safety.scan_failed", - severity: "warn", - title: `Plugin "${pluginName}" code scan failed`, - detail: `Static code scan could not complete: ${String(err)}`, - remediation: - "Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.", - }); - return null; - }); - if (!summary) { - continue; - } - - if (summary.critical > 0) { - const criticalFindings = summary.findings.filter((f) => f.severity === "critical"); - const details = formatCodeSafetyDetails(criticalFindings, pluginPath); - - findings.push({ - checkId: "plugins.code_safety", - severity: "critical", - title: `Plugin "${pluginName}" contains dangerous code patterns`, - detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s):\n${details}`, - remediation: - "Review the plugin source code carefully before use. If untrusted, remove the plugin from your OpenClaw extensions state directory.", - }); - } else if (summary.warn > 0) { - const warnFindings = summary.findings.filter((f) => f.severity === "warn"); - const details = formatCodeSafetyDetails(warnFindings, pluginPath); - - findings.push({ - checkId: "plugins.code_safety", - severity: "warn", - title: `Plugin "${pluginName}" contains suspicious code patterns`, - detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s):\n${details}`, - remediation: `Review the flagged code to ensure it is intentional and safe.`, - }); - } - } - - return findings; -} - -export async function collectInstalledSkillsCodeSafetyFindings(params: { - cfg: OpenClawConfig; - stateDir: string; -}): Promise { - const findings: SecurityAuditFinding[] = []; - const pluginExtensionsDir = path.join(params.stateDir, "extensions"); - const scannedSkillDirs = new Set(); - const workspaceDirs = listWorkspaceDirs(params.cfg); - - for (const workspaceDir of workspaceDirs) { - const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); - for (const entry of entries) { - if (entry.skill.source === "openclaw-bundled") { - continue; - } - - const skillDir = path.resolve(entry.skill.baseDir); - if (isPathInside(pluginExtensionsDir, skillDir)) { - // Plugin code is already covered by plugins.code_safety checks. - continue; - } - if (scannedSkillDirs.has(skillDir)) { - continue; - } - scannedSkillDirs.add(skillDir); - - const skillName = entry.skill.name; - const summary = await scanDirectoryWithSummary(skillDir).catch((err) => { - findings.push({ - checkId: "skills.code_safety.scan_failed", - severity: "warn", - title: `Skill "${skillName}" code scan failed`, - detail: `Static code scan could not complete for ${skillDir}: ${String(err)}`, - remediation: - "Check file permissions and skill layout, then rerun `openclaw security audit --deep`.", - }); - return null; - }); - if (!summary) { - continue; - } - - if (summary.critical > 0) { - const criticalFindings = summary.findings.filter( - (finding) => finding.severity === "critical", - ); - const details = formatCodeSafetyDetails(criticalFindings, skillDir); - findings.push({ - checkId: "skills.code_safety", - severity: "critical", - title: `Skill "${skillName}" contains dangerous code patterns`, - detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`, - remediation: `Review the skill source code before use. If untrusted, remove "${skillDir}".`, - }); - } else if (summary.warn > 0) { - const warnFindings = summary.findings.filter((finding) => finding.severity === "warn"); - const details = formatCodeSafetyDetails(warnFindings, skillDir); - findings.push({ - checkId: "skills.code_safety", - severity: "warn", - title: `Skill "${skillName}" contains suspicious code patterns`, - detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`, - remediation: "Review flagged lines to ensure the behavior is intentional and safe.", - }); - } - } - } - - return findings; -} +/** + * Re-export barrel for security audit collector functions. + * + * Maintains backward compatibility with existing imports from audit-extra. + * Implementation split into: + * - audit-extra.sync.ts: Config-based checks (no I/O) + * - audit-extra.async.ts: Filesystem/plugin checks (async I/O) + */ + +// Sync collectors +export { + collectAttackSurfaceSummaryFindings, + collectExposureMatrixFindings, + collectHooksHardeningFindings, + collectModelHygieneFindings, + collectSecretsInConfigFindings, + collectSmallModelRiskFindings, + collectSyncedFolderFindings, + type SecurityAuditFinding, +} from "./audit-extra.sync.js"; + +// Async collectors +export { + collectIncludeFilePermFindings, + collectInstalledSkillsCodeSafetyFindings, + collectPluginsCodeSafetyFindings, + collectPluginsTrustFindings, + collectStateDeepFilesystemFindings, + readConfigSnapshotForAudit, +} from "./audit-extra.async.js"; diff --git a/tmp-refactoring-strategy.md b/tmp-refactoring-strategy.md new file mode 100644 index 00000000000..d2b19ce93bb --- /dev/null +++ b/tmp-refactoring-strategy.md @@ -0,0 +1,275 @@ +# Refactoring Strategy — Oversized Files + +> **Target:** ~500–700 LOC per file (AGENTS.md guideline) +> **Baseline:** 681K total lines across 3,781 code files (avg 180 LOC) +> **Problem:** 50+ files exceed 700 LOC; top offenders are 2–4× over target + +--- + +## Progress Summary + +| Item | Before | After | Status | +| ----------------------------------- | ------ | ------------------------------------ | ------- | +| `src/config/schema.ts` | 1,114 | 353 + 729 (field-metadata) | ✅ Done | +| `src/security/audit-extra.ts` | 1,199 | 31 barrel + 559 (sync) + 668 (async) | ✅ Done | +| `src/infra/session-cost-usage.ts` | 984 | — | Pending | +| `src/media-understanding/runner.ts` | 1,232 | — | Pending | + +### All Targets (current LOC) + +| Phase | File | Current LOC | Target | +| ----- | -------------------------------- | ----------- | ------ | +| 1 | session-cost-usage.ts | 984 | ~700 | +| 1 | media-understanding/runner.ts | 1,232 | ~700 | +| 2a | heartbeat-runner.ts | 956 | ~560 | +| 2a | message-action-runner.ts | 1,082 | ~620 | +| 2b | tts/tts.ts | 1,445 | ~950 | +| 2b | exec-approvals.ts | 1,437 | ~700 | +| 2b | update-cli.ts | 1,245 | ~1,000 | +| 3 | memory/manager.ts | 2,280 | ~1,300 | +| 3 | bash-tools.exec.ts | 1,546 | ~1,000 | +| 3 | ws-connection/message-handler.ts | 970 | ~720 | +| 4 | ui/views/usage.ts | 3,076 | ~1,200 | +| 4 | ui/views/agents.ts | 1,894 | ~950 | +| 4 | ui/views/nodes.ts | 1,118 | ~440 | +| 4 | bluebubbles/monitor.ts | 2,348 | ~650 | + +--- + +## Naming Convention (Established Pattern) + +The codebase uses **dot-separated module decomposition**: `..ts` + +**Examples from codebase:** + +- `provider-usage.ts` → `provider-usage.types.ts`, `provider-usage.fetch.ts`, `provider-usage.shared.ts` +- `zod-schema.ts` → `zod-schema.core.ts`, `zod-schema.agents.ts`, `zod-schema.session.ts` +- `directive-handling.ts` → `directive-handling.parse.ts`, `directive-handling.impl.ts`, `directive-handling.shared.ts` + +**Pattern:** + +- `.ts` — main barrel, re-exports public API +- `.types.ts` — type definitions +- `.shared.ts` — shared constants/utilities +- `..ts` — domain-specific implementations + +**Consequences for this refactoring:** + +- ✅ Renamed: `audit-collectors-sync.ts` → `audit-extra.sync.ts`, `audit-collectors-async.ts` → `audit-extra.async.ts` +- Use `session-cost-usage.types.ts` (not `session-cost-types.ts`) +- Use `runner.binary.ts` (not `binary-resolve.ts`) + +--- + +## Triage: What NOT to split + +| File | LOC | Reason to skip | +| ---------------------------------------------- | ----- | -------------------------------------------------------------------------------- | +| `ui/src/ui/views/usageStyles.ts` | 1,911 | Pure CSS-in-JS data. Zero logic. | +| `apps/macos/.../GatewayModels.swift` | 2,790 | Generated/shared protocol models. Splitting fragments the schema. | +| `apps/shared/.../GatewayModels.swift` | 2,790 | Same — shared protocol definitions. | +| `*.test.ts` files (bot.test, audit.test, etc.) | 1K–3K | Tests naturally grow with the module. Split only if parallel execution needs it. | +| `ui/src/ui/app-render.ts` | 1,222 | Mechanical prop-wiring glue. Large but low complexity. Optional. | + +--- + +## Phase 1 — Low-Risk, High-Impact (Pure Data / Independent Functions) + +These files contain cleanly separable sections with no shared mutable state. Each extraction is a straightforward "move functions + update imports" operation. + +### 1. ✅ `src/config/schema.ts` (1,114 → 353 LOC) — DONE + +| Extract to | What moves | LOC | +| --------------------------------- | ------------------------------------------------------------------- | --- | +| `config/schema.field-metadata.ts` | `FIELD_LABELS`, `FIELD_HELP`, `FIELD_PLACEHOLDERS`, sensitivity map | 729 | + +**Result:** schema.ts reduced to 353 LOC. Field metadata extracted to schema.field-metadata.ts (729 LOC). + +### 2. ✅ `src/security/audit-extra.ts` (1,199 → 31 LOC barrel) — DONE + +| Extract to | What moves | LOC | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | +| `security/audit-extra.sync.ts` | 7 sync collectors (config-based, no I/O): attack surface, synced folders, secrets, hooks, model hygiene, small model risk, exposure matrix | 559 | +| `security/audit-extra.async.ts` | 6 async collectors (filesystem/plugin checks): plugins trust, include perms, deep filesystem, config snapshot, plugins code safety, skills code safety | 668 | + +**Result:** Used centralized sync vs. async split (2 files) instead of domain scatter (3 files). audit-extra.ts is now a 31-line re-export barrel for backward compatibility. Files renamed to follow `..ts` convention. + +### 3. `src/infra/session-cost-usage.ts` (984 → ~700 LOC) + +| Extract to | What moves | LOC | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| `infra/session-cost-usage.types.ts` | 20+ exported type definitions | ~130 | +| `infra/session-cost-usage.parsers.ts` | `emptyTotals`, `toFiniteNumber`, `extractCostBreakdown`, `parseTimestamp`, `parseTranscriptEntry`, `formatDayKey`, `computeLatencyStats`, `apply*` helpers, `scan*File` helpers | ~240 | + +**Why:** Types + pure parser functions. Zero side effects. Consumers just import them. + +### 4. `src/media-understanding/runner.ts` (1,232 → ~700 LOC) + +| Extract to | What moves | LOC | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---- | +| `media-understanding/runner.binary.ts` | `findBinary`, `hasBinary`, `isExecutable`, `candidateBinaryNames` + caching | ~150 | +| `media-understanding/runner.cli.ts` | `extractGeminiResponse`, `extractSherpaOnnxText`, `probeGeminiCli`, `resolveCliOutput` | ~200 | +| `media-understanding/runner.entry.ts` | local entry resolvers, `resolveAutoEntries`, `resolveAutoImageModel`, `resolveActiveModelEntry`, `resolveKeyEntry` | ~250 | + +**Why:** Three clean layers (binary discovery → CLI output parsing → entry resolution). One-way dependency flow. + +--- + +## Phase 2 — Medium-Risk, Clean Boundaries + +These require converting private methods or closure variables to explicit parameters, but the seams are well-defined. + +### 5. `src/infra/heartbeat-runner.ts` (956 → ~560 LOC) + +| Extract to | What moves | LOC | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---- | +| `infra/heartbeat-runner.config.ts` | Active hours logic, config/agent/session resolution, `resolveHeartbeat*` helpers, `isHeartbeatEnabledForAgent` | ~370 | +| `infra/heartbeat-runner.reply.ts` | Reply payload helpers: `resolveHeartbeatReplyPayload`, `normalizeHeartbeatReply`, `restoreHeartbeatUpdatedAt` | ~100 | + +### 6. `src/infra/outbound/message-action-runner.ts` (1,082 → ~620 LOC) + +| Extract to | What moves | LOC | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---- | +| `infra/outbound/message-action-runner.media.ts` | Attachment handling (max bytes, filename, base64, sandbox) + hydration (group icon, send attachment) | ~330 | +| `infra/outbound/message-action-runner.context.ts` | Cross-context decoration + Slack/Telegram auto-threading | ~190 | + +### 7. `src/tts/tts.ts` (1,445 → ~950 LOC, then follow-up) + +| Extract to | What moves | LOC | +| ----------------------- | -------------------------------------------------------- | ---- | +| `tts/tts.directives.ts` | `parseTtsDirectives` + related types/constants | ~260 | +| `tts/tts.providers.ts` | `elevenLabsTTS`, `openaiTTS`, `edgeTTS`, `summarizeText` | ~200 | +| `tts/tts.prefs.ts` | 15 TTS preference get/set functions | ~165 | + +**Note:** Still ~955 LOC after this. A second pass could extract config resolution (~100 LOC) into `tts-config.ts`. + +### 8. `src/infra/exec-approvals.ts` (1,437 → ~700 LOC) + +| Extract to | What moves | LOC | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| `infra/exec-approvals.shell.ts` | `iterateQuoteAware`, `splitShellPipeline`, `analyzeWindowsShellCommand`, `tokenizeWindowsSegment`, `analyzeShellCommand`, `analyzeArgvCommand` | ~250 | +| `infra/exec-approvals.allowlist.ts` | `matchAllowlist`, `matchesPattern`, `globToRegExp`, `isSafeBinUsage`, `evaluateSegments`, `evaluateExecAllowlist`, `splitCommandChain`, `evaluateShellAllowlist` | ~350 | + +**Note:** Still ~942 LOC. Follow-up: `exec-command-resolution.ts` (~220 LOC) and `exec-approvals-io.ts` (~200 LOC) would bring it under 700. + +### 9. `src/cli/update-cli.ts` (1,245 → ~1,000 LOC) + +| Extract to | What moves | LOC | +| --------------------------- | ----------------------------------------------------------------------------------------- | ---- | +| `cli/update-cli.helpers.ts` | Version/tag helpers, constants, shell completion, git checkout, global manager resolution | ~340 | + +**Note:** The 3 command functions (`updateCommand`, `updateStatusCommand`, `updateWizardCommand`) are large but procedural with heavy shared context. Deeper splitting needs an interface layer. + +--- + +## Phase 3 — Higher Risk / Structural Refactors + +These files need more than "move functions" — they need closure variable threading, class decomposition, or handler-per-method patterns. + +### 10. `src/memory/manager.ts` (2,280 → ~1,300 LOC, then follow-up) + +| Extract to | What moves | LOC | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| `memory/manager.embedding.ts` | `embedChunksWithVoyageBatch`, `embedChunksWithOpenAiBatch`, `embedChunksWithGeminiBatch` (3 functions ~90% identical — **dedup opportunity**) | ~600 | +| `memory/manager.batch.ts` | `embedBatchWithRetry`, `runBatchWithFallback`, `runBatchWithTimeoutRetry`, `recordBatchFailure`, `resetBatchFailureCount` | ~300 | +| `memory/manager.cache.ts` | `loadEmbeddingCache`, `upsertEmbeddingCache`, `computeProviderKey` | ~150 | + +**Key insight:** The 3 provider embedding methods share ~90% identical structure. After extraction, refactor into a single generic `embedChunksWithProvider(config)` with provider-specific config objects. This is both a size and a logic DRY win. + +**Still ~1,362 LOC** — session sync + search could be a follow-up split. + +### 11. `src/agents/bash-tools.exec.ts` (1,546 → ~1,000 LOC) + +| Extract to | What moves | LOC | +| ----------------------------------- | ---------------------------------------------------------------- | ---- | +| `agents/bash-tools.exec.process.ts` | `runExecProcess` + supporting spawn helpers | ~400 | +| `agents/bash-tools.exec.helpers.ts` | Security constants, `validateHostEnv`, normalizers, PATH helpers | ~200 | + +**Challenge:** `runExecProcess` reads closure variables from `createExecTool`. Extraction requires passing explicit params. + +### 12. `src/gateway/server/ws-connection/message-handler.ts` (970 → ~720 LOC) + +| Extract to | What moves | LOC | +| ------------------------------------------ | --------------------------------------- | ---- | +| `ws-connection/message-handler.auth.ts` | Device signature/nonce/key verification | ~180 | +| `ws-connection/message-handler.pairing.ts` | Pairing flow | ~110 | + +**Challenge:** Everything is inside a single deeply-nested closure sharing `send`, `close`, `frame`, `connectParams`. Extraction requires threading many parameters. Consider refactoring to a class or state machine first. + +--- + +## UI Files + +### 13. `ui/src/ui/views/usage.ts` (3,076 → ~1,200 LOC) + +| Extract to | What moves | LOC | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | ---- | +| `views/usage.aggregation.ts` | Data builders, CSV export, query engine | ~550 | +| `views/usage.charts.ts` | `renderDailyChartCompact`, `renderCostBreakdown`, `renderTimeSeriesCompact`, `renderUsageMosaic` | ~600 | +| `views/usage.sessions.ts` | `renderSessionsCard`, `renderSessionDetailPanel`, `renderSessionLogsCompact` | ~800 | + +### 14. `ui/src/ui/views/agents.ts` (1,894 → ~950 LOC) + +| Extract to | What moves | LOC | +| -------------------------- | ------------------------------------- | ---- | +| `views/agents.tools.ts` | Tools panel + policy matching helpers | ~350 | +| `views/agents.skills.ts` | Skills panel + grouping logic | ~280 | +| `views/agents.channels.ts` | Channels + cron panels | ~380 | + +### 15. `ui/src/ui/views/nodes.ts` (1,118 → ~440 LOC) + +| Extract to | What moves | LOC | +| ------------------------------- | ------------------------------------------- | ---- | +| `views/nodes.exec-approvals.ts` | Exec approvals rendering + state resolution | ~500 | +| `views/nodes.devices.ts` | Device management rendering | ~230 | + +--- + +## Extension: BlueBubbles + +### 16. `extensions/bluebubbles/src/monitor.ts` (2,348 → ~650 LOC) + +| Extract to | What moves | LOC | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | ------ | +| `monitor.normalize.ts` | `normalizeWebhookMessage`, `normalizeWebhookReaction`, field extractors, participant resolution | ~500 | +| `monitor.debounce.ts` | Debounce infrastructure, combine/flush logic | ~200 | +| `monitor.webhook.ts` | `handleBlueBubblesWebhookRequest` + registration | ~1,050 | +| Merge into existing `reactions.ts` | tapback parsing, reaction normalization | ~120 | + +**Key insight:** Message/reaction normalization share ~300 lines of near-identical field extraction — dedup opportunity similar to memory providers. + +--- + +## Execution Plan + +| Wave | Files | Total extractable LOC | Est. effort | Status | +| ----------- | -------------------------------------------------------------- | --------------------- | ------------ | ------------------------------------- | +| **Wave 1** | #1–#4 (schema, audit-extra, session-cost, media-understanding) | ~2,600 | 1 session | ✅ #1 done, ✅ #2 done, #3–#4 pending | +| **Wave 2a** | #5–#6 (heartbeat, message-action-runner) | ~990 | 1 session | Not started | +| **Wave 2b** | #7–#9 (tts, exec-approvals, update-cli) | ~1,565 | 1–2 sessions | Not started | +| **Wave 3** | #10–#12 (memory, bash-tools, message-handler) | ~1,830 | 2 sessions | Not started | +| **Wave 4** | #13–#16 (UI + BlueBubbles) | ~4,560 | 2–3 sessions | Not started | + +### Ground Rules + +1. **No behavior changes.** Every extraction is a pure structural move + import update. +2. **Tests must pass.** Run `pnpm test` after each file extraction. +3. **Imports only.** New files re-export from old paths if needed to avoid breaking external consumers. +4. **Dot-naming convention.** Use `..ts` pattern (e.g., `runner.binary.ts`, not `binary-resolve.ts`). +5. **Centralized patterns over scatter.** Prefer 2 logical groupings (e.g., sync vs async) over 3-4 domain-specific fragments. +6. **Update colocated tests.** If `foo.test.ts` imports from `foo.ts`, update imports to the new module. +7. **CI gate.** Each PR must pass `pnpm build && pnpm check && pnpm test`. + +--- + +## Metrics + +After all waves complete, the expected result: + +| Metric | Before | After (est.) | +| ------------------------------- | ------ | -------------------------- | +| Files > 1,000 LOC (non-test TS) | 17 | ~5 | +| Files > 700 LOC (non-test TS) | 50+ | ~15–20 | +| New files created | 0 | ~35 | +| Net LOC change | 0 | ~0 (moves only) | +| Largest core `src/` file | 2,280 | ~1,300 (memory/manager.ts) |