From 53c29df2a9eb242a70d0ff29f3d1e67c8d6801f0 Mon Sep 17 00:00:00 2001 From: mappel-nv Date: Thu, 2 Apr 2026 10:40:23 -0400 Subject: [PATCH] Channel setup: ignore untrusted workspace shadows (#59158) Keeps untrusted workspace channel metadata from overriding setup/login resolution for built-in channels. Workspace channel entries are only eligible during setup when the plugin is already explicitly trusted in config. - Track discovered origin on channel catalog entries and add a setup-time catalog lookup that excludes workspace discoveries when needed - Add resolver regression coverage for untrusted shadowing and trusted workspace overrides Thanks @mappel-nv --- CHANGELOG.md | 1 + src/channels/plugins/catalog.ts | 6 + .../channel-plugin-resolution.test.ts | 159 ++++++++++++++++++ .../channel-plugin-resolution.ts | 55 +++++- 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/commands/channel-setup/channel-plugin-resolution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ab7dcb24f..51b53348a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Exec approvals/config: strip invalid `security`, `ask`, and `askFallback` values from `~/.openclaw/exec-approvals.json` during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf. - Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson. - MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux. +- Channels/setup: ignore untrusted workspace channel plugins during setup resolution so a shadowing workspace plugin cannot override built-in channel setup/login flows unless explicitly trusted in config. (#59158) Thanks @mappel-nv. - Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong. - Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared `safeEqualSecret` helper and reject empty auth tokens in BlueBubbles. (#58432) Thanks @eleqtrizit. - OpenShell/mirror: constrain `remoteWorkspaceDir` and `remoteAgentWorkspaceDir` to the managed `/sandbox` and `/agent` roots so mirror sync cannot escape the intended remote workspace paths. (#58515) Thanks @eleqtrizit. diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 3bc54ebb48c..473c4b31019 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -30,6 +30,7 @@ export type ChannelUiCatalog = { export type ChannelPluginCatalogEntry = { id: string; pluginId?: string; + origin?: PluginOrigin; meta: ChannelMeta; install: { npmSpec: string; @@ -43,6 +44,7 @@ type CatalogOptions = { catalogPaths?: string[]; officialCatalogPaths?: string[]; env?: NodeJS.ProcessEnv; + excludeWorkspace?: boolean; }; const ORIGIN_PRIORITY: Record = { @@ -294,6 +296,7 @@ function buildCatalogEntry(candidate: { return { id, ...(pluginId ? { pluginId } : {}), + ...(candidate.origin ? { origin: candidate.origin } : {}), meta, install, }; @@ -385,6 +388,9 @@ export function listChannelPluginCatalogEntries( const resolved = new Map(); for (const candidate of discovery.candidates) { + if (options.excludeWorkspace && candidate.origin === "workspace") { + continue; + } const entry = buildCatalogEntry(candidate); if (!entry) { continue; diff --git a/src/commands/channel-setup/channel-plugin-resolution.test.ts b/src/commands/channel-setup/channel-plugin-resolution.test.ts new file mode 100644 index 00000000000..f853795fc7d --- /dev/null +++ b/src/commands/channel-setup/channel-plugin-resolution.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; + +const mocks = vi.hoisted(() => ({ + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentId: vi.fn(() => "default"), + listChannelPluginCatalogEntries: vi.fn(), + getChannelPluginCatalogEntry: vi.fn(), + getChannelPlugin: vi.fn(), + loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), + ensureChannelSetupPluginInstalled: vi.fn(), + createClackPrompter: vi.fn(() => ({}) as never), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +vi.mock("../../channels/plugins/catalog.js", () => ({ + listChannelPluginCatalogEntries: mocks.listChannelPluginCatalogEntries, + getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry, +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: mocks.getChannelPlugin, + normalizeChannelId: (value: unknown) => (typeof value === "string" ? value.trim() || null : null), +})); + +vi.mock("./plugin-install.js", () => ({ + loadChannelSetupPluginRegistrySnapshotForChannel: + mocks.loadChannelSetupPluginRegistrySnapshotForChannel, + ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled, +})); + +vi.mock("../../wizard/clack-prompter.js", () => ({ + createClackPrompter: mocks.createClackPrompter, +})); + +import { resolveInstallableChannelPlugin } from "./channel-plugin-resolution.js"; + +function createCatalogEntry(params: { + id: string; + pluginId: string; + origin?: "workspace" | "bundled"; +}): ChannelPluginCatalogEntry { + return { + id: params.id, + pluginId: params.pluginId, + origin: params.origin, + meta: { + id: params.id, + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram channel", + }, + install: { + npmSpec: params.pluginId, + }, + }; +} + +function createPlugin(id: string): ChannelPlugin { + return { id } as ChannelPlugin; +} + +describe("resolveInstallableChannelPlugin", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getChannelPlugin.mockReturnValue(undefined); + mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({ + cfg: {}, + installed: false, + }); + }); + + it("ignores untrusted workspace channel shadows during setup resolution", async () => { + const workspaceEntry = createCatalogEntry({ + id: "telegram", + pluginId: "evil-telegram-shadow", + origin: "workspace", + }); + const bundledEntry = createCatalogEntry({ + id: "telegram", + pluginId: "telegram", + origin: "bundled", + }); + const bundledPlugin = createPlugin("telegram"); + + mocks.listChannelPluginCatalogEntries.mockImplementation( + ({ excludeWorkspace }: { excludeWorkspace?: boolean }) => + excludeWorkspace ? [bundledEntry] : [workspaceEntry], + ); + mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockImplementation( + ({ pluginId }: { pluginId?: string }) => ({ + channels: pluginId === "telegram" ? [{ plugin: bundledPlugin }] : [], + channelSetups: [], + }), + ); + + const result = await resolveInstallableChannelPlugin({ + cfg: { plugins: { enabled: true } }, + runtime: {} as never, + rawChannel: "telegram", + allowInstall: false, + }); + + expect(result.catalogEntry?.pluginId).toBe("telegram"); + expect(result.plugin?.id).toBe("telegram"); + expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + pluginId: "telegram", + workspaceDir: "/tmp/workspace", + }), + ); + }); + + it("keeps trusted workspace channel plugins eligible for setup resolution", async () => { + const workspaceEntry = createCatalogEntry({ + id: "telegram", + pluginId: "evil-telegram-shadow", + origin: "workspace", + }); + const workspacePlugin = createPlugin("telegram"); + + mocks.listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]); + mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockImplementation( + ({ pluginId }: { pluginId?: string }) => ({ + channels: pluginId === "evil-telegram-shadow" ? [{ plugin: workspacePlugin }] : [], + channelSetups: [], + }), + ); + + const result = await resolveInstallableChannelPlugin({ + cfg: { + plugins: { + enabled: true, + allow: ["evil-telegram-shadow"], + }, + }, + runtime: {} as never, + rawChannel: "telegram", + allowInstall: false, + }); + + expect(result.catalogEntry?.pluginId).toBe("evil-telegram-shadow"); + expect(result.plugin?.id).toBe("telegram"); + expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + pluginId: "evil-telegram-shadow", + workspaceDir: "/tmp/workspace", + }), + ); + }); +}); diff --git a/src/commands/channel-setup/channel-plugin-resolution.ts b/src/commands/channel-setup/channel-plugin-resolution.ts index b0f63d44568..4ef639569da 100644 --- a/src/commands/channel-setup/channel-plugin-resolution.ts +++ b/src/commands/channel-setup/channel-plugin-resolution.ts @@ -7,6 +7,7 @@ import { import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelId, ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizePluginsConfig, resolveEnableState } from "../../plugins/config-state.js"; import type { RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; @@ -70,6 +71,51 @@ function findScopedChannelPlugin( ); } +function isTrustedWorkspaceChannelCatalogEntry( + entry: ChannelPluginCatalogEntry | undefined, + cfg: OpenClawConfig, +): boolean { + if (entry?.origin !== "workspace") { + return true; + } + if (!entry.pluginId) { + return false; + } + return resolveEnableState(entry.pluginId, "workspace", normalizePluginsConfig(cfg.plugins)) + .enabled; +} + +function resolveTrustedCatalogEntry(params: { + rawChannel?: string | null; + channelId?: ChannelId; + cfg: OpenClawConfig; + workspaceDir?: string; + catalogEntry?: ChannelPluginCatalogEntry; +}): ChannelPluginCatalogEntry | undefined { + if (isTrustedWorkspaceChannelCatalogEntry(params.catalogEntry, params.cfg)) { + return params.catalogEntry; + } + if (params.rawChannel) { + const trimmed = params.rawChannel.trim().toLowerCase(); + return listChannelPluginCatalogEntries({ + workspaceDir: params.workspaceDir, + excludeWorkspace: true, + }).find((entry) => { + if (entry.id.toLowerCase() === trimmed) { + return true; + } + return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed); + }); + } + if (!params.channelId) { + return undefined; + } + return getChannelPluginCatalogEntry(params.channelId, { + workspaceDir: params.workspaceDir, + excludeWorkspace: true, + }); +} + function loadScopedChannelPlugin(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; @@ -99,13 +145,20 @@ export async function resolveInstallableChannelPlugin(params: { const supports = params.supports ?? (() => true); let nextCfg = params.cfg; const workspaceDir = resolveWorkspaceDir(nextCfg); - const catalogEntry = + const unresolvedCatalogEntry = (params.rawChannel ? resolveCatalogChannelEntry(params.rawChannel, nextCfg) : undefined) ?? (params.channelId ? getChannelPluginCatalogEntry(params.channelId, { workspaceDir, }) : undefined); + const catalogEntry = resolveTrustedCatalogEntry({ + rawChannel: params.rawChannel, + channelId: params.channelId, + cfg: nextCfg, + workspaceDir, + catalogEntry: unresolvedCatalogEntry, + }); const channelId = params.channelId ?? resolveResolvedChannelId({