diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index e6c28ceea79d..15c2cab689de 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -185,7 +185,8 @@ function resolveRuntimeDiscordMessageActions() { } } -const discordMessageActions = { +const discordMessageActions: ChannelMessageActionAdapter = { + providerOwnedReadGates: true, resolveExecutionMode: ( ctx: Parameters>[0], ) => diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 702f7c71708a..4d8b9f915d25 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1056,6 +1056,7 @@ export const feishuPlugin: ChannelPlugin { diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index a32e3c946ecf..46205baa080f 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -129,6 +129,7 @@ function resolveMatrixActionAccount(params: { cfg: CoreConfig; accountId?: strin } export const matrixMessageActions: ChannelMessageActionAdapter = { + providerOwnedReadGates: true, describeMessageTool: ({ cfg, accountId, senderIsOwner }) => { const resolvedCfg = cfg as CoreConfig; const account = resolveMatrixActionAccount({ cfg: resolvedCfg, accountId }); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 27ec7bb09c69..20770bef235e 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -388,6 +388,7 @@ async function listMattermostDirectoryPeers(params: MattermostDirectoryListParam } const mattermostMessageActions: ChannelMessageActionAdapter = { + providerOwnedReadGates: ["read"], describeMessageTool: describeMattermostMessageTool, extractToolSend: ({ args }) => extractMattermostToolSend(args), extractToolSendResult: ({ result, send }) => extractMattermostToolSendResult(result, send), diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index ad13b8178c79..f87935f52f29 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -585,6 +585,7 @@ export const msteamsPlugin: ChannelPlugin extractMSTeamsToolSendResult(result, send), requiresTrustedRequesterSender: ({ action, toolContext }) => diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index ce56619d27a9..b8c0cdd2fe0f 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -62,6 +62,7 @@ export function createSlackActions( options?: { invoke?: SlackActionInvoke }, ): ChannelMessageActionAdapter { return { + providerOwnedReadGates: true, describeMessageTool: describeSlackMessageTool, extractToolSend: ({ args }) => extractSlackToolSend(args), isToolDeliveryAction: ({ args }) => diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 59cc0cb82911..ae8e19340869 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -276,6 +276,7 @@ const telegramMessageAdapter = createChannelMessageAdapterFromOutbound getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.resolveExecutionMode?.(ctx) ?? diff --git a/src/channels/plugins/contracts/plugin-shape.contract.test.ts b/src/channels/plugins/contracts/plugin-shape.contract.test.ts index f5e7a3b0d849..49eeddf7d18f 100644 --- a/src/channels/plugins/contracts/plugin-shape.contract.test.ts +++ b/src/channels/plugins/contracts/plugin-shape.contract.test.ts @@ -45,6 +45,15 @@ const SHARED_SANITIZER_CHANNEL_IDS = [ const MESSAGE_TOOL_ARTIFACT_PLUGIN_IDS = ["imessage", "slack"] as const; const SESSION_CONVERSATION_ARTIFACT_PLUGIN_IDS = ["feishu", "telegram"] as const; const THREAD_BINDING_ARTIFACT_PLUGIN_IDS = ["discord", "matrix"] as const; +const PROVIDER_OWNED_READ_GATE_PLUGINS = [ + ["discord", true], + ["feishu", true], + ["matrix", true], + ["msteams", true], + ["slack", true], + ["mattermost", ["read"]], + ["telegram", ["react", "edit", "delete"]], +] as const; type ExplicitSessionKeyNormalizer = ( sessionKey: string, @@ -185,6 +194,13 @@ describe("bundled channel plugin shape coherence", () => { }, ); + it.each(PROVIDER_OWNED_READ_GATE_PLUGINS)( + "keeps the %s provider-owned read gate declaration on its registered plugin surface", + (id, expected) => { + expect(plugins.get(id)?.actions?.providerOwnedReadGates).toEqual(expected); + }, + ); + describe.each(bundledChannelPluginIds)("%s", (id) => { it("keeps plugin identity aligned with the catalog id", () => { const plugin = plugins.get(id); diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts index 7340155efef0..9539142a1c0f 100644 --- a/src/channels/plugins/message-action-dispatch.ts +++ b/src/channels/plugins/message-action-dispatch.ts @@ -14,26 +14,6 @@ import type { ChannelPlugin, } from "./types.js"; -// These bundled adapters have host-reviewed provider-side current/configured -// gates. Other bundled adapters retain the exact-current compatibility limit. -const BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES: ReadonlySet = new Set([ - "discord", - "feishu", - "matrix", - "msteams", - "slack", -]); - -// Telegram owns exact topic/account binding for message mutations only. Other -// Telegram reads retain the host gate, including targetless sticker cache reads. -const BUNDLED_PROVIDER_READ_GATE_ACTIONS: ReadonlyMap< - string, - ReadonlySet -> = new Map([ - ["mattermost", new Set(["read"])], - ["telegram", new Set(["react", "edit", "delete"])], -]); - declare const serverOwnedConversationReadOrigin: unique symbol; type ServerOwnedConversationReadOrigin = ReturnType< @@ -156,13 +136,13 @@ type MessageActionReadEnforcement = function resolveMessageActionReadEnforcement(params: { action: ChannelMessageActionName; - channel: string; + actions: ChannelPlugin["actions"]; pluginOrigin: string | undefined; }): MessageActionReadEnforcement { + const providerOwnedReadGates = params.actions?.providerOwnedReadGates; if ( params.pluginOrigin === "bundled" && - (BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.channel) || - BUNDLED_PROVIDER_READ_GATE_ACTIONS.get(params.channel)?.has(params.action) === true) + (providerOwnedReadGates === true || providerOwnedReadGates?.includes(params.action) === true) ) { return { kind: "provider-owned" }; } @@ -571,7 +551,7 @@ function prepareMessageActionReadContext( actionPolicy, enforcement: resolveMessageActionReadEnforcement({ action, - channel: actionContext.channel, + actions: registration.plugin.actions, pluginOrigin: registration.origin, }), }; diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index f801e853d693..06cb54c5b762 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -17,7 +17,11 @@ function dispatchTestChannelMessageAction( ...overrides, }); } -import type { ChannelMessageActionContext, ChannelPlugin } from "./types.js"; +import type { + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelPlugin, +} from "./types.js"; const handleAction = vi.fn(async (_ctx: ChannelMessageActionContext) => jsonResult({ ok: true })); @@ -95,9 +99,9 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { function setReadPlugin(params?: { channel?: ChannelPlugin["id"]; origin?: string; - strayPolicy?: string; normalizeTarget?: (raw: string) => string | undefined; targetPrefixes?: readonly string[]; + providerOwnedReadGates?: true | readonly ChannelMessageActionName[]; messageActionTargetAliases?: NonNullable< NonNullable["messageActionTargetAliases"] >; @@ -121,9 +125,7 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { } : {}), actions: { - ...(params?.strayPolicy - ? ({ conversationReadPolicy: params.strayPolicy } as Record) - : {}), + providerOwnedReadGates: params?.providerOwnedReadGates, describeMessageTool: () => ({ actions: ["read", "send"] }), supportsAction, requiresTrustedRequesterSender, @@ -511,8 +513,54 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { expect(handleAction).toHaveBeenCalledOnce(); }); + it.each([ + { + name: "declared bundled adapter", + channel: "declared-bundled", + origin: "bundled", + providerOwnedReadGates: true, + allowed: true, + }, + { + name: "undeclared bundled adapter", + channel: "undeclared-bundled", + origin: "bundled", + providerOwnedReadGates: undefined, + allowed: false, + }, + { + name: "declared external adapter", + channel: "declared-external", + origin: "workspace", + providerOwnedReadGates: true, + allowed: false, + }, + ] as const)("applies provider-owned read gates for a $name", async (testCase) => { + setReadPlugin(testCase); + const dispatch = dispatchTestChannelMessageAction({ + channel: testCase.channel, + action: "read", + params: { channelId: "configured" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: testCase.channel, + currentChannelId: "current", + }, + }); + + if (testCase.allowed) { + await dispatch; + expect(handleAction).toHaveBeenCalledOnce(); + return; + } + await expect(dispatch).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + it("delegates configured-target policy to a bundled adapter", async () => { - setReadPlugin({ origin: "bundled" }); + setReadPlugin({ origin: "bundled", providerOwnedReadGates: true }); await dispatchTestChannelMessageAction({ channel: "discord", @@ -525,7 +573,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { }); it("delegates Mattermost cross-channel policy to its bundled provider gate", async () => { - setReadPlugin({ channel: "mattermost", origin: "bundled" }); + setReadPlugin({ + channel: "mattermost", + origin: "bundled", + providerOwnedReadGates: ["read"], + }); await dispatchChannelMessageAction({ channel: "mattermost", @@ -539,7 +591,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { }); it("keeps Mattermost reactions behind the host exact-current gate", async () => { - setReadPlugin({ channel: "mattermost", origin: "bundled" }); + setReadPlugin({ + channel: "mattermost", + origin: "bundled", + providerOwnedReadGates: ["read"], + }); await expect( dispatchChannelMessageAction({ @@ -560,7 +616,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { }); it("keeps unaudited bundled adapters on the exact-current host limit", async () => { - setReadPlugin({ channel: "telegram", origin: "bundled" }); + setReadPlugin({ + channel: "telegram", + origin: "bundled", + providerOwnedReadGates: ["react", "edit", "delete"], + }); await expect( dispatchTestChannelMessageAction({ @@ -582,7 +642,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { it.each(["react", "edit", "delete"] as const)( "delegates Telegram %s topic binding to the bundled provider", async (action) => { - setReadPlugin({ channel: "telegram", origin: "bundled" }); + setReadPlugin({ + channel: "telegram", + origin: "bundled", + providerOwnedReadGates: ["react", "edit", "delete"], + }); await dispatchTestChannelMessageAction({ channel: "telegram", @@ -603,7 +667,11 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { ); it("does not grant Telegram mutation enforcement to an external override", async () => { - setReadPlugin({ channel: "telegram", origin: "workspace" }); + setReadPlugin({ + channel: "telegram", + origin: "workspace", + providerOwnedReadGates: ["react", "edit", "delete"], + }); await expect( dispatchTestChannelMessageAction({ @@ -1522,29 +1590,6 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { expect(handleAction).not.toHaveBeenCalled(); }); - it("does not let an external adapter opt into bundled behavior with a stray property", async () => { - setReadPlugin({ - origin: "workspace", - strayPolicy: "current-or-configured-v1", - }); - - await expect( - dispatchTestChannelMessageAction({ - channel: "discord", - action: "read", - params: { channelId: "configured" }, - accountId: "default", - requesterAccountId: "default", - conversationReadOrigin: "delegated", - toolContext: { - currentChannelProvider: "discord", - currentChannelId: "current", - }, - }), - ).rejects.toThrow("requires the exact current conversation and account"); - expect(handleAction).not.toHaveBeenCalled(); - }); - it.each([undefined, "unknown", "global", "workspace", "config"] as const)( "treats %s channel provenance as non-bundled", async (origin) => { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index fa2e50486d0c..82e088756555 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -747,6 +747,8 @@ export type ChannelMessageActionAdapter = { describeMessageTool: ( params: ChannelMessageActionDiscoveryContext, ) => ChannelMessageToolDiscovery | null | undefined; + /** Delegate conversation-read authorization to this adapter for bundled registrations only. */ + providerOwnedReadGates?: true | readonly ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; resolveExecutionMode?: (params: { action: ChannelMessageActionName }) => "local" | "gateway"; resolveCliActionRequest?: (params: { diff --git a/src/infra/outbound/message-action-routing.test.ts b/src/infra/outbound/message-action-routing.test.ts index 728fed84678c..cf200fd44fd0 100644 --- a/src/infra/outbound/message-action-routing.test.ts +++ b/src/infra/outbound/message-action-routing.test.ts @@ -495,6 +495,7 @@ describe("runMessageAction plugin dispatch", () => { }, actions: { describeMessageTool: () => ({ actions: ["channel-delete", "channel-info"] }), + providerOwnedReadGates: true, supportsAction: ({ action }) => action === "channel-delete" || action === "channel-info", requiresTrustedRequesterSender: ({ action, toolContext }) => Boolean(toolContext) && action === "channel-delete",