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
This commit is contained in:
max
2026-02-09 22:22:29 -08:00
committed by GitHub
parent 47f6bb4146
commit f17c978f5c
5 changed files with 2381 additions and 1305 deletions

View File

@@ -0,0 +1,738 @@
export const GROUP_LABELS: Record<string, string> = {
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<string, number> = {
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<string, string> = {
"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<string, string> = {
"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/<id>).",
"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 (05).",
"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<string, string> = {
"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));
}

View File

@@ -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<string, unknown>;
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<string[]> {
const visited = new Set<string>();
const result: string[] = [];
const walk = async (basePath: string, parsed: unknown, depth: number): Promise<void> => {
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<string[]> {
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<typeof MANIFEST_KEY, { extensions?: unknown }>
> | 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<string>();
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<SecurityAuditFinding[]> {
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<string, unknown>)[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<SecurityAuditFinding[]> {
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<SecurityAuditFinding[]> {
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<ConfigFileSnapshot> {
return await createConfigIO({
env: params.env,
configPath: params.configPath,
}).readConfigFileSnapshot();
}
export async function collectPluginsCodeSafetyFindings(params: {
stateDir: string;
}): Promise<SecurityAuditFinding[]> {
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<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const pluginExtensionsDir = path.join(params.stateDir, "extensions");
const scannedSkillDirs = new Set<string>();
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;
}

View File

@@ -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<string, unknown> | 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<string, unknown>;
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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, { model: string; source: string; reasons: string[] }>();
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<string>();
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;
}

File diff suppressed because it is too large Load Diff

275
tmp-refactoring-strategy.md Normal file
View File

@@ -0,0 +1,275 @@
# Refactoring Strategy — Oversized Files
> **Target:** ~500700 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 24× 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**: `<base-module>.<concern>.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:**
- `<base>.ts` — main barrel, re-exports public API
- `<base>.types.ts` — type definitions
- `<base>.shared.ts` — shared constants/utilities
- `<base>.<domain>.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.) | 1K3K | 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 `<base>.<concern>.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 | 12 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 | 23 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 `<base>.<concern>.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+ | ~1520 |
| New files created | 0 | ~35 |
| Net LOC change | 0 | ~0 (moves only) |
| Largest core `src/` file | 2,280 | ~1,300 (memory/manager.ts) |