From 54300e52705e91da3792f5ee640091625db8af6d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 02:40:08 -0700 Subject: [PATCH] fix(plugins): quiet official npm install scan warnings --- CHANGELOG.md | 1 + src/cli/plugin-install-plan.test.ts | 31 ++++++++++++ src/cli/plugin-install-plan.ts | 39 +++++++++++++++ src/cli/plugins-cli.install.test.ts | 50 +++++++++++++++++++ src/cli/plugins-install-command.ts | 51 ++++++++++++++++++++ src/plugins/install-security-scan.runtime.ts | 21 ++------ src/plugins/install.test.ts | 12 +---- 7 files changed, 179 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6671d1be2c..7c77ad198ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987. - Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987. +- Plugins/install: suppress dangerous-pattern scanner warnings for trusted official OpenClaw npm installs, so installing `@openclaw/discord` no longer prints credential-harvesting warnings for the official package. - Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc. - Plugins/security: ignore inline and block comments when matching source-rule context in plugin install scans, so comment-only `fetch`/`post` references near environment defaults do not block clean plugins. Thanks @vincentkoc. - Doctor/plugins: remove stale managed install records for bundled plugins even when the bundled plugin is not explicitly configured, so doctor cleanup cannot leave orphaned install metadata behind. Thanks @vincentkoc. diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index 2358f707b87..ed5a863afc3 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -6,6 +6,7 @@ import { resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, resolveOfficialExternalInstallPlanBeforeNpm, + resolveOfficialExternalNpmPackageTrust, } from "./plugin-install-plan.js"; describe("plugin install plan helpers", () => { @@ -86,6 +87,36 @@ describe("plugin install plan helpers", () => { expect(result).toBeNull(); }); + it("trusts exact official external npm packages without remapping the spec", () => { + const findOfficialExternalPackage = vi.fn().mockReturnValue({ + pluginId: "discord", + npmSpec: "@openclaw/discord", + }); + + const result = resolveOfficialExternalNpmPackageTrust({ + npmSpec: "@openclaw/discord", + findOfficialExternalPackage, + }); + + expect(findOfficialExternalPackage).toHaveBeenCalledWith("@openclaw/discord"); + expect(result).toEqual({ + pluginId: "discord", + trustedSourceLinkedOfficialInstall: true, + }); + }); + + it("does not trust npm package names outside the official external catalog", () => { + const findOfficialExternalPackage = vi.fn(); + + const result = resolveOfficialExternalNpmPackageTrust({ + npmSpec: "brave", + findOfficialExternalPackage, + }); + + expect(findOfficialExternalPackage).toHaveBeenCalledWith("brave"); + expect(result).toBeNull(); + }); + it("prefers bundled catalog plugin by id before npm spec", () => { const findBundledSource = vi .fn() diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index 039c5e43771..1829bf1d981 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -1,3 +1,4 @@ +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import type { BundledPluginSource } from "../plugins/bundled-sources.js"; import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js"; import { shortenHomePath } from "../utils.js"; @@ -15,6 +16,14 @@ type OfficialExternalPluginLookup = (pluginId: string) => } | undefined; +type OfficialExternalPackageLookup = (packageName: string) => + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined; + function isBareNpmPackageName(spec: string): boolean { const trimmed = spec.trim(); return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); @@ -92,6 +101,36 @@ export function resolveOfficialExternalInstallPlanBeforeNpm(params: { }; } +export function resolveOfficialExternalNpmPackageTrust(params: { + npmSpec: string; + findOfficialExternalPackage: OfficialExternalPackageLookup; +}): { + pluginId: string; + expectedIntegrity?: string; + trustedSourceLinkedOfficialInstall: true; +} | null { + const parsed = parseRegistryNpmSpec(params.npmSpec); + if (!parsed) { + return null; + } + const entry = params.findOfficialExternalPackage(parsed.name); + if (!entry?.pluginId) { + return null; + } + const catalogSpec = entry.npmSpec?.trim(); + const catalogPackageName = catalogSpec ? parseRegistryNpmSpec(catalogSpec)?.name : undefined; + if (catalogPackageName && catalogPackageName !== parsed.name) { + return null; + } + return { + pluginId: entry.pluginId, + ...(entry.expectedIntegrity && catalogSpec === params.npmSpec.trim() + ? { expectedIntegrity: entry.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + }; +} + export function resolveBundledInstallPlanForNpmFailure(params: { rawSpec: string; code?: string; diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 56973737cec..68da54a32cb 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -942,6 +942,56 @@ describe("plugins cli install", () => { expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); + it("marks explicit official npm package installs as trusted", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("discord"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("discord")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "npm:@openclaw/discord"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/discord", + expectedPluginId: "discord", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + }); + + it("marks scoped official npm package installs as trusted", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("discord"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("discord")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "@openclaw/discord"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/discord", + expectedPluginId: "discord", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + }); + it("passes the active profile extensions dir to npm installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 89b12bab717..32f29f0e310 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -22,6 +22,7 @@ import { resolveMarketplaceInstallShortcut, } from "../plugins/marketplace.js"; import { + getOfficialExternalPluginCatalogEntryForPackage, getOfficialExternalPluginCatalogEntry, resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, @@ -42,6 +43,7 @@ import { resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, resolveOfficialExternalInstallPlanBeforeNpm, + resolveOfficialExternalNpmPackageTrust, } from "./plugin-install-plan.js"; import { createHookPackInstallLogger, @@ -62,6 +64,29 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta }; } +function findTrustedOfficialExternalPackageInstall(packageName: string): + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined { + const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (entry?.source !== "official") { + return undefined; + } + const pluginId = resolveOfficialExternalPluginId(entry); + if (!pluginId) { + return undefined; + } + const install = resolveOfficialExternalPluginInstall(entry); + return { + pluginId, + ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), + ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + }; +} + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -693,6 +718,10 @@ export async function runPluginInstallCommand(params: { runtime.error("unsupported npm: spec: missing package"); return runtime.exit(1); } + const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ + npmSpec: npmPrefixSpec, + findOfficialExternalPackage: findTrustedOfficialExternalPackageInstall, + }); const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, installMode, @@ -701,6 +730,15 @@ export async function runPluginInstallCommand(params: { safetyOverrides, allowBundledFallback: false, extensionsDir, + ...(officialNpmTrust + ? { + expectedPluginId: officialNpmTrust.pluginId, + ...(officialNpmTrust.expectedIntegrity + ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + } + : {}), runtime, }); if (!npmPrefixResult.ok) { @@ -827,6 +865,10 @@ export async function runPluginInstallCommand(params: { return; } + const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ + npmSpec: raw, + findOfficialExternalPackage: findTrustedOfficialExternalPackageInstall, + }); const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, installMode, @@ -835,6 +877,15 @@ export async function runPluginInstallCommand(params: { safetyOverrides, allowBundledFallback: true, extensionsDir, + ...(officialNpmTrust + ? { + expectedPluginId: officialNpmTrust.pluginId, + ...(officialNpmTrust.expectedIntegrity + ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + } + : {}), runtime, }); if (!npmResult.ok) { diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index a1331159f22..f831d26b36b 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -561,6 +561,7 @@ async function scanDirectoryTarget(params: { includeFiles?: string[]; logger: InstallScanLogger; path: string; + suppressBuiltinWarnings?: boolean; suspiciousMessage: string; targetName: string; warningMessage: string; @@ -571,6 +572,9 @@ async function scanDirectoryTarget(params: { includeFiles: params.includeFiles, }); const builtinScan = buildBuiltinScanFromSummary(scanSummary); + if (params.suppressBuiltinWarnings) { + return builtinScan; + } if (scanSummary.critical > 0) { params.logger.warn?.( `${params.warningMessage}: ${buildCriticalDetails({ findings: scanSummary.findings })}`, @@ -632,16 +636,6 @@ function logDangerousForceUnsafeInstall(params: { ); } -function logTrustedSourceLinkedOfficialInstall(params: { - findings: Array<{ file: string; line: number; message: string; severity: string }>; - logger: InstallScanLogger; - targetLabel: string; -}) { - params.logger.warn?.( - `WARNING: ${params.targetLabel} allowed because it is an official OpenClaw package: ${buildCriticalDetails({ findings: params.findings })}`, - ); -} - function resolveBuiltinScanDecision( params: InstallSafetyOverrides & { builtinScan: BuiltinInstallScan; @@ -661,12 +655,6 @@ function resolveBuiltinScanDecision( logger: params.logger, targetLabel: params.targetLabel, }); - } else if (params.trustedSourceLinkedOfficialInstall && params.builtinScan.critical > 0) { - logTrustedSourceLinkedOfficialInstall({ - findings: params.builtinScan.findings, - logger: params.logger, - targetLabel: params.targetLabel, - }); } return builtinBlocked; } @@ -857,6 +845,7 @@ export async function scanPackageInstallSourceRuntime( includeFiles: forcedScanEntries, logger: params.logger, path: params.packageDir, + suppressBuiltinWarnings: params.trustedSourceLinkedOfficialInstall === true, suspiciousMessage: `Plugin "{target}" has {count} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, targetName: params.pluginId, warningMessage: `WARNING: Plugin "${params.pluginId}" contains dangerous code patterns`, diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index a74f63ef93b..b8b6a354a27 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -771,11 +771,7 @@ describe("installPluginFromArchive", () => { }); expect(result.ok).toBe(true); - expect( - warnings.some((warning) => - warning.includes("allowed because it is an official OpenClaw package"), - ), - ).toBe(true); + expect(warnings).toEqual([]); }); it("installs flat-root plugin archives from ClawHub-style downloads", async () => { @@ -1990,11 +1986,7 @@ describe("installPluginFromArchive", () => { }); expect(result.ok).toBe(true); - expect( - warnings.some((warning) => - warning.includes("allowed because it is an official OpenClaw package"), - ), - ).toBe(true); + expect(warnings).toEqual([]); }); it("does not flag the real qa-matrix plugin as dangerous install code", async () => {