From a5dce367cec8b6f3abdc30ae52e629181279820a Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Tue, 12 May 2026 20:28:40 +0530 Subject: [PATCH] fix: scan plugin runtime entries during install [AI] (#80998) * fix: scan plugin runtime entries during install * addressing review-skill * addressing claude review * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + src/plugins/install-security-scan.runtime.ts | 58 +++++++++- src/plugins/install-security-scan.ts | 7 ++ src/plugins/install.test.ts | 113 +++++++++++++++++++ src/plugins/install.ts | 1 + 5 files changed, 177 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d508a9b71b33..d980d3562e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix: scan plugin runtime entries during install [AI]. (#80998) Thanks @pgondhi987. - Require auth for sandbox browser CDP relay [AI]. (#81002) Thanks @pgondhi987. - fix: detect carried exec command forms [AI]. (#81000) Thanks @pgondhi987. - Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987. diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index cd60bb60fa7e..be8cec5dd1ed 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -4,6 +4,7 @@ import { tryReadJson } from "../infra/json-files.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { findBlockedPackageDirectoryInPath, findBlockedPackageFileAliasInPath, @@ -14,6 +15,7 @@ import { import { getGlobalHookRunner } from "./hook-runner-global.js"; import { createBeforeInstallHookPayload } from "./install-policy-context.js"; import type { InstallSafetyOverrides } from "./install-security-scan.types.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; type InstallScanLogger = { warn?: (message: string) => void; @@ -45,6 +47,12 @@ type PackageManifest = { peerDependencies?: Record; }; +type PackageExecutableScanMetadata = { + runtimeExtensions?: readonly string[]; + runtimeSetupEntry?: string; + setupEntry?: string; +}; + type PackageManifestTraversalLimits = { maxDepth: number; maxDirectories: number; @@ -591,6 +599,45 @@ async function scanDirectoryTarget(params: { } } +function readStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + +function collectPackageExecutableScanEntries(params: { + extensions: string[]; + packageMetadata?: PackageExecutableScanMetadata; +}): string[] { + const entries: string[] = []; + const metadata = params.packageMetadata; + const runtimeExtensions = readStringList(metadata?.runtimeExtensions); + for (const [index, extensionEntry] of params.extensions.entries()) { + entries.push(extensionEntry); + const runtimeEntry = runtimeExtensions[index]; + if (runtimeEntry) { + entries.push(runtimeEntry); + continue; + } + entries.push(...listBuiltRuntimeEntryCandidates(extensionEntry)); + } + + const setupEntry = normalizeOptionalString(metadata?.setupEntry); + if (setupEntry) { + entries.push(setupEntry); + } + const runtimeSetupEntry = normalizeOptionalString(metadata?.runtimeSetupEntry); + if (runtimeSetupEntry) { + entries.push(runtimeSetupEntry); + } else if (setupEntry) { + entries.push(...listBuiltRuntimeEntryCandidates(setupEntry)); + } + return [...new Set(entries)]; +} + function buildBlockedScanResult(params: { builtinScan: BuiltinInstallScan; dangerouslyForceUnsafeInstall?: boolean; @@ -805,6 +852,7 @@ export async function scanPackageInstallSourceRuntime( extensions: string[]; logger: InstallScanLogger; packageDir: string; + packageMetadata?: PackageExecutableScanMetadata; pluginId: string; requestKind?: PluginInstallRequestKind; requestedSpecifier?: string; @@ -824,17 +872,21 @@ export async function scanPackageInstallSourceRuntime( } const forcedScanEntries: string[] = []; - for (const entry of params.extensions) { + const executableEntries = collectPackageExecutableScanEntries({ + extensions: params.extensions, + ...(params.packageMetadata ? { packageMetadata: params.packageMetadata } : {}), + }); + for (const entry of executableEntries) { const resolvedEntry = path.resolve(params.packageDir, entry); if (!isPathInside(params.packageDir, resolvedEntry)) { params.logger.warn?.( - `extension entry escapes plugin directory and will not be scanned: ${entry}`, + `plugin executable entry escapes plugin directory and will not be scanned: ${entry}`, ); continue; } if (extensionUsesSkippedScannerPath(entry)) { params.logger.warn?.( - `extension entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`, + `plugin executable entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`, ); } forcedScanEntries.push(resolvedEntry); diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 0c8e4689cd20..14ea1c4d13f6 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -35,6 +35,12 @@ export type SkillInstallSpecMetadata = { targetDir?: string; }; +export type PackageExecutableScanMetadata = { + runtimeExtensions?: readonly string[]; + runtimeSetupEntry?: string; + setupEntry?: string; +}; + async function loadInstallSecurityScanRuntime() { return await import("./install-security-scan.runtime.js"); } @@ -59,6 +65,7 @@ export async function scanPackageInstallSource( extensions: string[]; logger: InstallScanLogger; packageDir: string; + packageMetadata?: PackageExecutableScanMetadata; pluginId: string; requestKind?: PluginInstallRequestKind; requestedSpecifier?: string; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index c401269bda45..6ffa61e3cc92 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -2606,6 +2606,119 @@ describe("installPluginFromArchive", () => { expectWarningIncludes(warnings, "dangerous code pattern"); }); + it("scans runtime extension entry files in hidden directories", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hidden-runtime-entry-plugin", + version: "1.0.0", + openclaw: { + extensions: ["index.js"], + runtimeExtensions: [".hidden/runtime.cjs"], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};\n"); + fs.writeFileSync( + path.join(pluginDir, ".hidden", "runtime.cjs"), + `const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + expectWarningIncludes(warnings, "hidden/node_modules path"); + expectWarningIncludes(warnings, "dangerous code pattern"); + }); + + it("scans setup entry files in hidden directories", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hidden-setup-entry-plugin", + version: "1.0.0", + openclaw: { + extensions: ["index.js"], + setupEntry: ".hidden/setup.cjs", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};\n"); + fs.writeFileSync( + path.join(pluginDir, ".hidden", "setup.cjs"), + `const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + expectWarningIncludes(warnings, "hidden/node_modules path"); + expectWarningIncludes(warnings, "dangerous code pattern"); + }); + + it("scans runtime setup entry files in hidden directories", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hidden-runtime-setup-entry-plugin", + version: "1.0.0", + openclaw: { + extensions: ["index.js"], + setupEntry: "setup.ts", + runtimeSetupEntry: ".hidden/setup.cjs", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};\n"); + fs.writeFileSync(path.join(pluginDir, "setup.ts"), "export {};\n"); + fs.writeFileSync( + path.join(pluginDir, ".hidden", "setup.cjs"), + `const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + expectWarningIncludes(warnings, "hidden/node_modules path"); + expectWarningIncludes(warnings, "dangerous code pattern"); + }); + + it("scans inferred runtime entry files in hidden directories", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true }); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hidden-inferred-runtime-entry-plugin", + version: "1.0.0", + openclaw: { + extensions: [".hidden/index.ts"], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, ".hidden", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(pluginDir, ".hidden", "index.js"), + `const { execFileSync } = require("child_process");\nexecFileSync(process.execPath, ["-e", ""]);`, + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + expectWarningIncludes(warnings, "hidden/node_modules path"); + expectWarningIncludes(warnings, "dangerous code pattern"); + }); + it("blocks install when scanner throws", async () => { const scanSpy = vi .spyOn(installSecurityScan, "scanPackageInstallSource") diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 48023c9e66b9..6dc7fc7cc7a2 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1167,6 +1167,7 @@ async function validatePackagePluginInstallSource(params: { pluginId, logger: params.logger, extensions, + ...(packageMetadata ? { packageMetadata } : {}), requestKind: params.installPolicyRequest?.kind, requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, mode: scanMode,