From c4358fb5671c34c90e7bdb4f085f04b500855570 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 16:22:05 +0100 Subject: [PATCH] perf(test): shorten security audit hotspot tests --- src/security/audit-channel-dm-policy.test.ts | 1 + .../audit-channel-readonly-resolution.test.ts | 1 + src/security/audit-channel.ts | 11 ++++++---- src/security/audit-extra.async.ts | 21 ++++++++++++------- .../audit-workspace-skill-escape.test.ts | 13 ++++-------- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/security/audit-channel-dm-policy.test.ts b/src/security/audit-channel-dm-policy.test.ts index 516e9fe45dd..162d3eb6174 100644 --- a/src/security/audit-channel-dm-policy.test.ts +++ b/src/security/audit-channel-dm-policy.test.ts @@ -22,6 +22,7 @@ describe("security audit channel dm policy", () => { capabilities: { chatTypes: ["direct"] }, config: { listAccountIds: () => ["default"], + inspectAccount: () => ({ enabled: true, configured: true }), resolveAccount: () => ({}), isEnabled: () => true, isConfigured: () => true, diff --git a/src/security/audit-channel-readonly-resolution.test.ts b/src/security/audit-channel-readonly-resolution.test.ts index 275fa777d62..a53ab13a546 100644 --- a/src/security/audit-channel-readonly-resolution.test.ts +++ b/src/security/audit-channel-readonly-resolution.test.ts @@ -23,6 +23,7 @@ function stubChannelPlugin(params: { security: {}, config: { listAccountIds: () => ["default"], + inspectAccount: () => null, resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId), isEnabled: () => true, isConfigured: () => true, diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 972df21048f..ead65992d90 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -89,13 +89,16 @@ export async function collectChannelSecurityFindings(params: { plugin: (typeof params.plugins)[number], cfg: OpenClawConfig, accountId: string, - ) => - plugin.config.inspectAccount?.(cfg, accountId) ?? - (await inspectReadOnlyChannelAccount({ + ) => { + if (plugin.config.inspectAccount) { + return await plugin.config.inspectAccount(cfg, accountId); + } + return await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - })); + }); + }; const asAccountRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 6931fc81717..2aa97584d5f 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -57,6 +57,10 @@ type ExecDockerRawFn = ( ) => Promise; type CodeSafetySummaryCache = Map>; +type WorkspaceSkillScanLimits = { + maxFiles?: number; + maxDirVisits?: number; +}; const MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE = 2_000; const MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS = 12; @@ -428,6 +432,7 @@ async function getCodeSafetySummary(params: { async function listWorkspaceSkillMarkdownFiles( workspaceDir: string, + limits: WorkspaceSkillScanLimits = {}, ): Promise<{ skillFilePaths: string[]; truncated: boolean }> { const skillsRoot = path.join(workspaceDir, "skills"); const rootStat = await safeStat(skillsRoot); @@ -435,18 +440,14 @@ async function listWorkspaceSkillMarkdownFiles( return { skillFilePaths: [], truncated: false }; } + const maxFiles = limits.maxFiles ?? MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE; + const maxTotalDirVisits = limits.maxDirVisits ?? maxFiles * 20; const skillFiles: string[] = []; const queue: string[] = [skillsRoot]; const visitedDirs = new Set(); - // Caps total BFS dequeues, not per-path depth. Named to reflect actual semantics. - const MAX_TOTAL_DIR_VISITS = MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE * 20; let totalDirVisits = 0; - while ( - queue.length > 0 && - skillFiles.length < MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE && - totalDirVisits++ < MAX_TOTAL_DIR_VISITS - ) { + while (queue.length > 0 && skillFiles.length < maxFiles && totalDirVisits++ < maxTotalDirVisits) { const dir = queue.shift()!; // Use the module-level realpathWithTimeout so a hanging network FS doesn't // block the BFS indefinitely (same 2 s guard as the outer escape-detection loop). @@ -965,6 +966,7 @@ export async function collectPluginsTrustFindings(params: { export async function collectWorkspaceSkillSymlinkEscapeFindings(params: { cfg: OpenClawConfig; + skillScanLimits?: WorkspaceSkillScanLimits; }): Promise { const findings: SecurityAuditFinding[] = []; const workspaceDirs = listAgentWorkspaceDirs(params.cfg); @@ -982,7 +984,10 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: { for (const workspaceDir of workspaceDirs) { const workspacePath = path.resolve(workspaceDir); const workspaceRealPath = (await realpathWithTimeout(workspacePath)) ?? workspacePath; - const { skillFilePaths, truncated } = await listWorkspaceSkillMarkdownFiles(workspacePath); + const { skillFilePaths, truncated } = await listWorkspaceSkillMarkdownFiles( + workspacePath, + params.skillScanLimits, + ); if (truncated) { // The BFS visit cap was hit before the full skills/ tree was scanned. diff --git a/src/security/audit-workspace-skill-escape.test.ts b/src/security/audit-workspace-skill-escape.test.ts index 4be9e63f162..82fbdf85614 100644 --- a/src/security/audit-workspace-skill-escape.test.ts +++ b/src/security/audit-workspace-skill-escape.test.ts @@ -117,15 +117,9 @@ describe("security audit workspace skill path escape findings", () => { const skillsRoot = path.join(workspaceDir, "skills"); await fs.mkdir(skillsRoot, { recursive: true }); - // Strategy: the first readdir (on skillsRoot) returns 41 001 unique subdir - // entries, filling the queue beyond the BFS visit cap - // (MAX_TOTAL_DIR_VISITS = 2000 * 20 = 40 000). All subsequent readdir calls - // return [] so no further queue growth occurs. After 40 000 dequeues the - // loop exits with ~1 001 entries still in queue → truncated = true. - // - // fs.realpath is also mocked to return paths immediately (no real I/O), - // keeping the 40 000 iterations fast (pure microtask overhead, <200 ms). - const FAKE_DIRS = 41_001; + // Use a tiny injected visit cap to exercise the truncation branch without + // forcing the test to await tens of thousands of mocked readdir calls. + const FAKE_DIRS = 3; const fakeDirEntries = Array.from({ length: FAKE_DIRS }, (_, i) => ({ name: `d${i}`, isDirectory: () => true, @@ -150,6 +144,7 @@ describe("security audit workspace skill path escape findings", () => { try { const findings = await collectWorkspaceSkillSymlinkEscapeFindings({ cfg: { agents: { defaults: { workspace: workspaceDir } } } satisfies OpenClawConfig, + skillScanLimits: { maxDirVisits: 2 }, }); const truncFinding = findings.find((f) => f.checkId === "skills.workspace.scan_truncated"); expect(truncFinding).toBeDefined();