From c9067b652090f4aa6ae4e2730060ef3c8ed5a67d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Apr 2026 19:29:44 +0100 Subject: [PATCH] fix: preserve scoped plugin symlink installs --- src/plugins/install-security-scan.runtime.ts | 57 ++++++++++---------- src/plugins/install.test.ts | 29 ++++++++++ 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 4cadf448b26..1506ac32be4 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -390,6 +390,35 @@ async function scanManifestDependencyDenylist(params: { targetLabel: string; }): Promise { const traversalResult = await collectPackageManifestPaths(params.packageDir); + if (traversalResult.blockedDirectoryFinding) { + const reason = buildBlockedDependencyDirectoryReason({ + dependencyName: traversalResult.blockedDirectoryFinding.dependencyName, + directoryRelativePath: traversalResult.blockedDirectoryFinding.directoryRelativePath, + targetLabel: params.targetLabel, + }); + params.logger.warn?.(`WARNING: ${reason}`); + return { + blocked: { + code: "security_scan_blocked", + reason, + }, + }; + } + if (traversalResult.blockedFileFinding) { + const reason = buildBlockedDependencyFileReason({ + dependencyName: traversalResult.blockedFileFinding.dependencyName, + fileRelativePath: traversalResult.blockedFileFinding.fileRelativePath, + targetLabel: params.targetLabel, + }); + params.logger.warn?.(`WARNING: ${reason}`); + return { + blocked: { + code: "security_scan_blocked", + reason, + }, + }; + } + const packageManifestPaths = traversalResult.packageManifestPaths; for (const manifestPath of packageManifestPaths) { let manifest: PackageManifest; @@ -419,34 +448,6 @@ async function scanManifestDependencyDenylist(params: { }, }; } - if (traversalResult.blockedDirectoryFinding) { - const reason = buildBlockedDependencyDirectoryReason({ - dependencyName: traversalResult.blockedDirectoryFinding.dependencyName, - directoryRelativePath: traversalResult.blockedDirectoryFinding.directoryRelativePath, - targetLabel: params.targetLabel, - }); - params.logger.warn?.(`WARNING: ${reason}`); - return { - blocked: { - code: "security_scan_blocked", - reason, - }, - }; - } - if (traversalResult.blockedFileFinding) { - const reason = buildBlockedDependencyFileReason({ - dependencyName: traversalResult.blockedFileFinding.dependencyName, - fileRelativePath: traversalResult.blockedFileFinding.fileRelativePath, - targetLabel: params.targetLabel, - }); - params.logger.warn?.(`WARNING: ${reason}`); - return { - blocked: { - code: "security_scan_blocked", - reason, - }, - }; - } return undefined; } diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 8198f766d63..b32d09c0f9f 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -1114,6 +1114,35 @@ describe("installPluginFromArchive", () => { }, ); + it.runIf(process.platform !== "win32")( + "does not block package installs when node_modules symlink targets an allowed scoped package path", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "allowed-scoped-symlink-target-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const scopedTargetDir = path.join(pluginDir, "vendor", "@scope", "plain-crypto-js"); + fs.mkdirSync(scopedTargetDir, { recursive: true }); + fs.writeFileSync(path.join(scopedTargetDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../@scope/plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + }, + ); + it.runIf(process.platform !== "win32")( "fails package installs when node_modules symlink target escapes the install root", async () => {