High severity8.8GHSA Advisory· Published May 5, 2026· Updated May 7, 2026
CVE-2026-43571
CVE-2026-43571
Description
OpenClaw before 2026.4.10 contains a plugin trust bypass vulnerability that allows channel setup catalog lookups to resolve workspace plugin shadows before bundled channel plugins. Attackers can exploit this by crafting malicious workspace plugins that bypass intended trust gates during setup-time plugin loading.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.10 | 2026.4.10 |
Affected products
2Patches
11fede43b948dfix: exclude workspace shadows from channel setup catalog lookups
8 files changed · +654 −11
CHANGELOG.md+1 −0 modified@@ -194,6 +194,7 @@ Docs: https://docs.openclaw.ai - Cron/isolated: resolve auth profiles without treating every isolated run as a brand-new auth session, so profile-based providers (for example OpenRouter) keep a stable credential choice instead of rotating or ignoring stored keys. (#62783) Thanks @neeravmakwana. - CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana. - Sessions/context: resolve context window limits using the active provider plus model (not bare model id alone) when persisting session usage, applying inline directives, and sizing memory-flush / preflight compaction thresholds, so duplicate model ids across providers no longer leak the wrong `contextTokens` into the session store or `/status`. (#62472) Thanks @neeravmakwana. +- Channels/setup: exclude workspace shadow entries from channel setup catalog lookups and align trust checks with auto-enable so workspace-scoped overrides no longer bypass the trusted catalog. (`GHSA-82qx-6vj7-p8m2`) Thanks @zsxsoft. ## 2026.4.5
src/commands/channel-setup/discovery.ts+19 −7 modified@@ -1,15 +1,16 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { - listChannelPluginCatalogEntries, - type ChannelPluginCatalogEntry, -} from "../../channels/plugins/catalog.js"; +import { type ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import { isChannelVisibleInSetup } from "../../channels/plugins/exposure.js"; import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js"; import { listChatChannels } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; import type { ChannelChoice } from "../onboard-types.js"; +import { + listSetupDiscoveryChannelPluginCatalogEntries, + listTrustedChannelPluginCatalogEntries, +} from "./trusted-catalog.js"; type ChannelCatalogEntry = { id: ChannelChoice; @@ -75,14 +76,25 @@ export function resolveChannelSetupEntries(params: { env: params.env, }); const installedPluginIds = new Set(params.installedPlugins.map((plugin) => plugin.id)); - const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); - const installedCatalogEntries = catalogEntries.filter( + // Discovery keeps workspace-only install candidates visible, while the + // installed bucket must still reflect what setup can safely auto-load. + const installedCatalogEntriesSource = listTrustedChannelPluginCatalogEntries({ + cfg: params.cfg, + workspaceDir, + env: params.env, + }); + const installableCatalogEntriesSource = listSetupDiscoveryChannelPluginCatalogEntries({ + cfg: params.cfg, + workspaceDir, + env: params.env, + }); + const installedCatalogEntries = installedCatalogEntriesSource.filter( (entry) => !installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice) && shouldShowChannelInSetup(entry.meta), ); - const installableCatalogEntries = catalogEntries.filter( + const installableCatalogEntries = installableCatalogEntriesSource.filter( (entry) => !installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice) &&
src/commands/channel-setup/plugin-install.test.ts+62 −0 modified@@ -455,6 +455,9 @@ describe("ensureChannelSetupPluginInstalled", () => { includeSetupOnlyChannelPlugins: true, }), ); + expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", { + workspaceDir: "/tmp/openclaw-workspace", + }); }); it("keeps full reloads when the active plugin registry is already populated", () => { @@ -547,6 +550,65 @@ describe("ensureChannelSetupPluginInstalled", () => { activate: false, }), ); + expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", { + workspaceDir: "/tmp/openclaw-workspace", + }); + }); + + it("falls back to the bundled plugin for untrusted workspace shadows", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = {}; + getChannelPluginCatalogEntry + .mockReturnValueOnce({ pluginId: "evil-telegram-shadow", origin: "workspace" }) + .mockReturnValueOnce({ pluginId: "@openclaw/telegram-plugin", origin: "bundled" }); + + loadChannelSetupPluginRegistrySnapshotForChannel({ + cfg, + runtime, + channel: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["@openclaw/telegram-plugin"], + }), + ); + expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "telegram", { + workspaceDir: "/tmp/openclaw-workspace", + }); + expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "telegram", { + workspaceDir: "/tmp/openclaw-workspace", + excludeWorkspace: true, + }); + }); + + it("keeps trusted workspace overrides scoped during setup reloads", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = { + plugins: { + enabled: true, + allow: ["trusted-telegram-shadow"], + }, + }; + getChannelPluginCatalogEntry.mockReturnValue({ + pluginId: "trusted-telegram-shadow", + origin: "workspace", + }); + + loadChannelSetupPluginRegistrySnapshotForChannel({ + cfg, + runtime, + channel: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["trusted-telegram-shadow"], + }), + ); + expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1); }); it("does not scope by raw channel id when no trusted plugin mapping exists", () => {
src/commands/channel-setup/plugin-install.ts+3 −2 modified@@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { getChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -22,6 +21,7 @@ import type { PluginRegistry } from "../../plugins/registry.js"; import { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; +import { getTrustedChannelPluginCatalogEntry } from "./trusted-catalog.js"; type InstallChoice = "npm" | "local" | "skip"; @@ -274,7 +274,8 @@ function resolveScopedChannelPluginId(params: { return explicitPluginId; } return ( - getChannelPluginCatalogEntry(params.channel, { + getTrustedChannelPluginCatalogEntry(params.channel, { + cfg: params.cfg, workspaceDir: params.workspaceDir, })?.pluginId ?? resolveUniqueManifestScopedChannelPluginId(params) );
src/commands/channel-setup/trusted-catalog.ts+100 −0 added@@ -0,0 +1,100 @@ +import { + getChannelPluginCatalogEntry, + listChannelPluginCatalogEntries, + type ChannelPluginCatalogEntry, +} from "../../channels/plugins/catalog.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; +import { normalizePluginsConfig, resolveEnableState } from "../../plugins/config-state.js"; + +function resolveEffectiveTrustConfig(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): OpenClawConfig { + return applyPluginAutoEnable({ + config: cfg, + env: env ?? process.env, + }).config; +} + +function isTrustedWorkspaceChannelCatalogEntry( + entry: ChannelPluginCatalogEntry | undefined, + cfg: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): boolean { + if (entry?.origin !== "workspace") { + return true; + } + if (!entry.pluginId) { + return false; + } + const effectiveConfig = resolveEffectiveTrustConfig(cfg, env); + return resolveEnableState( + entry.pluginId, + "workspace", + normalizePluginsConfig(effectiveConfig.plugins), + ).enabled; +} + +export function getTrustedChannelPluginCatalogEntry( + channelId: string, + params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + }, +): ChannelPluginCatalogEntry | undefined { + const candidate = getChannelPluginCatalogEntry(channelId, { + workspaceDir: params.workspaceDir, + }); + if (isTrustedWorkspaceChannelCatalogEntry(candidate, params.cfg, params.env)) { + return candidate; + } + return getChannelPluginCatalogEntry(channelId, { + workspaceDir: params.workspaceDir, + excludeWorkspace: true, + }); +} + +export function listTrustedChannelPluginCatalogEntries(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ChannelPluginCatalogEntry[] { + const unfiltered = listChannelPluginCatalogEntries({ + workspaceDir: params.workspaceDir, + }); + const fallbackById = new Map( + listChannelPluginCatalogEntries({ + workspaceDir: params.workspaceDir, + excludeWorkspace: true, + }).map((entry) => [entry.id, entry]), + ); + return unfiltered.flatMap((entry) => { + if (isTrustedWorkspaceChannelCatalogEntry(entry, params.cfg, params.env)) { + return [entry]; + } + const fallback = fallbackById.get(entry.id); + return fallback ? [fallback] : []; + }); +} + +export function listSetupDiscoveryChannelPluginCatalogEntries(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ChannelPluginCatalogEntry[] { + const unfiltered = listChannelPluginCatalogEntries({ + workspaceDir: params.workspaceDir, + }); + const fallbackById = new Map( + listChannelPluginCatalogEntries({ + workspaceDir: params.workspaceDir, + excludeWorkspace: true, + }).map((entry) => [entry.id, entry]), + ); + return unfiltered.flatMap((entry) => { + if (isTrustedWorkspaceChannelCatalogEntry(entry, params.cfg, params.env)) { + return [entry]; + } + const fallback = fallbackById.get(entry.id); + return fallback ? [fallback] : [entry]; + }); +}
src/commands/channel-setup/workspace-shadow-bypass.test.ts+297 −0 added@@ -0,0 +1,297 @@ +/** + * Regression tests for GHSA-2qrv-rc5x-2g2h incomplete-fix bypass. + * + * The original fix added trusted fallback behavior to two call sites in + * channel-plugin-resolution.ts. Three other setup-flow call sites were + * missed. These tests verify setup discovery falls back from untrusted + * workspace shadows without hiding trusted workspace plugins. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks (hoisted to module top level) +// --------------------------------------------------------------------------- + +const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_opts?: unknown): unknown[] => [])); +const listChatChannels = vi.hoisted(() => vi.fn((): unknown[] => [])); +const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); +const applyPluginAutoEnable = vi.hoisted(() => + vi.fn(({ config }: { config: unknown }) => ({ + config: config as never, + changes: [] as string[], + autoEnabledReasons: {}, + })), +); +const getChannelPluginCatalogEntry = vi.hoisted(() => vi.fn()); + +vi.mock("../../channels/plugins/catalog.js", () => ({ + listChannelPluginCatalogEntries: (opts?: unknown) => listChannelPluginCatalogEntries(opts), + getChannelPluginCatalogEntry: (...args: unknown[]) => + getChannelPluginCatalogEntry(...(args as [string, Record<string, unknown>])), +})); +vi.mock("../../channels/registry.js", () => ({ + listChatChannels: () => listChatChannels(), +})); +vi.mock("../../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...a: unknown[]) => loadPluginManifestRegistry(...a), +})); +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: (a: unknown) => applyPluginAutoEnable(a as { config: unknown }), +})); +vi.mock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: vi.fn(), +})); + +import { resolveChannelSetupEntries } from "./discovery.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); + listChatChannels.mockReturnValue([]); +}); + +// --------------------------------------------------------------------------- +// Regression: resolveChannelSetupEntries (discovery.ts) +// --------------------------------------------------------------------------- + +describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-2g2h)", () => { + it("falls back to the bundled entry for untrusted workspace shadows", () => { + const workspaceEntry = { + id: "telegram", + pluginId: "evil-telegram-shadow", + origin: "workspace", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/", + blurb: "t", + order: 1, + }, + install: { npmSpec: "evil-telegram-shadow" }, + }; + const bundledEntry = { + id: "telegram", + pluginId: "@openclaw/telegram", + origin: "bundled", + meta: workspaceEntry.meta, + install: { npmSpec: "@openclaw/telegram" }, + }; + listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) => + (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace + ? [bundledEntry] + : [workspaceEntry], + ); + + resolveChannelSetupEntries({ + cfg: {} as never, + env: process.env, + installedPlugins: [], + }); + + const fallbackCall = listChannelPluginCatalogEntries.mock.calls.find( + ([opts]) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace === true, + ); + expect(fallbackCall).toBeTruthy(); + }); + + it("still returns bundled-origin entries", () => { + const bundledEntry = { + id: "telegram", + pluginId: "@openclaw/telegram", + origin: "bundled", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/", + blurb: "t", + order: 1, + }, + install: { npmSpec: "@openclaw/telegram" }, + }; + listChannelPluginCatalogEntries.mockReturnValue([bundledEntry]); + + const result = resolveChannelSetupEntries({ + cfg: {} as never, + env: process.env, + installedPlugins: [], + }); + + const allIds = [ + ...result.installedCatalogEntries.map((e: { id: string }) => e.id), + ...result.installableCatalogEntries.map((e: { id: string }) => e.id), + ]; + expect(allIds).toContain("telegram"); + }); + + it("keeps trusted workspace channel plugins visible in setup", () => { + const workspaceEntry = { + id: "telegram", + pluginId: "trusted-telegram-shadow", + origin: "workspace", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/", + blurb: "t", + order: 1, + }, + install: { npmSpec: "trusted-telegram-shadow" }, + }; + listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }], + diagnostics: [], + }); + + const result = resolveChannelSetupEntries({ + cfg: { + plugins: { + enabled: true, + allow: ["trusted-telegram-shadow"], + }, + } as never, + env: process.env, + installedPlugins: [], + }); + + expect( + result.installedCatalogEntries.map((entry: { pluginId?: string }) => entry.pluginId), + ).toEqual(["trusted-telegram-shadow"]); + }); + + it("treats auto-enabled workspace channel plugins as trusted during setup discovery", () => { + const workspaceEntry = { + id: "telegram", + pluginId: "trusted-telegram-shadow", + origin: "workspace", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/", + blurb: "t", + order: 1, + }, + install: { npmSpec: "trusted-telegram-shadow" }, + }; + listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]); + applyPluginAutoEnable.mockImplementation(({ config }: { config: unknown }) => ({ + config: { + ...(config as Record<string, unknown>), + plugins: { + enabled: true, + allow: ["trusted-telegram-shadow"], + }, + } as never, + changes: ["trusted-telegram-shadow"] as string[], + autoEnabledReasons: { + "trusted-telegram-shadow": ["channel configured"], + }, + })); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }], + diagnostics: [], + }); + + const result = resolveChannelSetupEntries({ + cfg: { + channels: { + telegram: { token: "existing-token" }, + }, + } as never, + env: process.env, + installedPlugins: [], + }); + + expect( + result.installedCatalogEntries.map((entry: { pluginId?: string }) => entry.pluginId), + ).toEqual(["trusted-telegram-shadow"]); + }); + + it("keeps workspace-only install candidates visible until the user trusts them", () => { + const workspaceEntry = { + id: "my-cool-plugin", + pluginId: "my-cool-plugin", + origin: "workspace", + meta: { + id: "my-cool-plugin", + label: "My Cool Plugin", + selectionLabel: "My Cool Plugin", + docsPath: "/", + blurb: "t", + order: 1, + }, + install: { npmSpec: "my-cool-plugin" }, + }; + listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) => + (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace + ? [] + : [workspaceEntry], + ); + + const result = resolveChannelSetupEntries({ + cfg: {} as never, + env: process.env, + installedPlugins: [], + }); + + expect( + result.installableCatalogEntries.map((entry: { pluginId?: string }) => entry.pluginId), + ).toEqual(["my-cool-plugin"]); + }); + + it("does not surface untrusted workspace-only entries as installed", () => { + const workspaceEntry = { + id: "my-cool-plugin", + pluginId: "my-cool-plugin", + origin: "workspace", + meta: { + id: "my-cool-plugin", + label: "My Cool Plugin", + selectionLabel: "My Cool Plugin", + docsPath: "/", + blurb: "t", + order: 1, + }, + install: { npmSpec: "my-cool-plugin" }, + }; + listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) => + (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace + ? [] + : [workspaceEntry], + ); + applyPluginAutoEnable.mockImplementation(({ config }: { config: unknown }) => ({ + config: { + ...(config as Record<string, unknown>), + plugins: {}, + } as never, + changes: [] as string[], + autoEnabledReasons: {}, + })); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "my-cool-plugin", channels: ["my-cool-plugin"] }], + diagnostics: [], + }); + + const result = resolveChannelSetupEntries({ + cfg: { + channels: { + "my-cool-plugin": { token: "existing-token" }, + }, + } as never, + env: process.env, + installedPlugins: [], + }); + + expect(result.installedCatalogEntries).toEqual([]); + expect(result.installableCatalogEntries).toEqual([]); + }); +});
src/flows/channel-setup.test.ts+168 −0 added@@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveAgentWorkspaceDir = vi.hoisted(() => + vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), +); +const resolveDefaultAgentId = vi.hoisted(() => vi.fn((_cfg?: unknown) => "default")); +const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_opts?: unknown): unknown[] => [])); +const getChannelPluginCatalogEntry = vi.hoisted(() => + vi.fn((_id?: unknown, _opts?: unknown) => undefined), +); +const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined)); +const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); +const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => + vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })), +); +const collectChannelStatus = vi.hoisted(() => + vi.fn(async (_params?: unknown) => ({ + installedPlugins: [], + catalogEntries: [], + installedCatalogEntries: [], + statusByChannel: new Map(), + statusLines: [], + })), +); +const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true)); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: (cfg?: unknown, agentId?: unknown) => + resolveAgentWorkspaceDir(cfg, agentId), + resolveDefaultAgentId: (cfg?: unknown) => resolveDefaultAgentId(cfg), +})); + +vi.mock("../channels/plugins/catalog.js", () => ({ + listChannelPluginCatalogEntries: (opts?: unknown) => listChannelPluginCatalogEntries(opts), + getChannelPluginCatalogEntry: (id?: unknown, opts?: unknown) => + getChannelPluginCatalogEntry(id, opts), +})); + +vi.mock("../channels/plugins/setup-registry.js", () => ({ + getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel), + listChannelSetupPlugins: () => listChannelSetupPlugins(), +})); + +vi.mock("../channels/registry.js", () => ({ + listChatChannels: () => [], +})); + +vi.mock("../commands/channel-setup/discovery.js", () => ({ + resolveChannelSetupEntries: vi.fn(), + shouldShowChannelInSetup: () => true, +})); + +vi.mock("../commands/channel-setup/plugin-install.js", () => ({ + ensureChannelSetupPluginInstalled: vi.fn(), + loadChannelSetupPluginRegistrySnapshotForChannel: (params?: unknown) => + loadChannelSetupPluginRegistrySnapshotForChannel(params), +})); + +vi.mock("../commands/channel-setup/registry.js", () => ({ + resolveChannelSetupWizardAdapterForPlugin: () => undefined, +})); + +vi.mock("../config/channel-configured.js", () => ({ + isChannelConfigured: (cfg?: unknown, channel?: unknown) => isChannelConfigured(cfg, channel), +})); + +vi.mock("./channel-setup.prompts.js", () => ({ + maybeConfigureDmPolicies: vi.fn(), + promptConfiguredAction: vi.fn(), + promptRemovalAccountId: vi.fn(), + formatAccountLabel: vi.fn(), +})); + +vi.mock("./channel-setup.status.js", () => ({ + collectChannelStatus: (params?: unknown) => collectChannelStatus(params), + noteChannelPrimer: vi.fn(), + noteChannelStatus: vi.fn(), + resolveChannelSelectionNoteLines: vi.fn(() => []), + resolveChannelSetupSelectionContributions: vi.fn(() => []), + resolveQuickstartDefault: vi.fn(() => undefined), +})); + +import { setupChannels } from "./channel-setup.js"; + +describe("setupChannels workspace shadow exclusion", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); + resolveDefaultAgentId.mockReturnValue("default"); + listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "telegram", + pluginId: "@openclaw/telegram-plugin", + }, + ]); + getChannelSetupPlugin.mockReturnValue(undefined); + listChannelSetupPlugins.mockReturnValue([]); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ + channels: [], + channelSetups: [], + }); + collectChannelStatus.mockResolvedValue({ + installedPlugins: [], + catalogEntries: [], + installedCatalogEntries: [], + statusByChannel: new Map(), + statusLines: [], + }); + isChannelConfigured.mockReturnValue(true); + }); + + it("preloads configured external plugins from the bundled fallback for untrusted shadows", async () => { + listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) => + (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace + ? [{ id: "telegram", pluginId: "@openclaw/telegram-plugin", origin: "bundled" }] + : [{ id: "telegram", pluginId: "evil-telegram-shadow", origin: "workspace" }], + ); + + await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => false), + note: vi.fn(async () => undefined), + } as never, + ); + + const fallbackCall = listChannelPluginCatalogEntries.mock.calls.find( + ([opts]) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace === true, + ); + expect(fallbackCall).toBeTruthy(); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + pluginId: "@openclaw/telegram-plugin", + workspaceDir: "/tmp/openclaw-workspace", + }), + ); + }); + + it("keeps trusted workspace overrides eligible during preload", async () => { + listChannelPluginCatalogEntries.mockReturnValue([ + { id: "telegram", pluginId: "trusted-telegram-shadow", origin: "workspace" }, + ]); + + await setupChannels( + { + plugins: { + enabled: true, + allow: ["trusted-telegram-shadow"], + }, + } as never, + {} as never, + { + confirm: vi.fn(async () => false), + note: vi.fn(async () => undefined), + } as never, + ); + + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + pluginId: "trusted-telegram-shadow", + workspaceDir: "/tmp/openclaw-workspace", + }), + ); + }); +});
src/flows/channel-setup.ts+4 −2 modified@@ -1,5 +1,4 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelSetupPlugin, @@ -17,6 +16,7 @@ import { loadChannelSetupPluginRegistrySnapshotForChannel, } from "../commands/channel-setup/plugin-install.js"; import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js"; +import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setup/trusted-catalog.js"; import type { ChannelSetupConfiguredResult, ChannelSetupResult, @@ -147,7 +147,9 @@ export async function setupChannels( const preloadConfiguredExternalPlugins = () => { // Keep setup memory bounded by snapshot-loading only configured external plugins. const workspaceDir = resolveWorkspaceDir(); - for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) { + // Security: keep trusted workspace overrides eligible during setup while + // falling back from untrusted workspace shadows to the non-workspace entry. + for (const entry of listTrustedChannelPluginCatalogEntries({ cfg: next, workspaceDir })) { const channel = entry.id as ChannelChoice; if (getVisibleChannelPlugin(channel)) { continue;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/1fede43b948df40ca8674511d4bd08d39f6c5837nvdPatchWEB
- github.com/advisories/GHSA-82qx-6vj7-p8m2ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-82qx-6vj7-p8m2nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-43571ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-untrusted-workspace-plugin-shadow-resolution-in-channel-setupnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.