fix: preserve scoped plugin symlink installs

This commit is contained in:
Peter Steinberger
2026-04-10 19:29:44 +01:00
parent 3198c10fba
commit c9067b6520
2 changed files with 58 additions and 28 deletions

View File

@@ -390,6 +390,35 @@ async function scanManifestDependencyDenylist(params: {
targetLabel: string;
}): Promise<InstallSecurityScanResult | undefined> {
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;
}

View File

@@ -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 () => {