fix: allow trusted openclaw peer symlinks

This commit is contained in:
Peter Steinberger
2026-04-27 14:39:52 +01:00
parent cbf6ed2b35
commit c3b3da41fe
3 changed files with 172 additions and 0 deletions

View File

@@ -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 <name> 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.

View File

@@ -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<string | null> {
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<PackageManifestTraversalResult, "blockedDirectoryFinding" | "blockedFileFinding">
> {
@@ -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<PackageManifestTraversalResult> {
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<string>();
@@ -355,6 +403,7 @@ async function collectPackageManifestPaths(
rootRealPath,
symlinkPath: nextPath,
symlinkRelativePath: relativeNextPath,
trustedHostOpenClawRootRealPath,
});
if (symlinkTargetInspection.blockedDirectoryFinding) {
firstBlockedDirectoryFinding ??= symlinkTargetInspection.blockedDirectoryFinding;

View File

@@ -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<string, string>;
optionalDependencies?: Record<string, string>;
@@ -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();