From 2aabe0e8fd785f22ff625658e1e07cba7ff2035a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 13:22:27 +0800 Subject: [PATCH] Tests: trim audit imports and fix reply typing --- src/auto-reply/reply/acp-reset-target.ts | 8 +- src/auto-reply/reply/dispatch-from-config.ts | 5 +- src/auto-reply/reply/reply-elevated.ts | 6 +- .../provider-capabilities.contract.test.ts | 2 +- .../contracts/media-provider-registry.ts | 92 ++++++ src/security/audit-extra.summary.ts | 297 ++++++++++++++++++ src/security/audit-extra.sync.test.ts | 2 +- src/security/audit-extra.sync.ts | 292 +++-------------- src/security/audit-small-model-risk.test.ts | 2 +- src/security/audit-summary.test.ts | 2 +- src/security/audit.nondeep.runtime.ts | 5 +- 11 files changed, 456 insertions(+), 257 deletions(-) create mode 100644 src/plugins/contracts/media-provider-registry.ts create mode 100644 src/security/audit-extra.summary.ts diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts index 8a0b26a88d9..ca1f87aadb3 100644 --- a/src/auto-reply/reply/acp-reset-target.ts +++ b/src/auto-reply/reply/acp-reset-target.ts @@ -63,12 +63,12 @@ function resolveRawConfiguredAcpSessionKey(params: { continue; } - const bindingAccountId = normalizeText(binding.match.accountId); + const bindingAccountId = normalizeOptionalString(binding.match.accountId) ?? ""; if (bindingAccountId && bindingAccountId !== "*" && bindingAccountId !== params.accountId) { continue; } - const peerId = normalizeText(binding.match.peer?.id); + const peerId = normalizeOptionalString(binding.match.peer?.id) ?? ""; const matchedConversationId = peerId === params.conversationId ? params.conversationId @@ -107,7 +107,7 @@ export function resolveEffectiveResetTargetSessionKey(params: { skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean; fallbackToActiveAcpWhenUnbound?: boolean; }): string | undefined { - const activeSessionKey = normalizeText(params.activeSessionKey); + const activeSessionKey = normalizeOptionalString(params.activeSessionKey) ?? ""; const activeAcpSessionKey = activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined; const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey; @@ -122,7 +122,7 @@ export function resolveEffectiveResetTargetSessionKey(params: { channel, accountId: params.accountId, }); - const parentConversationId = normalizeText(params.parentConversationId) || undefined; + const parentConversationId = normalizeOptionalString(params.parentConversationId) || undefined; const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey); const serviceBinding = acpResetTargetDeps.getSessionBindingService().resolveByConversation({ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index b586f133e9a..a4855134ccc 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -746,8 +746,9 @@ export async function dispatchReplyFromConfig(params: { message?: string; }) => { if (payload.status === "pending") { - if (normalizeOptionalString(payload.command)) { - return normalizeWorkingLabel(`awaiting approval: ${payload.command}`); + const command = normalizeOptionalString(payload.command); + if (command) { + return normalizeWorkingLabel(`awaiting approval: ${command}`); } return "awaiting approval"; } diff --git a/src/auto-reply/reply/reply-elevated.ts b/src/auto-reply/reply/reply-elevated.ts index 95252ffad24..86eba86e990 100644 --- a/src/auto-reply/reply/reply-elevated.ts +++ b/src/auto-reply/reply/reply-elevated.ts @@ -77,8 +77,10 @@ function isApprovedElevatedSender(params: { const senderIdTokens = new Set(); const senderFromTokens = new Set(); const senderE164Tokens = new Set(); - const senderId = normalizeOptionalString(params.ctx.SenderId); + const senderFrom = normalizeOptionalString(params.ctx.From); + const senderE164 = normalizeOptionalString(params.ctx.SenderE164); + if (senderId) { addFormattedTokens({ formatAllowFrom: params.formatAllowFrom, @@ -88,7 +90,6 @@ function isApprovedElevatedSender(params: { tokens: senderIdTokens, }); } - const senderFrom = normalizeOptionalString(params.ctx.From); if (senderFrom) { addFormattedTokens({ formatAllowFrom: params.formatAllowFrom, @@ -98,7 +99,6 @@ function isApprovedElevatedSender(params: { tokens: senderFromTokens, }); } - const senderE164 = normalizeOptionalString(params.ctx.SenderE164); if (senderE164) { addFormattedTokens({ formatAllowFrom: params.formatAllowFrom, diff --git a/src/media-generation/provider-capabilities.contract.test.ts b/src/media-generation/provider-capabilities.contract.test.ts index 25d6e8bb90b..d97fa43dc0c 100644 --- a/src/media-generation/provider-capabilities.contract.test.ts +++ b/src/media-generation/provider-capabilities.contract.test.ts @@ -3,7 +3,7 @@ import { listSupportedMusicGenerationModes } from "../music-generation/capabilit import { musicGenerationProviderContractRegistry, videoGenerationProviderContractRegistry, -} from "../plugins/contracts/registry.js"; +} from "../plugins/contracts/media-provider-registry.js"; import { listSupportedVideoGenerationModes } from "../video-generation/capabilities.js"; describe("bundled media-generation provider capabilities", () => { diff --git a/src/plugins/contracts/media-provider-registry.ts b/src/plugins/contracts/media-provider-registry.ts new file mode 100644 index 00000000000..e255a98a6bb --- /dev/null +++ b/src/plugins/contracts/media-provider-registry.ts @@ -0,0 +1,92 @@ +import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js"; +import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js"; +import { + loadVitestMusicGenerationProviderContractRegistry, + loadVitestVideoGenerationProviderContractRegistry, + type MusicGenerationProviderContractEntry, + type VideoGenerationProviderContractEntry, +} from "./speech-vitest-registry.js"; + +function resolveBundledManifestPluginIdsForContract( + contract: "videoGenerationProviders" | "musicGenerationProviders", +): string[] { + return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) => + contract === "videoGenerationProviders" + ? entry.videoGenerationProviderIds.length > 0 + : entry.musicGenerationProviderIds.length > 0, + ) + .map((entry) => entry.pluginId) + .toSorted((left, right) => left.localeCompare(right)); +} + +function createLazyArrayView(load: () => T[]): T[] { + return new Proxy([] as T[], { + get(_target, prop) { + const actual = load(); + const value = Reflect.get(actual, prop, actual); + return typeof value === "function" ? value.bind(actual) : value; + }, + has(_target, prop) { + return Reflect.has(load(), prop); + }, + ownKeys() { + return Reflect.ownKeys(load()); + }, + getOwnPropertyDescriptor(_target, prop) { + const actual = load(); + const descriptor = Reflect.getOwnPropertyDescriptor(actual, prop); + if (descriptor) { + return descriptor; + } + if (Reflect.has(actual, prop)) { + return { + configurable: true, + enumerable: true, + writable: false, + value: Reflect.get(actual, prop, actual), + }; + } + return undefined; + }, + }); +} + +let videoGenerationProviderContractRegistryCache: VideoGenerationProviderContractEntry[] | null = + null; +let musicGenerationProviderContractRegistryCache: MusicGenerationProviderContractEntry[] | null = + null; + +function loadVideoGenerationProviderContractRegistry(): VideoGenerationProviderContractEntry[] { + if (!videoGenerationProviderContractRegistryCache) { + videoGenerationProviderContractRegistryCache = process.env.VITEST + ? loadVitestVideoGenerationProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("videoGenerationProviders"), + pluginSdkResolution: "dist", + }).videoGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); + } + return videoGenerationProviderContractRegistryCache; +} + +function loadMusicGenerationProviderContractRegistry(): MusicGenerationProviderContractEntry[] { + if (!musicGenerationProviderContractRegistryCache) { + musicGenerationProviderContractRegistryCache = process.env.VITEST + ? loadVitestMusicGenerationProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("musicGenerationProviders"), + pluginSdkResolution: "dist", + }).musicGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); + } + return musicGenerationProviderContractRegistryCache; +} + +export const videoGenerationProviderContractRegistry: VideoGenerationProviderContractEntry[] = + createLazyArrayView(loadVideoGenerationProviderContractRegistry); +export const musicGenerationProviderContractRegistry: MusicGenerationProviderContractEntry[] = + createLazyArrayView(loadMusicGenerationProviderContractRegistry); diff --git a/src/security/audit-extra.summary.ts b/src/security/audit-extra.summary.ts new file mode 100644 index 00000000000..7bc81e893e0 --- /dev/null +++ b/src/security/audit-extra.summary.ts @@ -0,0 +1,297 @@ +import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; +import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; +import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; +import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; +import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../config/model-input.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; +import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js"; +import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; +import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; + +export type SecurityAuditFinding = { + checkId: string; + severity: "info" | "warn" | "critical"; + title: string; + detail: string; + remediation?: string; +}; + +const SMALL_MODEL_PARAM_B_MAX = 300; + +type ModelRef = { id: string; source: string }; + +function summarizeGroupPolicy(cfg: OpenClawConfig): { + open: number; + allowlist: number; + other: number; +} { + const channels = cfg.channels as Record | undefined; + if (!channels || typeof channels !== "object") { + return { open: 0, allowlist: 0, other: 0 }; + } + let open = 0; + let allowlist = 0; + let other = 0; + for (const value of Object.values(channels)) { + if (!value || typeof value !== "object") { + continue; + } + const section = value as Record; + const policy = section.groupPolicy; + if (policy === "open") { + open += 1; + } else if (policy === "allowlist") { + allowlist += 1; + } else { + other += 1; + } + } + return { open, allowlist, other }; +} + +function 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, + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), + "agents.defaults.model.primary", + ); + for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) { + addModel(out, fallback, "agents.defaults.model.fallbacks"); + } + addModel( + out, + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel), + "agents.defaults.imageModel.primary", + ); + for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) { + addModel(out, fallback, "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 fallback of fallbacks) { + addModel(out, fallback, `agents.list.${id}.model.fallbacks`); + } + } + } + } + return out; +} + +function extractAgentIdFromSource(source: string): string | null { + const match = source.match(/^agents\.list\.([^.]*)\./); + return match?.[1] ?? null; +} + +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 = pickSandboxToolPolicy(params.cfg.tools ?? undefined); + if (globalPolicy) { + policies.push(globalPolicy); + } + + const agentPolicy = pickSandboxToolPolicy(params.agentTools); + if (agentPolicy) { + policies.push(agentPolicy); + } + + if (params.sandboxMode === "all") { + policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined)); + } + + return policies; +} + +function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + return hasConfiguredWebSearchCredential({ + config: cfg, + env, + origin: "bundled", + bundledAllowlistCompat: true, + }); +} + +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 { + return cfg.browser?.enabled !== false; +} + +export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const group = summarizeGroupPolicy(cfg); + const elevated = cfg.tools?.elevated?.enabled !== false; + const webhooksEnabled = cfg.hooks?.enabled === true; + const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false; + const browserEnabled = cfg.browser?.enabled ?? true; + + const detail = + `groups: open=${group.open}, allowlist=${group.allowlist}` + + `\n` + + `tools.elevated: ${elevated ? "enabled" : "disabled"}` + + `\n` + + `hooks.webhooks: ${webhooksEnabled ? "enabled" : "disabled"}` + + `\n` + + `hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` + + `\n` + + `browser control: ${browserEnabled ? "enabled" : "disabled"}` + + `\n` + + "trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway"; + + return [ + { + checkId: "summary.attack_surface", + severity: "info", + title: "Attack surface summary", + detail, + }, + ]; +} + +export function collectSmallModelRiskFindings(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel")); + if (models.length === 0) { + return findings; + } + + const smallModels = models + .map((entry) => { + const paramB = inferParamBFromIdOrName(entry.id); + if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) { + return null; + } + return { ...entry, paramB }; + }) + .filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry)); + + if (smallModels.length === 0) { + return findings; + } + + let hasUnsafe = false; + const modelLines: string[] = []; + const exposureSet = new Set(); + for (const entry of smallModels) { + const agentId = extractAgentIdFromSource(entry.source); + const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode; + const agentTools = + agentId && params.cfg.agents?.list + ? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools + : undefined; + const policies = resolveToolPolicies({ + cfg: params.cfg, + agentTools, + sandboxMode, + agentId, + }); + const exposed: string[] = []; + if ( + isWebSearchEnabled(params.cfg, params.env) && + isToolAllowedByPolicies("web_search", policies) + ) { + exposed.push("web_search"); + } + if (isWebFetchEnabled(params.cfg) && isToolAllowedByPolicies("web_fetch", policies)) { + exposed.push("web_fetch"); + } + if (isBrowserEnabled(params.cfg) && 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; +} diff --git a/src/security/audit-extra.sync.test.ts b/src/security/audit-extra.sync.test.ts index 90fcfdf6e1e..18eb2c042a3 100644 --- a/src/security/audit-extra.sync.test.ts +++ b/src/security/audit-extra.sync.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { collectAttackSurfaceSummaryFindings, collectSmallModelRiskFindings, -} from "./audit-extra.sync.js"; +} from "./audit-extra.summary.js"; import { safeEqualSecret } from "./secret-equal.js"; describe("collectAttackSurfaceSummaryFindings", () => { diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 7458e70a942..62d171fe1cd 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -5,6 +5,10 @@ import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/ * * These functions analyze config-based security properties without I/O. */ +export { + collectAttackSurfaceSummaryFindings, + collectSmallModelRiskFindings, +} from "./audit-extra.summary.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js"; @@ -23,8 +27,6 @@ import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, } from "../gateway/node-command-policy.js"; -import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js"; -import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; export type SecurityAuditFinding = { @@ -35,41 +37,10 @@ export type SecurityAuditFinding = { remediation?: string; }; -const SMALL_MODEL_PARAM_B_MAX = 300; - // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- -function summarizeGroupPolicy(cfg: OpenClawConfig): { - open: number; - allowlist: number; - other: number; -} { - const channels = cfg.channels as Record | undefined; - if (!channels || typeof channels !== "object") { - return { open: 0, allowlist: 0, other: 0 }; - } - let open = 0; - let allowlist = 0; - let other = 0; - for (const value of Object.values(channels)) { - if (!value || typeof value !== "object") { - continue; - } - const section = value as Record; - const policy = section.groupPolicy; - if (policy === "open") { - open += 1; - } else if (policy === "allowlist") { - allowlist += 1; - } else { - other += 1; - } - } - return { open, allowlist, other }; -} - function isProbablySyncedPath(p: string): boolean { const s = p.toLowerCase(); return ( @@ -86,15 +57,6 @@ function looksLikeEnvRef(value: string): boolean { return v.startsWith("${") && v.endsWith("}"); } -function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean { - const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; - if (bind !== "loopback") { - return true; - } - const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - return tailscaleMode === "serve" || tailscaleMode === "funnel"; -} - type ModelRef = { id: string; source: string }; function addModel(models: ModelRef[], raw: unknown, source: string) { @@ -115,16 +77,16 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] { resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), "agents.defaults.model.primary", ); - for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) { - addModel(out, f, "agents.defaults.model.fallbacks"); + for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) { + addModel(out, fallback, "agents.defaults.model.fallbacks"); } addModel( out, resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel), "agents.defaults.imageModel.primary", ); - for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) { - addModel(out, f, "agents.defaults.imageModel.fallbacks"); + for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) { + addModel(out, fallback, "agents.defaults.imageModel.fallbacks"); } const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : []; @@ -141,8 +103,8 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] { 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`); + for (const fallback of fallbacks) { + addModel(out, fallback, `agents.list.${id}.model.fallbacks`); } } } @@ -150,6 +112,15 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] { return out; } +function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean { + const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; + if (bind !== "loopback") { + return true; + } + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + return tailscaleMode === "serve" || tailscaleMode === "funnel"; +} + 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" }, @@ -179,11 +150,6 @@ function isClaude45OrHigher(id: string): boolean { ); } -function extractAgentIdFromSource(source: string): string | null { - const match = source.match(/^agents\.list\.([^.]*)\./); - return match?.[1] ?? null; -} - function hasConfiguredDockerConfig( docker: Record | undefined | null, ): docker is Record { @@ -221,6 +187,36 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set { return out; } +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 = pickSandboxToolPolicy(params.cfg.tools ?? undefined); + if (globalPolicy) { + policies.push(globalPolicy); + } + + const agentPolicy = pickSandboxToolPolicy(params.agentTools); + if (agentPolicy) { + policies.push(agentPolicy); + } + + if (params.sandboxMode === "all") { + policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined)); + } + + return policies; +} + function looksLikeNodeCommandPattern(value: string): boolean { if (!value) { return false; @@ -294,71 +290,6 @@ function suggestKnownNodeCommands(unknown: string, known: Set): string[] .map((r) => r.cmd); } -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 = pickSandboxToolPolicy(params.cfg.tools ?? undefined); - if (globalPolicy) { - policies.push(globalPolicy); - } - - const agentPolicy = pickSandboxToolPolicy(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 { - return hasConfiguredWebSearchCredential({ - config: cfg, - env, - origin: "bundled", - bundledAllowlistCompat: true, - }); -} - -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 { - // The audit only needs the enablement policy, not full browser runtime - // resolution. Browser defaults to enabled unless it is explicitly disabled. - return cfg.browser?.enabled !== false; -} - function listGroupPolicyOpen(cfg: OpenClawConfig): string[] { const out: string[] = []; const channels = cfg.channels as Record | undefined; @@ -524,36 +455,6 @@ function collectRiskyToolExposureContexts(cfg: OpenClawConfig): { // Exported collectors // -------------------------------------------------------------------------- -export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { - const group = summarizeGroupPolicy(cfg); - const elevated = cfg.tools?.elevated?.enabled !== false; - const webhooksEnabled = cfg.hooks?.enabled === true; - const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false; - const browserEnabled = cfg.browser?.enabled ?? true; - - const detail = - `groups: open=${group.open}, allowlist=${group.allowlist}` + - `\n` + - `tools.elevated: ${elevated ? "enabled" : "disabled"}` + - `\n` + - `hooks.webhooks: ${webhooksEnabled ? "enabled" : "disabled"}` + - `\n` + - `hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` + - `\n` + - `browser control: ${browserEnabled ? "enabled" : "disabled"}` + - `\n` + - "trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway"; - - return [ - { - checkId: "summary.attack_surface", - severity: "info", - title: "Attack surface summary", - detail, - }, - ]; -} - export function collectSyncedFolderFindings(params: { stateDir: string; configPath: string; @@ -1194,101 +1095,6 @@ export function collectModelHygieneFindings(cfg: OpenClawConfig): SecurityAuditF return findings; } -export function collectSmallModelRiskFindings(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): SecurityAuditFinding[] { - const findings: SecurityAuditFinding[] = []; - const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel")); - if (models.length === 0) { - return findings; - } - - const smallModels = models - .map((entry) => { - const paramB = inferParamBFromIdOrName(entry.id); - if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) { - return null; - } - return { ...entry, paramB }; - }) - .filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry)); - - if (smallModels.length === 0) { - return findings; - } - - let hasUnsafe = false; - const modelLines: string[] = []; - const exposureSet = new Set(); - for (const entry of smallModels) { - const agentId = extractAgentIdFromSource(entry.source); - const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode; - const agentTools = - agentId && params.cfg.agents?.list - ? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools - : undefined; - const policies = resolveToolPolicies({ - cfg: params.cfg, - agentTools, - sandboxMode, - agentId, - }); - const exposed: string[] = []; - if (isWebSearchEnabled(params.cfg, params.env)) { - if (isToolAllowedByPolicies("web_search", policies)) { - exposed.push("web_search"); - } - } - if (isWebFetchEnabled(params.cfg)) { - if (isToolAllowedByPolicies("web_fetch", policies)) { - exposed.push("web_fetch"); - } - } - if (isBrowserEnabled(params.cfg)) { - if (isToolAllowedByPolicies("browser", policies)) { - exposed.push("browser"); - } - } - for (const tool of exposed) { - exposureSet.add(tool); - } - const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`; - const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]"; - const safe = sandboxMode === "all" && exposed.length === 0; - if (!safe) { - hasUnsafe = true; - } - const statusLabel = safe ? "ok" : "unsafe"; - modelLines.push( - `- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`, - ); - } - - const exposureList = Array.from(exposureSet); - const exposureDetail = - exposureList.length > 0 - ? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.` - : "No web/browser tools detected for these models."; - - findings.push({ - checkId: "models.small_params", - severity: hasUnsafe ? "critical" : "info", - title: "Small models require sandboxing and web tools disabled", - detail: - `Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` + - modelLines.join("\n") + - `\n` + - exposureDetail + - `\n` + - "Small models are not recommended for untrusted inputs.", - remediation: - 'If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode="all") and disable web_search/web_fetch/browser (tools.deny=["group:web","browser"]).', - }); - - return findings; -} - export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const openGroups = listGroupPolicyOpen(cfg); diff --git a/src/security/audit-small-model-risk.test.ts b/src/security/audit-small-model-risk.test.ts index 235551340f8..52c7a92541a 100644 --- a/src/security/audit-small-model-risk.test.ts +++ b/src/security/audit-small-model-risk.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { collectSmallModelRiskFindings } from "./audit-extra.sync.js"; +import { collectSmallModelRiskFindings } from "./audit-extra.summary.js"; describe("security audit small-model risk findings", () => { it("scores small-model risk by tool/sandbox exposure", () => { diff --git a/src/security/audit-summary.test.ts b/src/security/audit-summary.test.ts index f92ac36b584..b0497bfc7b0 100644 --- a/src/security/audit-summary.test.ts +++ b/src/security/audit-summary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { collectAttackSurfaceSummaryFindings } from "./audit-extra.sync.js"; +import { collectAttackSurfaceSummaryFindings } from "./audit-extra.summary.js"; describe("security audit attack surface summary", () => { it("includes an attack surface summary (info)", () => { diff --git a/src/security/audit.nondeep.runtime.ts b/src/security/audit.nondeep.runtime.ts index 5a962bf8386..eae30e69b61 100644 --- a/src/security/audit.nondeep.runtime.ts +++ b/src/security/audit.nondeep.runtime.ts @@ -1,5 +1,9 @@ export { collectAttackSurfaceSummaryFindings, + collectSmallModelRiskFindings, +} from "./audit-extra.summary.js"; + +export { collectExposureMatrixFindings, collectGatewayHttpNoAuthFindings, collectGatewayHttpSessionKeyOverrideFindings, @@ -12,7 +16,6 @@ export { collectSandboxDangerousConfigFindings, collectSandboxDockerNoopFindings, collectSecretsInConfigFindings, - collectSmallModelRiskFindings, collectSyncedFolderFindings, } from "./audit-extra.sync.js";