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
This commit is contained in:
mappel-nv 2026-04-02 10:40:23 -04:00 committed by GitHub
parent 4251ad6638
commit 53c29df2a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 220 additions and 1 deletions

View File

@ -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.

View File

@ -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<PluginOrigin, number> = {
@ -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<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
for (const candidate of discovery.candidates) {
if (options.excludeWorkspace && candidate.origin === "workspace") {
continue;
}
const entry = buildCatalogEntry(candidate);
if (!entry) {
continue;

View File

@ -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",
}),
);
});
});

View File

@ -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({