feat(plugin-sdk): publish identifier authentication contract (#123793)

* feat(plugin-sdk): publish identifier authentication contract

* fix(discord): distinguish PluralKit identity provenance

* fix(discord): preserve PluralKit group provenance

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-08-27 04:03:40 -07:00
committed by GitHub
parent 88c684c13e
commit ea552dff2c
15 changed files with 155 additions and 10 deletions
+19
View File
@@ -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:<name>` entries stay redacted. Core resolves static
@@ -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);
@@ -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,
@@ -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,
@@ -1198,6 +1198,7 @@ describe("preflightDiscordMessage", () => {
id: "pk-member-1",
name: "Echo",
tag: "Echo",
isPluralKit: true,
},
}),
);
@@ -649,6 +649,7 @@ export async function preflightDiscordMessage(
id: sender.id,
name: sender.name,
tag: sender.tag,
isPluralKit: sender.isPluralKit,
},
memberAccessConfigured: hasAccessRestrictions,
memberAllowed,
+3 -1
View File
@@ -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) === "*",
+5 -2
View File
@@ -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",
},
],
@@ -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) === "*",
@@ -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,
},
+5 -1
View File
@@ -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,
})),
],
+17
View File
@@ -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<PluginRuntime>({
pluginId: "package-consumer",
errorMessage: "package consumer runtime not initialized",
+2 -1
View File
@@ -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(
@@ -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<ResolveChannelMessageIngressParams> = {})
}
describe("plugin-sdk/channel-ingress-runtime", () => {
it("exports only the generic identifier-authentication inputs", () => {
const strengths: IdentifierAuthentication[] = ["verified", "asserted", "unverified", "mutable"];
const subject: NonNullable<ResolveChannelMessageIngressParams["subject"]["authentication"]> = {
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,
@@ -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";