mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix: allow trusted openclaw peer symlinks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user