diff --git a/CHANGELOG.md b/CHANGELOG.md index 539662eb42..1622d49433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. - MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin. - Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr. +- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability. - Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. - Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive. - Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck. diff --git a/docs/cli/security.md b/docs/cli/security.md index 2ea4df8361..dc0969266b 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,3 +25,4 @@ openclaw security audit --fix The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. +It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 14a3f17b00..0f7364d92d 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -45,6 +45,7 @@ Start with the smallest access that still works, then widen it as you gain confi - **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints). - **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths). - **Plugins** (extensions exist without an explicit allowlist). +- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy). - **Model hygiene** (warn when configured models look legacy; not a hard block). If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe. diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index a20edd6dcd..25f098c892 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -6,15 +6,24 @@ import JSON5 from "json5"; import fs from "node:fs/promises"; import path from "node:path"; +import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; import type { ExecFn } from "./windows-acl.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; +import { + resolveSandboxConfigForAgent, + resolveSandboxToolPolicyForAgent, +} from "../agents/sandbox.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; +import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { 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 { normalizePluginsConfig } from "../plugins/config-state.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { formatPermissionDetail, @@ -196,6 +205,135 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): .join("\n"); } +function unionAllow(base?: string[], extra?: string[]): string[] | undefined { + if (!Array.isArray(extra) || extra.length === 0) { + return base; + } + if (!Array.isArray(base) || base.length === 0) { + return Array.from(new Set(["*", ...extra])); + } + return Array.from(new Set([...base, ...extra])); +} + +function pickToolPolicy(config?: { + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; +}): SandboxToolPolicy | undefined { + if (!config) { + return undefined; + } + const allow = Array.isArray(config.allow) + ? unionAllow(config.allow, config.alsoAllow) + : Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0 + ? unionAllow(undefined, config.alsoAllow) + : undefined; + const deny = Array.isArray(config.deny) ? config.deny : undefined; + if (!allow && !deny) { + return undefined; + } + return { allow, deny }; +} + +function resolveToolPolicies(params: { + cfg: OpenClawConfig; + agentTools?: AgentToolsConfig; + sandboxMode?: "off" | "non-main" | "all"; + agentId?: string | null; +}): Array { + const profile = params.agentTools?.profile ?? params.cfg.tools?.profile; + const profilePolicy = resolveToolProfilePolicy(profile); + const policies: Array = [ + profilePolicy, + pickToolPolicy(params.cfg.tools ?? undefined), + pickToolPolicy(params.agentTools), + ]; + if (params.sandboxMode === "all") { + policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined)); + } + return policies; +} + +function normalizePluginIdSet(entries: string[]): Set { + return new Set(entries.map((entry) => entry.trim().toLowerCase()).filter(Boolean)); +} + +function resolveEnabledExtensionPluginIds(params: { + cfg: OpenClawConfig; + pluginDirs: string[]; +}): string[] { + const normalized = normalizePluginsConfig(params.cfg.plugins); + if (!normalized.enabled) { + return []; + } + + const allowSet = normalizePluginIdSet(normalized.allow); + const denySet = normalizePluginIdSet(normalized.deny); + const entryById = new Map(); + for (const [id, entry] of Object.entries(normalized.entries)) { + entryById.set(id.trim().toLowerCase(), entry); + } + + const enabled: string[] = []; + for (const id of params.pluginDirs) { + const normalizedId = id.trim().toLowerCase(); + if (!normalizedId) { + continue; + } + if (denySet.has(normalizedId)) { + continue; + } + if (allowSet.size > 0 && !allowSet.has(normalizedId)) { + continue; + } + if (entryById.get(normalizedId)?.enabled === false) { + continue; + } + enabled.push(normalizedId); + } + return enabled; +} + +function collectAllowEntries(config?: { allow?: string[]; alsoAllow?: string[] }): string[] { + const out: string[] = []; + if (Array.isArray(config?.allow)) { + out.push(...config.allow); + } + if (Array.isArray(config?.alsoAllow)) { + out.push(...config.alsoAllow); + } + return out.map((entry) => entry.trim().toLowerCase()).filter(Boolean); +} + +function hasExplicitPluginAllow(params: { + allowEntries: string[]; + enabledPluginIds: Set; +}): boolean { + return params.allowEntries.some( + (entry) => entry === "group:plugins" || params.enabledPluginIds.has(entry), + ); +} + +function hasProviderPluginAllow(params: { + byProvider?: Record; + enabledPluginIds: Set; +}): boolean { + if (!params.byProvider) { + return false; + } + for (const policy of Object.values(params.byProvider)) { + if ( + hasExplicitPluginAllow({ + allowEntries: collectAllowEntries(policy), + enabledPluginIds: params.enabledPluginIds, + }) + ) { + return true; + } + } + return false; +} + // -------------------------------------------------------------------------- // Exported collectors // -------------------------------------------------------------------------- @@ -297,6 +435,78 @@ export async function collectPluginsTrustFindings(params: { }); } + const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({ + cfg: params.cfg, + pluginDirs, + }); + if (enabledExtensionPluginIds.length > 0) { + const enabledPluginSet = new Set(enabledExtensionPluginIds); + const contexts: Array<{ + label: string; + agentId?: string; + tools?: AgentToolsConfig; + }> = [{ label: "default" }]; + for (const entry of params.cfg.agents?.list ?? []) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + contexts.push({ + label: `agents.list.${entry.id}`, + agentId: entry.id, + tools: entry.tools, + }); + } + + const permissiveContexts: string[] = []; + for (const context of contexts) { + const profile = context.tools?.profile ?? params.cfg.tools?.profile; + const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile)); + const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode; + const policies = resolveToolPolicies({ + cfg: params.cfg, + agentTools: context.tools, + sandboxMode, + agentId: context.agentId, + }); + const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies); + const explicitPluginAllow = + !restrictiveProfile && + (hasExplicitPluginAllow({ + allowEntries: collectAllowEntries(params.cfg.tools), + enabledPluginIds: enabledPluginSet, + }) || + hasProviderPluginAllow({ + byProvider: params.cfg.tools?.byProvider, + enabledPluginIds: enabledPluginSet, + }) || + hasExplicitPluginAllow({ + allowEntries: collectAllowEntries(context.tools), + enabledPluginIds: enabledPluginSet, + }) || + hasProviderPluginAllow({ + byProvider: context.tools?.byProvider, + enabledPluginIds: enabledPluginSet, + })); + + if (broadPolicy || explicitPluginAllow) { + permissiveContexts.push(context.label); + } + } + + if (permissiveContexts.length > 0) { + findings.push({ + checkId: "plugins.tools_reachable_permissive_policy", + severity: "warn", + title: "Extension plugin tools may be reachable under permissive tool policy", + detail: + `Enabled extension plugins: ${enabledExtensionPluginIds.join(", ")}.\n` + + `Permissive tool policy contexts:\n${permissiveContexts.map((entry) => `- ${entry}`).join("\n")}`, + remediation: + "Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.", + }); + } + } + return findings; } diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 45330dbfd2..06a16f55c0 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -15,6 +15,7 @@ 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"; +import { resolveNodeCommandAllowlist } from "../gateway/node-command-policy.js"; export type SecurityAuditFinding = { checkId: string; @@ -185,11 +186,29 @@ function extractAgentIdFromSource(source: string): string | null { return match?.[1] ?? null; } -function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): SandboxToolPolicy | null { +function unionAllow(base?: string[], extra?: string[]): string[] | undefined { + if (!Array.isArray(extra) || extra.length === 0) { + return base; + } + if (!Array.isArray(base) || base.length === 0) { + return Array.from(new Set(["*", ...extra])); + } + return Array.from(new Set([...base, ...extra])); +} + +function pickToolPolicy(config?: { + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; +}): SandboxToolPolicy | null { if (!config) { return null; } - const allow = Array.isArray(config.allow) ? config.allow : undefined; + const allow = Array.isArray(config.allow) + ? unionAllow(config.allow, config.alsoAllow) + : Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0 + ? unionAllow(undefined, config.alsoAllow) + : undefined; const deny = Array.isArray(config.deny) ? config.deny : undefined; if (!allow && !deny) { return null; @@ -197,6 +216,61 @@ function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): Sandbox return { allow, deny }; } +function hasConfiguredDockerConfig( + docker: Record | undefined | null, +): docker is Record { + if (!docker || typeof docker !== "object") { + return false; + } + return Object.values(docker).some((value) => value !== undefined); +} + +function normalizeNodeCommand(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function listKnownNodeCommands(cfg: OpenClawConfig): Set { + const baseCfg: OpenClawConfig = { + ...cfg, + gateway: { + ...cfg.gateway, + nodes: { + ...cfg.gateway?.nodes, + denyCommands: [], + }, + }, + }; + const out = new Set(); + for (const platform of ["ios", "android", "macos", "linux", "windows", "unknown"]) { + const allow = resolveNodeCommandAllowlist(baseCfg, { platform }); + for (const cmd of allow) { + const normalized = normalizeNodeCommand(cmd); + if (normalized) { + out.add(normalized); + } + } + } + return out; +} + +function looksLikeNodeCommandPattern(value: string): boolean { + if (!value) { + return false; + } + if (/[?*[\]{}(),|]/.test(value)) { + return true; + } + if ( + value.startsWith("/") || + value.endsWith("/") || + value.startsWith("^") || + value.endsWith("$") + ) { + return true; + } + return /\s/.test(value) || value.includes("group:"); +} + function resolveToolPolicies(params: { cfg: OpenClawConfig; agentTools?: AgentToolsConfig; @@ -471,6 +545,141 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi return findings; } +export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const configuredPaths: string[] = []; + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + + const defaultsSandbox = cfg.agents?.defaults?.sandbox; + const hasDefaultDocker = hasConfiguredDockerConfig( + defaultsSandbox?.docker as Record | undefined, + ); + const defaultMode = defaultsSandbox?.mode ?? "off"; + const hasAnySandboxEnabledAgent = agents.some((entry) => { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + return false; + } + return resolveSandboxConfigForAgent(cfg, entry.id).mode !== "off"; + }); + if (hasDefaultDocker && defaultMode === "off" && !hasAnySandboxEnabledAgent) { + configuredPaths.push("agents.defaults.sandbox.docker"); + } + + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + if (!hasConfiguredDockerConfig(entry.sandbox?.docker as Record | undefined)) { + continue; + } + if (resolveSandboxConfigForAgent(cfg, entry.id).mode === "off") { + configuredPaths.push(`agents.list.${entry.id}.sandbox.docker`); + } + } + + if (configuredPaths.length === 0) { + return findings; + } + + findings.push({ + checkId: "sandbox.docker_config_mode_off", + severity: "warn", + title: "Sandbox docker settings configured while sandbox mode is off", + detail: + "These docker settings will not take effect until sandbox mode is enabled:\n" + + configuredPaths.map((entry) => `- ${entry}`).join("\n"), + remediation: + 'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) where needed, or remove unused docker settings.', + }); + + return findings; +} + +export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const denyListRaw = cfg.gateway?.nodes?.denyCommands; + if (!Array.isArray(denyListRaw) || denyListRaw.length === 0) { + return findings; + } + + const denyList = denyListRaw.map(normalizeNodeCommand).filter(Boolean); + if (denyList.length === 0) { + return findings; + } + + const knownCommands = listKnownNodeCommands(cfg); + const patternLike = denyList.filter((entry) => looksLikeNodeCommandPattern(entry)); + const unknownExact = denyList.filter( + (entry) => !looksLikeNodeCommandPattern(entry) && !knownCommands.has(entry), + ); + if (patternLike.length === 0 && unknownExact.length === 0) { + return findings; + } + + const detailParts: string[] = []; + if (patternLike.length > 0) { + detailParts.push( + `Pattern-like entries (not supported by exact matching): ${patternLike.join(", ")}`, + ); + } + if (unknownExact.length > 0) { + detailParts.push( + `Unknown command names (not in defaults/allowCommands): ${unknownExact.join(", ")}`, + ); + } + const examples = Array.from(knownCommands).slice(0, 8); + + findings.push({ + checkId: "gateway.nodes.deny_commands_ineffective", + severity: "warn", + title: "Some gateway.nodes.denyCommands entries are ineffective", + detail: + "gateway.nodes.denyCommands uses exact command-name matching only.\n" + + detailParts.map((entry) => `- ${entry}`).join("\n"), + remediation: + `Use exact command names (for example: ${examples.join(", ")}). ` + + "If you need broader restrictions, remove risky commands from allowCommands/default workflows.", + }); + + return findings; +} + +export function collectMinimalProfileOverrideFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + if (cfg.tools?.profile !== "minimal") { + return findings; + } + + const overrides = (cfg.agents?.list ?? []) + .filter((entry): entry is { id: string; tools?: AgentToolsConfig } => { + return Boolean( + entry && + typeof entry === "object" && + typeof entry.id === "string" && + entry.tools?.profile && + entry.tools.profile !== "minimal", + ); + }) + .map((entry) => `${entry.id}=${entry.tools?.profile}`); + + if (overrides.length === 0) { + return findings; + } + + findings.push({ + checkId: "tools.profile_minimal_overridden", + severity: "warn", + title: "Global tools.profile=minimal is overridden by agent profiles", + detail: + "Global minimal profile is set, but these agent profiles take precedence:\n" + + overrides.map((entry) => `- agents.list.${entry}`).join("\n"), + remediation: + 'Set those agents to `tools.profile="minimal"` (or remove the agent override) if you want minimal tools enforced globally.', + }); + + return findings; +} + export function collectModelHygieneFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const models = collectModels(cfg); diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 634c51cbdb..35b4d3405a 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -12,7 +12,10 @@ export { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, collectHooksHardeningFindings, + collectMinimalProfileOverrideFindings, collectModelHygieneFindings, + collectNodeDenyCommandPatternFindings, + collectSandboxDockerNoopFindings, collectSecretsInConfigFindings, collectSmallModelRiskFindings, collectSyncedFolderFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index c71dffe390..bac80213d0 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -303,6 +303,110 @@ describe("security audit", () => { expect(finding?.detail).toContain("sandbox=all"); }); + it("flags sandbox docker config when sandbox mode is off", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "off", + docker: { image: "ghcr.io/example/sandbox:latest" }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "sandbox.docker_config_mode_off", + severity: "warn", + }), + ]), + ); + }); + + it("does not flag global sandbox docker config when an agent enables sandbox mode", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "off", + docker: { image: "ghcr.io/example/sandbox:latest" }, + }, + }, + list: [{ id: "ops", sandbox: { mode: "all" } }], + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings.some((f) => f.checkId === "sandbox.docker_config_mode_off")).toBe(false); + }); + + it("flags ineffective gateway.nodes.denyCommands entries", async () => { + const cfg: OpenClawConfig = { + gateway: { + nodes: { + denyCommands: ["system.*", "system.runx"], + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + const finding = res.findings.find( + (f) => f.checkId === "gateway.nodes.deny_commands_ineffective", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain("system.*"); + expect(finding?.detail).toContain("system.runx"); + }); + + it("flags agent profile overrides when global tools.profile is minimal", async () => { + const cfg: OpenClawConfig = { + tools: { + profile: "minimal", + }, + agents: { + list: [ + { + id: "owner", + tools: { profile: "full" }, + }, + ], + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "tools.profile_minimal_overridden", + severity: "warn", + }), + ]), + ); + }); + it("flags tools.elevated allowFrom wildcard as critical", async () => { const cfg: OpenClawConfig = { tools: { @@ -1149,6 +1253,68 @@ describe("security audit", () => { } }); + it("flags enabled extensions when tool policy can expose plugin tools", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), { + recursive: true, + mode: 0o700, + }); + + try { + const cfg: OpenClawConfig = { + plugins: { allow: ["some-plugin"] }, + }; + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.tools_reachable_permissive_policy", + severity: "warn", + }), + ]), + ); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("does not flag plugin tool reachability when profile is restrictive", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), { + recursive: true, + mode: 0o700, + }); + + try { + const cfg: OpenClawConfig = { + plugins: { allow: ["some-plugin"] }, + tools: { profile: "coding" }, + }; + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); + + expect( + res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), + ).toBe(false); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => { const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; delete process.env.DISCORD_BOT_TOKEN; diff --git a/src/security/audit.ts b/src/security/audit.ts index 2dd9c4bb61..16e778aa35 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -18,8 +18,11 @@ import { collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, + collectMinimalProfileOverrideFindings, collectModelHygieneFindings, + collectNodeDenyCommandPatternFindings, collectSmallModelRiskFindings, + collectSandboxDockerNoopFindings, collectPluginsTrustFindings, collectSecretsInConfigFindings, collectPluginsCodeSafetyFindings, @@ -980,6 +983,9 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise