diff --git a/src/security/audit-plugin-readonly-scope.test.ts b/src/security/audit-plugin-readonly-scope.test.ts index 8fe631c2c19..aef07f3a721 100644 --- a/src/security/audit-plugin-readonly-scope.test.ts +++ b/src/security/audit-plugin-readonly-scope.test.ts @@ -18,11 +18,32 @@ vi.mock("../plugins/runtime/metadata-registry-loader.js", () => ({ loadPluginMetadataRegistrySnapshotMock(...args), })); -let runSecurityAudit: typeof import("./audit.js").runSecurityAudit; +let collectPluginSecurityAuditFindings: typeof import("./audit.js").collectPluginSecurityAuditFindings; + +function createAuditContext(params: { + sourceConfig: Parameters[0]["sourceConfig"]; + plugins: Parameters[0]["plugins"]; +}): Parameters[0] { + return { + cfg: params.sourceConfig, + sourceConfig: params.sourceConfig, + env: {}, + platform: process.platform, + includeFilesystem: false, + includeChannelSecurity: true, + deep: false, + deepTimeoutMs: 5000, + stateDir: "/tmp/openclaw-test-state", + configPath: "/tmp/openclaw-test-config.json", + plugins: params.plugins, + configSnapshot: null, + codeSafetySummaryCache: new Map>(), + }; +} describe("security audit read-only plugin scope", () => { beforeAll(async () => { - ({ runSecurityAudit } = await import("./audit.js")); + ({ collectPluginSecurityAuditFindings } = await import("./audit.js")); }); beforeEach(() => { @@ -56,14 +77,12 @@ describe("security audit read-only plugin scope", () => { }); resolveConfiguredChannelPluginIdsMock.mockReturnValue(["external-channel-plugin"]); - await runSecurityAudit({ - config: sourceConfig, - sourceConfig, - env: {} as NodeJS.ProcessEnv, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [], - }); + await collectPluginSecurityAuditFindings( + createAuditContext({ + sourceConfig, + plugins: [], + }), + ); expect(resolveConfiguredChannelPluginIdsMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -95,14 +114,12 @@ describe("security audit read-only plugin scope", () => { }); resolveConfiguredChannelPluginIdsMock.mockReturnValue(["external-channel-plugin"]); - await runSecurityAudit({ - config: sourceConfig, - sourceConfig, - env: {} as NodeJS.ProcessEnv, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [{ id: "external-channel-plugin" }] as never, - }); + await collectPluginSecurityAuditFindings( + createAuditContext({ + sourceConfig, + plugins: [{ id: "external-channel-plugin" }] as never, + }), + ); expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/security/audit.ts b/src/security/audit.ts index b27c82c30bc..587ddd65db9 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -4,7 +4,6 @@ import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import type { listChannelPlugins } from "../channels/plugins/index.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; -import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js"; import { isInterpreterLikeAllowlistPattern } from "../infra/exec-inline-eval.js"; import { @@ -13,11 +12,6 @@ import { } from "../infra/exec-safe-bin-runtime-policy.js"; import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; -import { - hasConfiguredChannelsForReadOnlyScope, - resolveConfiguredChannelPluginIds, -} from "../plugins/channel-plugin-ids.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; @@ -80,7 +74,7 @@ export type SecurityAuditOptions = { probeGatewayFn?: ProbeGatewayFn; }; -type AuditExecutionContext = { +export type AuditExecutionContext = { cfg: OpenClawConfig; sourceConfig: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -112,6 +106,13 @@ let pluginRegistryLoaderModulePromise: let pluginMetadataRegistryLoaderModulePromise: | Promise | undefined; +let pluginAutoEnableModulePromise: + | Promise + | undefined; +let channelPluginIdsModulePromise: + | Promise + | undefined; +let pluginRuntimeModulePromise: Promise | undefined; let gatewayProbeDepsPromise: | Promise<{ buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails; @@ -147,6 +148,21 @@ async function loadPluginMetadataRegistryLoaderModule() { return await pluginMetadataRegistryLoaderModulePromise; } +async function loadPluginAutoEnableModule() { + pluginAutoEnableModulePromise ??= import("../config/plugin-auto-enable.js"); + return await pluginAutoEnableModulePromise; +} + +async function loadChannelPluginIdsModule() { + channelPluginIdsModulePromise ??= import("../plugins/channel-plugin-ids.js"); + return await channelPluginIdsModulePromise; +} + +async function loadPluginRuntimeModule() { + pluginRuntimeModulePromise ??= import("../plugins/runtime.js"); + return await pluginRuntimeModulePromise; +} + async function loadGatewayProbeDeps() { gatewayProbeDepsPromise ??= Promise.all([ import("../gateway/call.js"), @@ -325,11 +341,13 @@ export function collectGatewayConfigFindings( }); } -async function collectPluginSecurityAuditFindings( +export async function collectPluginSecurityAuditFindings( context: AuditExecutionContext, ): Promise { + const { getActivePluginRegistry } = await loadPluginRuntimeModule(); let collectors = getActivePluginRegistry()?.securityAuditCollectors ?? []; if (collectors.length === 0) { + const { applyPluginAutoEnable } = await loadPluginAutoEnableModule(); const autoEnabled = applyPluginAutoEnable({ config: context.sourceConfig, env: context.env, @@ -360,6 +378,7 @@ async function collectPluginSecurityAuditFindings( } } if (context.includeChannelSecurity && context.plugins !== undefined) { + const { resolveConfiguredChannelPluginIds } = await loadChannelPluginIdsModule(); const auditedChannelPluginIds = new Set(context.plugins.map((plugin) => plugin.id)); for (const pluginId of resolveConfiguredChannelPluginIds({ config: autoEnabled.config, @@ -1008,21 +1027,28 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise 0); + let shouldAuditChannelSecurity = false; + if (context.includeChannelSecurity) { + if (context.plugins !== undefined) { + shouldAuditChannelSecurity = true; + } else { + const { hasConfiguredChannelsForReadOnlyScope, resolveConfiguredChannelPluginIds } = + await loadChannelPluginIdsModule(); + shouldAuditChannelSecurity = + hasConfiguredChannelsForReadOnlyScope({ + config: cfg, + activationSourceConfig: context.sourceConfig, + workspaceDir: context.workspaceDir, + env, + }) || + resolveConfiguredChannelPluginIds({ + config: cfg, + activationSourceConfig: context.sourceConfig, + workspaceDir: context.workspaceDir, + env, + }).length > 0; + } + } if (shouldAuditChannelSecurity) { if (context.plugins === undefined) { (await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({