Skip to content

Commit 5ecd01f

Browse files
committed
fix(plugins): trust managed npm peer links
1 parent 9ef35ea commit 5ecd01f

5 files changed

Lines changed: 143 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
- Matrix: persist pending approval reaction targets across Gateway restarts so room approvers can still approve or deny outstanding prompts after OpenClaw comes back online. (#75586) Thanks @amknight.
2525
- Channels/onboarding: map third-party official WeCom and Yuanbao catalog entries to their published plugin ids so npm installs pass expected-plugin validation. Thanks @vincentkoc.
2626
- Plugin SDK: restore the Mattermost and Matrix compatibility subpaths used by the pinned Yuanbao channel package so external installs can module-load after npm install. Thanks @vincentkoc.
27+
- Plugins/install: keep managed npm-root security scans from treating earlier plugin `openclaw` peer links as failures, so one external plugin install cannot poison later official npm installs. Thanks @vincentkoc.
2728
- CLI/plugins: keep `plugins enable` and `plugins disable` from creating unconfigured channel config sections, so channel plugins with required setup fields no longer fail validation during lifecycle probes. Thanks @vincentkoc.
2829
- Agents/sessions: keep delayed `sessions_send` A2A replies alive after soft wait-window timeouts, while preserving terminal run timeouts and avoiding stale target replies in requester sessions. Fixes #76443. Thanks @ryswork1993 and @vincentkoc.
2930
- CLI/sessions: keep intentional empty agent replies silent after tool-delivered channel output, instead of surfacing a misleading "No reply from agent." fallback. Thanks @vincentkoc.

src/plugins/install-security-scan.runtime.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,7 @@ function pathContainsNodeModulesSegment(relativePath: string): boolean {
177177
.includes("node_modules");
178178
}
179179

180-
function isTrustedOpenClawPeerSymlink(relativePath: string): boolean {
181-
const segments = relativePath.split(/[\\/]+/);
180+
function isPackageRootOpenClawPeerSymlink(segments: string[]): boolean {
182181
return (
183182
(segments.length === 2 && segments[0] === "node_modules" && segments[1] === "openclaw") ||
184183
(segments.length === 3 &&
@@ -188,6 +187,33 @@ function isTrustedOpenClawPeerSymlink(relativePath: string): boolean {
188187
);
189188
}
190189

190+
function isManagedNpmRootPackagePeerSymlink(segments: string[]): boolean {
191+
if (segments[0] !== "node_modules") {
192+
return false;
193+
}
194+
const packageEndIndex = segments[1]?.startsWith("@") ? 3 : 2;
195+
const packageNameSegments = segments.slice(1, packageEndIndex);
196+
if (
197+
packageNameSegments.length === 0 ||
198+
packageNameSegments.some((segment) => !segment || segment === "." || segment === "..")
199+
) {
200+
return false;
201+
}
202+
return isPackageRootOpenClawPeerSymlink(segments.slice(packageEndIndex));
203+
}
204+
205+
function isTrustedOpenClawPeerSymlink(params: {
206+
allowManagedNpmRootPackagePeerSymlinks?: boolean;
207+
relativePath: string;
208+
}): boolean {
209+
const segments = params.relativePath.split(/[\\/]+/);
210+
return (
211+
isPackageRootOpenClawPeerSymlink(segments) ||
212+
(params.allowManagedNpmRootPackagePeerSymlinks === true &&
213+
isManagedNpmRootPackagePeerSymlink(segments))
214+
);
215+
}
216+
191217
async function resolveTrustedHostOpenClawRootRealPath(): Promise<string | null> {
192218
const hostRoot = resolveOpenClawPackageRootSync({
193219
argv1: process.argv[1],
@@ -211,6 +237,7 @@ function isTrustedHostOpenClawPath(params: {
211237
}
212238

213239
async function inspectNodeModulesSymlinkTarget(params: {
240+
allowManagedNpmRootPackagePeerSymlinks?: boolean;
214241
rootRealPath: string;
215242
symlinkPath: string;
216243
symlinkRelativePath: string;
@@ -235,7 +262,10 @@ async function inspectNodeModulesSymlinkTarget(params: {
235262
// package. Trust only the exact peer-link shapes and only when the resolved
236263
// target stays inside the host package root.
237264
if (
238-
isTrustedOpenClawPeerSymlink(params.symlinkRelativePath) &&
265+
isTrustedOpenClawPeerSymlink({
266+
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
267+
relativePath: params.symlinkRelativePath,
268+
}) &&
239269
isTrustedHostOpenClawPath({
240270
resolvedTargetPath,
241271
trustedHostOpenClawRootRealPath: params.trustedHostOpenClawRootRealPath,
@@ -329,10 +359,12 @@ function resolvePackageManifestTraversalLimits(): PackageManifestTraversalLimits
329359
};
330360
}
331361

332-
async function collectPackageManifestPaths(
333-
rootDir: string,
334-
): Promise<PackageManifestTraversalResult> {
362+
async function collectPackageManifestPaths(params: {
363+
allowManagedNpmRootPackagePeerSymlinks?: boolean;
364+
rootDir: string;
365+
}): Promise<PackageManifestTraversalResult> {
335366
const limits = resolvePackageManifestTraversalLimits();
367+
const rootDir = params.rootDir;
336368
const rootRealPath = await fs.realpath(rootDir).catch(() => rootDir);
337369
const trustedHostOpenClawRootRealPath = await resolveTrustedHostOpenClawRootRealPath();
338370
const queue: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: rootDir }];
@@ -401,6 +433,7 @@ async function collectPackageManifestPaths(
401433
}
402434
if (pathContainsNodeModulesSegment(relativeNextPath)) {
403435
const symlinkTargetInspection = await inspectNodeModulesSymlinkTarget({
436+
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
404437
rootRealPath,
405438
symlinkPath: nextPath,
406439
symlinkRelativePath: relativeNextPath,
@@ -452,11 +485,15 @@ async function collectPackageManifestPaths(
452485
}
453486

454487
async function scanManifestDependencyDenylist(params: {
488+
allowManagedNpmRootPackagePeerSymlinks?: boolean;
455489
logger: InstallScanLogger;
456490
packageDir: string;
457491
targetLabel: string;
458492
}): Promise<InstallSecurityScanResult | undefined> {
459-
const traversalResult = await collectPackageManifestPaths(params.packageDir);
493+
const traversalResult = await collectPackageManifestPaths({
494+
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
495+
rootDir: params.packageDir,
496+
});
460497
const packageManifestPaths = traversalResult.packageManifestPaths;
461498
for (const manifestPath of packageManifestPaths) {
462499
let manifest: PackageManifest;
@@ -857,13 +894,15 @@ export async function scanPackageInstallSourceRuntime(
857894
}
858895

859896
export async function scanInstalledPackageDependencyTreeRuntime(params: {
897+
allowManagedNpmRootPackagePeerSymlinks?: boolean;
860898
logger: InstallScanLogger;
861899
packageDir: string;
862900
pluginId: string;
863901
}): Promise<InstallSecurityScanResult | undefined> {
864902
return await scanManifestDependencyDenylist({
865903
logger: params.logger,
866904
packageDir: params.packageDir,
905+
allowManagedNpmRootPackagePeerSymlinks: params.allowManagedNpmRootPackagePeerSymlinks,
867906
targetLabel: `Plugin "${params.pluginId}" installation`,
868907
});
869908
}

src/plugins/install-security-scan.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export async function scanPackageInstallSource(
7373
}
7474

7575
export async function scanInstalledPackageDependencyTree(params: {
76+
allowManagedNpmRootPackagePeerSymlinks?: boolean;
7677
logger: InstallScanLogger;
7778
packageDir: string;
7879
pluginId: string;

src/plugins/install.npm-spec.test.ts

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ function writeInstalledNpmPlugin(params: {
6161
indexJs?: string;
6262
dependency?: { name: string; version: string };
6363
hoistedDependency?: { name: string; version: string };
64+
peerDependencies?: Record<string, string>;
6465
}) {
6566
const pluginDir = path.join(params.npmRoot, "node_modules", params.packageName);
6667
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
@@ -73,6 +74,7 @@ function writeInstalledNpmPlugin(params: {
7374
...(params.dependency
7475
? { dependencies: { [params.dependency.name]: params.dependency.version } }
7576
: {}),
77+
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
7678
}),
7779
"utf-8",
7880
);
@@ -128,26 +130,60 @@ function mockNpmViewAndInstall(params: {
128130
indexJs?: string;
129131
dependency?: { name: string; version: string };
130132
hoistedDependency?: { name: string; version: string };
133+
peerDependencies?: Record<string, string>;
131134
}) {
135+
mockNpmViewAndInstallMany([params]);
136+
}
137+
138+
function mockNpmViewAndInstallMany(
139+
packages: Array<{
140+
spec: string;
141+
packageName: string;
142+
version: string;
143+
npmRoot: string;
144+
pluginId?: string;
145+
integrity?: string;
146+
shasum?: string;
147+
indexJs?: string;
148+
dependency?: { name: string; version: string };
149+
hoistedDependency?: { name: string; version: string };
150+
peerDependencies?: Record<string, string>;
151+
}>,
152+
) {
153+
const packagesBySpec = new Map(packages.map((pkg) => [pkg.spec, pkg]));
154+
const packagesByName = new Map(packages.map((pkg) => [pkg.packageName, pkg]));
132155
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
133-
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv(params.spec))) {
156+
const viewPackage = packages.find(
157+
(pkg) => JSON.stringify(argv) === JSON.stringify(npmViewArgv(pkg.spec)),
158+
);
159+
if (viewPackage) {
134160
return successfulSpawn(
135161
JSON.stringify({
136-
name: params.packageName,
137-
version: params.version,
162+
name: viewPackage.packageName,
163+
version: viewPackage.version,
138164
dist: {
139-
integrity: params.integrity ?? "sha512-plugin-test",
140-
shasum: params.shasum ?? "pluginshasum",
165+
integrity: viewPackage.integrity ?? "sha512-plugin-test",
166+
shasum: viewPackage.shasum ?? "pluginshasum",
141167
},
142168
}),
143169
);
144170
}
145171
if (argv[0] === "npm" && argv[1] === "install") {
146-
writeInstalledNpmPlugin(params);
172+
const spec = argv.at(-1);
173+
const pkg = spec ? packagesBySpec.get(spec) : undefined;
174+
if (!pkg) {
175+
throw new Error(`unexpected npm install spec: ${spec ?? ""}`);
176+
}
177+
writeInstalledNpmPlugin(pkg);
147178
return successfulSpawn();
148179
}
149180
if (argv[0] === "npm" && argv[1] === "uninstall") {
150-
fs.rmSync(path.join(params.npmRoot, "node_modules", params.packageName), {
181+
const packageName = argv.at(-1);
182+
const pkg = packageName ? packagesByName.get(packageName) : undefined;
183+
if (!pkg) {
184+
throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`);
185+
}
186+
fs.rmSync(path.join(pkg.npmRoot, "node_modules", pkg.packageName), {
151187
recursive: true,
152188
force: true,
153189
});
@@ -230,6 +266,55 @@ describe("installPluginFromNpmSpec", () => {
230266
}
231267
});
232268

269+
it.runIf(process.platform !== "win32")(
270+
"does not let managed openclaw peer links poison later npm installs",
271+
async () => {
272+
const stateDir = suiteTempRootTracker.makeTempDir();
273+
const npmRoot = path.join(stateDir, "npm");
274+
275+
mockNpmViewAndInstallMany([
276+
{
277+
spec: "peer-plugin@1.0.0",
278+
packageName: "peer-plugin",
279+
version: "1.0.0",
280+
pluginId: "peer-plugin",
281+
npmRoot,
282+
peerDependencies: { openclaw: "^2026.0.0" },
283+
},
284+
{
285+
spec: "next-plugin@1.0.0",
286+
packageName: "next-plugin",
287+
version: "1.0.0",
288+
pluginId: "next-plugin",
289+
npmRoot,
290+
},
291+
]);
292+
293+
const first = await installPluginFromNpmSpec({
294+
spec: "peer-plugin@1.0.0",
295+
npmDir: npmRoot,
296+
logger: { info: () => {}, warn: () => {} },
297+
});
298+
expect(first.ok).toBe(true);
299+
expect(
300+
fs
301+
.lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw"))
302+
.isSymbolicLink(),
303+
).toBe(true);
304+
305+
const second = await installPluginFromNpmSpec({
306+
spec: "next-plugin@1.0.0",
307+
npmDir: npmRoot,
308+
logger: { info: () => {}, warn: () => {} },
309+
});
310+
311+
expect(second.ok).toBe(true);
312+
if (!second.ok) {
313+
expect(second.error).not.toContain("peer-plugin/node_modules/openclaw");
314+
}
315+
},
316+
);
317+
233318
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
234319
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
235320
const warnings: string[] = [];

src/plugins/install.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,9 @@ async function scanAndLinkInstalledPackage(params: {
799799
subject: `Plugin "${params.pluginId}"`,
800800
scan: async () =>
801801
await params.runtime.scanInstalledPackageDependencyTree({
802+
allowManagedNpmRootPackagePeerSymlinks:
803+
params.dependencyScanRootDir !== undefined &&
804+
path.resolve(params.dependencyScanRootDir) !== path.resolve(params.installedDir),
802805
logger: params.logger,
803806
packageDir: params.dependencyScanRootDir ?? params.installedDir,
804807
pluginId: params.pluginId,

0 commit comments

Comments
 (0)