diff --git a/docs/plugins/sdk-channel-ingress.md b/docs/plugins/sdk-channel-ingress.md index c2e5be668041..714ba0797bbc 100644 --- a/docs/plugins/sdk-channel-ingress.md +++ b/docs/plugins/sdk-channel-ingress.md @@ -176,6 +176,25 @@ precedence. The deprecated fields remain through the current Plugin SDK major and are planned for removal in the next major after bundled and known external plugins migrate. +### Bundled channel declarations + +Bundled channels use the strongest claim supported by every receive path that +shares an identity declaration. These are channel-authorization claims, not +execution-identity assurance: + +| Channel | Identifier claim | Authoritative transport or session fact | +| --------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Discord | Gateway user ID: `verified`; PluralKit member ID: `asserted`; names and tags: `mutable` | Discord supplies `author.id` or `user.id` on events delivered over the authenticated bot-token Gateway session. PluralKit member IDs come from its authenticated API response, not the Discord Gateway. | +| Google Chat | `sender.name`: `verified`; email: `mutable` | The webhook validates Google's signed token, issuer, and configured audience before consuming the Google-owned event body. | +| IRC | server connection prefix and `user@host`: `asserted`; nick-based aliases: `mutable` | The selected IRC server vouches for the connection prefix, but the generic transport does not prove account ownership. | +| Mattermost | post user ID: `verified`; username: `mutable` | The authenticated Mattermost WebSocket emits server-owned post events whose `post.user_id` identifies the author. | +| Microsoft Teams | sender and conversation IDs: `asserted`; sender name: `mutable` | Bot Framework authenticates the connector activity, but the plugin does not independently prove exact ownership of every ID representation. | +| Slack | user and workspace-user IDs: `asserted`; names and slugs: `mutable` | Direct Slack delivery binds user IDs, while relay mode authenticates the relay peer without an end-to-end exact-sender attestation. The shared declaration uses the defensible common claim. | + +If a receive path cannot support the declaration shared by its channel, split +the declaration or supply a weaker per-message claim. Never infer a stronger +claim from message text, routing, or host evidence-carrier integrity. + ## Access groups `accessGroup:` entries stay redacted. Core resolves static diff --git a/extensions/discord/src/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts index 61cda198bed1..2ba650aca028 100644 --- a/extensions/discord/src/monitor/dm-command-auth.test.ts +++ b/extensions/discord/src/monitor/dm-command-auth.test.ts @@ -71,6 +71,35 @@ describe("resolveDiscordTextCommandAccess", () => { expect(result.commandAccess.authorized).toBe(false); expect(result.commandAccess.shouldBlockControlCommand).toBe(true); }); + + it("applies the PluralKit provenance downgrade to strict group commands", async () => { + const ordinary = await resolveDiscordTextCommandAccess({ + accountId: "default", + sender, + ownerAllowFrom: ["discord:123"], + memberAccessConfigured: false, + memberAllowed: false, + allowNameMatching: false, + allowTextCommands: true, + hasControlCommand: true, + minIdentifierAuthentication: "verified", + }); + const pluralKit = await resolveDiscordTextCommandAccess({ + accountId: "default", + sender: { id: "pk-member-1", name: "Echo", isPluralKit: true }, + ownerAllowFrom: ["pk:pk-member-1"], + memberAccessConfigured: false, + memberAllowed: false, + allowNameMatching: false, + allowTextCommands: true, + hasControlCommand: true, + minIdentifierAuthentication: "verified", + }); + + expect(ordinary.commandAccess.authorized).toBe(true); + expect(pluralKit.commandAccess.authorized).toBe(false); + expect(pluralKit.commandAccess.shouldBlockControlCommand).toBe(true); + }); }); describe("resolveDiscordDmCommandAccess", () => { @@ -174,6 +203,7 @@ describe("resolveDiscordDmCommandAccess", () => { id: "pk-member-1", name: "Echo", tag: "Echo", + isPluralKit: true, }, allowNameMatching: false, readStoreAllowFrom: async () => ["pk:pk-member-1"], @@ -183,6 +213,39 @@ describe("resolveDiscordDmCommandAccess", () => { expect(dmCommandAuthorized(result)).toBe(true); }); + it("distinguishes Gateway-bound Discord ids from asserted PluralKit member ids", async () => { + const ordinary = await resolveDiscordDmCommandAccess({ + accountId: "default", + dmPolicy: "allowlist", + configuredAllowFrom: ["discord:123"], + sender, + allowNameMatching: false, + minIdentifierAuthentication: "verified", + readStoreAllowFrom: async () => [], + }); + const pluralKit = await resolveDiscordDmCommandAccess({ + accountId: "default", + dmPolicy: "allowlist", + configuredAllowFrom: ["pk:pk-member-1"], + sender: { id: "pk-member-1", name: "Echo", isPluralKit: true }, + allowNameMatching: false, + minIdentifierAuthentication: "verified", + readStoreAllowFrom: async () => [], + }); + const compatiblePluralKitDefault = await resolveDiscordDmCommandAccess({ + accountId: "default", + dmPolicy: "allowlist", + configuredAllowFrom: ["pk:pk-member-1"], + sender: { id: "pk-member-1", name: "Echo", isPluralKit: true }, + allowNameMatching: false, + readStoreAllowFrom: async () => [], + }); + + expect(ordinary.senderAccess.decision).toBe("allow"); + expect(pluralKit.senderAccess.decision).toBe("block"); + expect(compatiblePluralKitDefault.senderAccess.decision).toBe("allow"); + }); + it("authorizes allowlist DMs from a Discord channel audience access group", async () => { canViewDiscordGuildChannelMock.mockResolvedValueOnce(true); diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 0da3541fa078..08b3ef4702c1 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -4,6 +4,7 @@ import { type ChannelIngressEventInput, type ChannelIngressContextBinding, type ChannelIngressIdentifierKind, + type IdentifierAuthentication, createChannelIngressResolver, defineStableChannelIngressIdentity, type ChannelIngressIdentitySubjectInput, @@ -65,6 +66,8 @@ function normalizeDiscordNameSubject(value: string): string | null { const discordIngressIdentity = defineStableChannelIngressIdentity({ key: "discordUserId", kind: DISCORD_USER_ID_KIND, + // Discord binds author/user.id on events delivered over the authenticated bot-token session. + authentication: "verified", normalizeEntry: normalizeDiscordIdEntry, normalizeSubject: (value) => value.trim() || null, sensitivity: "pii", @@ -78,7 +81,7 @@ const discordIngressIdentity = defineStableChannelIngressIdentity({ kind: DISCORD_USER_NAME_KIND, normalizeEntry, normalizeSubject: normalizeDiscordNameSubject, - dangerous: true, + authentication: "mutable", sensitivity: "pii", })), }); @@ -87,6 +90,7 @@ function createDiscordDmIngressSubject(sender: { id: string; name?: string; tag?: string; + isPluralKit?: boolean; }): ChannelIngressIdentitySubjectInput { return { stableId: sender.id, @@ -94,6 +98,9 @@ function createDiscordDmIngressSubject(sender: { discordUserName: sender.name, discordUserTag: sender.tag, }, + // PluralKit replaces Discord's Gateway author id with a member id returned by + // its API. The lookup is trusted input, but Discord did not bind that exact id. + ...(sender.isPluralKit ? { authentication: { discordUserId: "asserted" as const } } : {}), }; } @@ -179,7 +186,7 @@ export async function resolveDiscordDmCommandAccess(params: { accountId: string; dmPolicy: DiscordDmPolicy; configuredAllowFrom: string[]; - sender: { id: string; name?: string; tag?: string }; + sender: { id: string; name?: string; tag?: string; isPluralKit?: boolean }; allowNameMatching: boolean; cfg?: OpenClawConfig; token?: string; @@ -190,6 +197,7 @@ export async function resolveDiscordDmCommandAccess(params: { conversationParentId?: string; conversationThreadId?: string; contextBinding?: ChannelIngressContextBinding; + minIdentifierAuthentication?: IdentifierAuthentication; }) { return await createDiscordIngressResolver({ accountId: params.accountId, @@ -216,6 +224,9 @@ export async function resolveDiscordDmCommandAccess(params: { groupPolicy: "disabled", policy: { mutableIdentifierMatching: params.allowNameMatching ? "enabled" : "disabled", + ...(params.minIdentifierAuthentication + ? { minIdentifierAuthentication: params.minIdentifierAuthentication } + : {}), }, allowFrom: params.configuredAllowFrom, command: { @@ -227,7 +238,7 @@ export async function resolveDiscordDmCommandAccess(params: { export async function resolveDiscordTextCommandAccess(params: { accountId: string; - sender: { id: string; name?: string; tag?: string }; + sender: { id: string; name?: string; tag?: string; isPluralKit?: boolean }; ownerAllowFrom?: string[]; memberAccessConfigured: boolean; memberAllowed: boolean; @@ -241,6 +252,7 @@ export async function resolveDiscordTextCommandAccess(params: { conversationParentId?: string; conversationThreadId?: string; contextBinding?: ChannelIngressContextBinding; + minIdentifierAuthentication?: IdentifierAuthentication; }) { const ownerAllowFrom = (params.ownerAllowFrom ?? []).filter((entry) => entry.trim() !== "*"); const memberAccessGroup = "discord-member-access"; @@ -267,6 +279,9 @@ export async function resolveDiscordTextCommandAccess(params: { groupPolicy: "allowlist", policy: { mutableIdentifierMatching: params.allowNameMatching ? "enabled" : "disabled", + ...(params.minIdentifierAuthentication + ? { minIdentifierAuthentication: params.minIdentifierAuthentication } + : {}), }, allowFrom: ownerAllowFrom, groupAllowFrom: commandGroup, diff --git a/extensions/discord/src/monitor/message-handler.dm-preflight.ts b/extensions/discord/src/monitor/message-handler.dm-preflight.ts index fa404b984e63..4c117e5c0848 100644 --- a/extensions/discord/src/monitor/message-handler.dm-preflight.ts +++ b/extensions/discord/src/monitor/message-handler.dm-preflight.ts @@ -67,6 +67,7 @@ export async function resolveDiscordDmPreflightAccess(params: { id: params.sender.id, name: params.sender.name, tag: params.sender.tag, + isPluralKit: params.sender.isPluralKit, }, allowNameMatching: params.allowNameMatching, cfg: params.preflight.cfg, diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 8f5a5a3420e4..5656f453a91f 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -1198,6 +1198,7 @@ describe("preflightDiscordMessage", () => { id: "pk-member-1", name: "Echo", tag: "Echo", + isPluralKit: true, }, }), ); diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index a4ac072caac9..8131bfdea7a7 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -649,6 +649,7 @@ export async function preflightDiscordMessage( id: sender.id, name: sender.name, tag: sender.tag, + isPluralKit: sender.isPluralKit, }, memberAccessConfigured: hasAccessRestrictions, memberAllowed, diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index 95bf9c2570ed..f50238ad48fd 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -65,6 +65,8 @@ function normalizeGoogleChatEmailEntry(entry: string): string | null { const googleChatIngressIdentity = defineStableChannelIngressIdentity({ key: "sender-id", + // Google signs the webhook for the configured audience before sender.name is consumed. + authentication: "verified", normalizeEntry: normalizeGoogleChatStableEntry, normalizeSubject: normalizeUserId, aliases: [ @@ -73,7 +75,7 @@ const googleChatIngressIdentity = defineStableChannelIngressIdentity({ kind: GOOGLECHAT_EMAIL_KIND, normalizeEntry: normalizeGoogleChatEmailEntry, normalizeSubject: normalizeEntryValue, - dangerous: true, + authentication: "mutable", }, ], isWildcardEntry: (entry) => normalizeEntryValue(entry) === "*", diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index a1d675e727e1..2ab942b8e895 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -46,6 +46,8 @@ type IrcGroupPolicy = "open" | "allowlist" | "disabled"; const ircIngressIdentity = defineStableChannelIngressIdentity({ key: "irc-id", + // The IRC server vouches for the connection prefix, but does not bind it to an account owner. + authentication: "asserted", normalizeEntry: normalizeIrcStableEntry, normalizeSubject: normalizeLowercaseStringOrEmpty, sensitivity: "pii", @@ -55,12 +57,13 @@ const ircIngressIdentity = defineStableChannelIngressIdentity({ kind: "stable-id" as const, normalizeEntry: normalizeIrcNickUserEntry, normalizeSubject: normalizeLowercaseStringOrEmpty, - dangerous: true, + authentication: "mutable", sensitivity: "pii" as const, }, { key: "irc-id-nick-host", kind: "stable-id" as const, + authentication: "asserted", normalizeEntry: normalizeIrcNickHostEntry, normalizeSubject: normalizeLowercaseStringOrEmpty, sensitivity: "pii" as const, @@ -70,7 +73,7 @@ const ircIngressIdentity = defineStableChannelIngressIdentity({ kind: IRC_NICK_KIND, normalizeEntry: normalizeIrcNickEntry, normalizeSubject: normalizeLowercaseStringOrEmpty, - dangerous: true, + authentication: "mutable", sensitivity: "pii", }, ], diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index e0049800b0f4..3c52ca6131fa 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -20,6 +20,8 @@ const MATTERMOST_USER_NAME_KIND = "plugin:mattermost-user-name" as const satisfies ChannelIngressIdentifierKind; const mattermostIngressIdentity = { key: "sender-id", + // Authenticated Mattermost WebSocket post events carry the server-owned post.user_id. + authentication: "verified", normalize: normalizeMattermostAllowEntry, aliases: [ { @@ -27,7 +29,7 @@ const mattermostIngressIdentity = { kind: MATTERMOST_USER_NAME_KIND, normalizeEntry: normalizeMattermostAllowEntry, normalizeSubject: normalizeMattermostAllowEntry, - dangerous: true, + authentication: "mutable", }, ], isWildcardEntry: (entry) => normalizeMattermostAllowEntry(entry) === "*", diff --git a/extensions/msteams/src/monitor-handler/access.ts b/extensions/msteams/src/monitor-handler/access.ts index 4dc652bd725a..c0701317a0a6 100644 --- a/extensions/msteams/src/monitor-handler/access.ts +++ b/extensions/msteams/src/monitor-handler/access.ts @@ -31,6 +31,9 @@ const MSTEAMS_SENDER_NAME_KIND = "plugin:msteams-sender-name" as const; const MSTEAMS_CONVERSATION_ID_KIND = "plugin:msteams-conversation-id" as const; const msteamsIngressIdentity = { key: "sender-id", + // Bot Framework authenticates the connector and vouches for the activity, without this + // plugin independently proving exact ownership of every from.id representation. + authentication: "asserted", normalize: normalizeIngressValue, aliases: [ { @@ -38,11 +41,12 @@ const msteamsIngressIdentity = { kind: MSTEAMS_SENDER_NAME_KIND, normalizeEntry: normalizeSenderNameIngressValue, normalizeSubject: normalizeSenderNameIngressValue, - dangerous: true, + authentication: "mutable", }, { key: "conversation-id", kind: MSTEAMS_CONVERSATION_ID_KIND, + authentication: "asserted", normalizeEntry: normalizeAllowlistConversationId, normalizeSubject: normalizeAllowlistConversationId, }, diff --git a/extensions/slack/src/monitor/auth.ts b/extensions/slack/src/monitor/auth.ts index 740faebfff20..7da25ae15a18 100644 --- a/extensions/slack/src/monitor/auth.ts +++ b/extensions/slack/src/monitor/auth.ts @@ -122,6 +122,9 @@ function normalizeSlackNameSlugEntry(entry: string): string | null { const slackIngressIdentity = defineStableChannelIngressIdentity({ key: "senderId", kind: "stable-id", + // Direct Slack transports bind this id, while relay mode only authenticates its relay peer. + // The shared declaration therefore uses the strongest claim defensible for every mode. + authentication: "asserted", normalizeEntry: normalizeSlackBareUserEntry, normalizeSubject: normalizeSlackUserId, sensitivity: "pii", @@ -129,6 +132,7 @@ const slackIngressIdentity = defineStableChannelIngressIdentity({ { key: "workspaceSenderId", kind: SLACK_WORKSPACE_USER_ID_KIND, + authentication: "asserted", normalizeEntry: normalizeSlackWorkspaceUserEntry, normalizeSubject: normalizeSlackWorkspaceUserEntry, sensitivity: "pii", @@ -143,7 +147,7 @@ const slackIngressIdentity = defineStableChannelIngressIdentity({ kind: SLACK_USER_NAME_KIND, normalizeEntry, normalizeSubject: normalizeSlackNameSubject, - dangerous: true, + authentication: "mutable" as const, sensitivity: "pii" as const, })), ], diff --git a/scripts/check-plugin-sdk-exports.mts b/scripts/check-plugin-sdk-exports.mts index f0a3129e7cc2..989f5d75b9ac 100755 --- a/scripts/check-plugin-sdk-exports.mts +++ b/scripts/check-plugin-sdk-exports.mts @@ -69,9 +69,26 @@ let missing = 0; join(consumerRoot, "index.ts"), `import { buildChannelConfigSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import type { + ChannelIngressIdentitySubjectInput, + IdentifierAuthentication, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +// @ts-expect-error Host admission evidence is intentionally private to core. +import type { ChannelAdmissionEvidence } from "openclaw/plugin-sdk/channel-ingress-runtime"; +// @ts-expect-error Plugins cannot mint host admission evidence. +import { prepareHostChannelContextAdmissionEvidence } from "openclaw/plugin-sdk/channel-ingress-runtime"; +// @ts-expect-error Plugins cannot register host evidence owners. +import { registerChannelAdmissionEvidenceOwner } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; import { z } from "zod"; +const identifierAuthentication: IdentifierAuthentication = "verified"; +const subject: ChannelIngressIdentitySubjectInput = { + stableId: "provider-user-id", + authentication: { "provider-user-id": identifierAuthentication }, +}; +void subject; + const runtimeStore = createPluginRuntimeStore({ pluginId: "package-consumer", errorMessage: "package consumer runtime not initialized", diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 0b124e8eb536..cd9723bf454e 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -319,7 +319,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +3: typed ask_user option-index contract and two bounded owner-order resolvers. // +2: exact-session deletion parameters and synchronous companion mutation contract. // +2: canonical session-model selection and auxiliary runtime-auth preparation. - 4345, + // +1: identifier authentication input type for external channel plugins. + 4346, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/plugin-sdk/channel-ingress-runtime.test.ts b/src/plugin-sdk/channel-ingress-runtime.test.ts index e93c183d961e..9cfacfce3307 100644 --- a/src/plugin-sdk/channel-ingress-runtime.test.ts +++ b/src/plugin-sdk/channel-ingress-runtime.test.ts @@ -4,6 +4,7 @@ import { fanInChannelIngressLifecycles, resolveChannelMessageIngress, type ChannelIngressIdentityDescriptor, + type IdentifierAuthentication, type ResolveChannelMessageIngressParams, } from "./channel-ingress-runtime.js"; @@ -26,6 +27,16 @@ async function resolve(input: Partial = {}) } describe("plugin-sdk/channel-ingress-runtime", () => { + it("exports only the generic identifier-authentication inputs", () => { + const strengths: IdentifierAuthentication[] = ["verified", "asserted", "unverified", "mutable"]; + const subject: NonNullable = { + email: "verified", + displayName: "mutable", + }; + + expect(strengths).toContain(subject.email); + }); + it("fans one logical turn lifecycle across every durable claim", async () => { const createLifecycle = () => ({ abortSignal: new AbortController().signal, diff --git a/src/plugin-sdk/channel-ingress-runtime.ts b/src/plugin-sdk/channel-ingress-runtime.ts index 5d6e728bbf80..0796c706baa5 100644 --- a/src/plugin-sdk/channel-ingress-runtime.ts +++ b/src/plugin-sdk/channel-ingress-runtime.ts @@ -20,6 +20,7 @@ export { resolveChannelMessageIngress, resolveStableChannelMessageIngress, } from "../channels/message-access/runtime.js"; +export type { IdentifierAuthentication } from "../channels/message-access/identifier-authentication.js"; export { defineStableChannelIngressIdentity } from "../channels/message-access/runtime-identity.js"; export { readChannelIngressStoreAllowFromForDmPolicy } from "../channels/message-access/store-allow-from.js"; export { resolveChannelImplicitMentions } from "../config/implicit-mentions.js";