From c3b3da41fe2b95382c901a5f620ae2122f934586 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:39:52 +0100 Subject: [PATCH] fix: allow trusted openclaw peer symlinks --- CHANGELOG.md | 1 + src/plugins/install-security-scan.runtime.ts | 49 ++++++++ src/plugins/install.test.ts | 122 +++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 027717a5668..b4485229a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - 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. - 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: 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/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo. - Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111. diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index c8924f22e63..ad488e2950a 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; import { @@ -175,10 +176,44 @@ function pathContainsNodeModulesSegment(relativePath: string): boolean { .includes("node_modules"); } +function isTrustedOpenClawPeerSymlink(relativePath: string): boolean { + const segments = relativePath.split(/[\\/]+/); + return ( + (segments.length === 2 && segments[0] === "node_modules" && segments[1] === "openclaw") || + (segments.length === 3 && + segments[0] === "node_modules" && + segments[1] === ".bin" && + segments[2] === "openclaw") + ); +} + +async function resolveTrustedHostOpenClawRootRealPath(): Promise { + const hostRoot = resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url, + }); + if (!hostRoot) { + return null; + } + return await fs.realpath(hostRoot).catch(() => path.resolve(hostRoot)); +} + +function isTrustedHostOpenClawPath(params: { + resolvedTargetPath: string; + trustedHostOpenClawRootRealPath: string | null; +}): boolean { + return ( + params.trustedHostOpenClawRootRealPath !== null && + isPathInside(params.trustedHostOpenClawRootRealPath, params.resolvedTargetPath) + ); +} + async function inspectNodeModulesSymlinkTarget(params: { rootRealPath: string; symlinkPath: string; symlinkRelativePath: string; + trustedHostOpenClawRootRealPath: string | null; }): Promise< Pick > { @@ -195,6 +230,18 @@ async function inspectNodeModulesSymlinkTarget(params: { } if (!isPathInside(params.rootRealPath, resolvedTargetPath)) { + // Workspace package managers can leave peer links back to the OpenClaw host + // package. Trust only the exact peer-link shapes and only when the resolved + // target stays inside the host package root. + if ( + isTrustedOpenClawPeerSymlink(params.symlinkRelativePath) && + isTrustedHostOpenClawPath({ + resolvedTargetPath, + trustedHostOpenClawRootRealPath: params.trustedHostOpenClawRootRealPath, + }) + ) { + return {}; + } throw new Error( `manifest dependency scan found node_modules symlink target outside install root at ${params.symlinkRelativePath}`, ); @@ -286,6 +333,7 @@ async function collectPackageManifestPaths( ): Promise { const limits = resolvePackageManifestTraversalLimits(); const rootRealPath = await fs.realpath(rootDir).catch(() => rootDir); + const trustedHostOpenClawRootRealPath = await resolveTrustedHostOpenClawRootRealPath(); const queue: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: rootDir }]; const packageManifestPaths: string[] = []; const visitedDirectories = new Set(); @@ -355,6 +403,7 @@ async function collectPackageManifestPaths( rootRealPath, symlinkPath: nextPath, symlinkRelativePath: relativeNextPath, + trustedHostOpenClawRootRealPath, }); if (symlinkTargetInspection.blockedDirectoryFinding) { firstBlockedDirectoryFinding ??= symlinkTargetInspection.blockedDirectoryFinding; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 17fee1da4dd..03bc35c8b09 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -168,6 +168,18 @@ function setupPluginInstallDirs() { return { tmpDir, pluginDir, extensionsDir }; } +function writeMinimalPackagePlugin(pluginDir: string, name: string): void { + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name, + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); +} + function setupInstallPluginFromDirFixture(params?: { devDependencies?: Record; optionalDependencies?: Record; @@ -1402,6 +1414,116 @@ describe("installPluginFromArchive", () => { }, ); + it.runIf(process.platform !== "win32")( + "allows package installs when node_modules/openclaw points at the host package root", + async () => { + const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); + const hostRoot = path.join(tmpDir, "host-openclaw"); + fs.mkdirSync(hostRoot, { recursive: true }); + fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); + vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); + writeMinimalPackagePlugin(pluginDir, "openclaw-peer-plugin"); + + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync(hostRoot, path.join(nodeModulesDir, "openclaw"), "junction"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + }, + ); + + it.runIf(process.platform !== "win32")( + "allows package installs when node_modules/.bin/openclaw points inside the host package root", + async () => { + const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); + const hostRoot = path.join(tmpDir, "host-openclaw"); + fs.mkdirSync(hostRoot, { recursive: true }); + fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); + const hostBin = path.join(hostRoot, "openclaw.mjs"); + fs.writeFileSync(hostBin, "#!/usr/bin/env node\n"); + vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); + writeMinimalPackagePlugin(pluginDir, "openclaw-bin-peer-plugin"); + + const binDir = path.join(pluginDir, "node_modules", ".bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.symlinkSync(hostBin, path.join(binDir, "openclaw"), "file"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + }, + ); + + it.runIf(process.platform !== "win32")( + "fails package installs when node_modules/openclaw points outside the host package root", + async () => { + const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); + const hostRoot = path.join(tmpDir, "host-openclaw"); + const spoofedRoot = path.join(tmpDir, "spoofed-openclaw"); + fs.mkdirSync(hostRoot, { recursive: true }); + fs.mkdirSync(spoofedRoot, { recursive: true }); + fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); + fs.writeFileSync(path.join(spoofedRoot, "package.json"), '{"name":"openclaw"}\n'); + vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); + writeMinimalPackagePlugin(pluginDir, "spoofed-openclaw-peer-plugin"); + + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync(spoofedRoot, path.join(nodeModulesDir, "openclaw"), "junction"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain("node_modules/openclaw"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "fails package installs for nested or non-exact openclaw node_modules symlinks", + async () => { + const cases = [ + { + pluginName: "nested-openclaw-peer-plugin", + relativePath: path.join("node_modules", "vendor", "node_modules", "openclaw"), + }, + { + pluginName: "uppercase-openclaw-peer-plugin", + relativePath: path.join("node_modules", "OpenClaw"), + }, + { + pluginName: "trailing-space-openclaw-peer-plugin", + relativePath: path.join("node_modules", "openclaw "), + }, + ] as const; + + for (const testCase of cases) { + const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); + const hostRoot = path.join(tmpDir, "host-openclaw"); + fs.mkdirSync(hostRoot, { recursive: true }); + fs.writeFileSync(path.join(hostRoot, "package.json"), '{"name":"openclaw"}\n'); + vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(hostRoot); + writeMinimalPackagePlugin(pluginDir, testCase.pluginName); + + const symlinkPath = path.join(pluginDir, testCase.relativePath); + fs.mkdirSync(path.dirname(symlinkPath), { recursive: true }); + fs.symlinkSync(hostRoot, symlinkPath, "junction"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain(testCase.relativePath); + } + } + }, + ); + it("does not block package installs for blocked-looking names outside node_modules", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs();