diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a769cd7e65..1c956419706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`. - Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits. - Plugins/externalization: repair missing configured plugin installs from npm by default, reserve ClawHub downloads for explicit `clawhubSpec` metadata, and cover agent-runtime/env-selected plugin repair. Thanks @vincentkoc. +- Plugins/install: allow official catalog-matched npm channel plugins such as Feishu to pass the trusted install scanner path while keeping spoofed package names blocked. Thanks @vincentkoc. - Upgrade/config: validate configured web-search providers and statically suppressed model/provider pairs against the active plugin set at config load, so stale plugin state fails loud before runtime fallback. - Status/update: resolve beta update-channel checks from the installed version when config still says `stable`, and let `status --deep` reuse live gateway channel credential state instead of warning on command-path-only token misses. - Doctor/plugins: preserve unmanaged third-party plugin `node_modules` during `doctor --fix`, while still pruning OpenClaw-managed runtime dependency caches. diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index cb66176fd3d..6be8c8f46a0 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -298,6 +298,61 @@ describe("installPluginFromNpmSpec", () => { }); }); + it("allows official catalog-matched npm plugins through the trusted scanner path", async () => { + const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); + const warnings: string[] = []; + mockNpmViewAndInstall({ + spec: "@openclaw/feishu@2026.5.2", + packageName: "@openclaw/feishu", + version: "2026.5.2", + pluginId: "feishu", + npmRoot, + indexJs: `const token = process.env.FEISHU_BOT_TOKEN;\nfetch("https://open.feishu.cn/open-apis/bot/v2/hook", { headers: { authorization: token } });`, + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/feishu@2026.5.2", + expectedPluginId: "feishu", + npmDir: npmRoot, + logger: { + info: () => {}, + warn: (msg: string) => warnings.push(msg), + }, + }); + + expect(result.ok).toBe(true); + expect( + warnings.some((warning) => + warning.includes("allowed because it is an official OpenClaw package"), + ), + ).toBe(true); + }); + + it("keeps blocking dangerous npm installs that do not match the official catalog", async () => { + const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); + mockNpmViewAndInstall({ + spec: "@openclaw/feishu-spoof@2026.5.2", + packageName: "@openclaw/feishu-spoof", + version: "2026.5.2", + pluginId: "feishu", + npmRoot, + indexJs: `const token = process.env.FEISHU_BOT_TOKEN;\nfetch("https://open.feishu.cn/open-apis/bot/v2/hook", { headers: { authorization: token } });`, + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/feishu-spoof@2026.5.2", + expectedPluginId: "feishu", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain("dangerous code patterns detected"); + } + }); + it("rejects non-registry npm specs", async () => { const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" }); expect(result.ok).toBe(false); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index a0e41be21b6..20fecca261f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -34,6 +34,11 @@ import { resolvePackageExtensionEntries, type PackageManifest as PluginPackageManifest, } from "./manifest.js"; +import { + listOfficialExternalPluginCatalogEntries, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "./official-external-plugin-catalog.js"; import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js"; import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; @@ -110,7 +115,23 @@ type PluginInstallPolicyRequest = { }; const defaultLogger: PluginInstallLogger = {}; -const TRUSTED_OFFICIAL_NPM_PLUGIN_PACKAGES = new Map([["@openclaw/codex", "codex"]]); + +function listTrustedOfficialNpmPluginPackages(): Map { + const packages = new Map(); + for (const entry of listOfficialExternalPluginCatalogEntries()) { + if (entry.source !== "official") { + continue; + } + const pluginId = resolveOfficialExternalPluginId(entry); + const install = resolveOfficialExternalPluginInstall(entry); + const npmSpec = install?.npmSpec ? parseRegistryNpmSpec(install.npmSpec) : null; + if (!pluginId || !npmSpec) { + continue; + } + packages.set(npmSpec.name, pluginId); + } + return packages; +} function ensureOpenClawExtensions(params: { manifest: PackageManifest }): | { @@ -198,7 +219,7 @@ function isTrustedOfficialNpmPluginInstall(params: { if (!requested) { return false; } - const expectedPluginId = TRUSTED_OFFICIAL_NPM_PLUGIN_PACKAGES.get(requested.name); + const expectedPluginId = listTrustedOfficialNpmPluginPackages().get(requested.name); return ( expectedPluginId !== undefined && params.packageName === requested.name &&