mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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) === "*",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user