mirror of https://github.com/openclaw/openclaw.git
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:
parent
4251ad6638
commit
53c29df2a9
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue