From c2cbdea28cfc9c05dd866f1d143edf089df567fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 09:03:41 +0900 Subject: [PATCH] refactor: add approval auth capabilities to more channels --- docs/plugins/sdk-channel-plugins.md | 1 + extensions/feishu/src/approval-auth.test.ts | 24 +++++++++ extensions/feishu/src/approval-auth.ts | 24 +++++++++ extensions/feishu/src/channel.ts | 2 + .../googlechat/src/approval-auth.test.ts | 24 +++++++++ extensions/googlechat/src/approval-auth.ts | 31 ++++++++++++ extensions/googlechat/src/channel.ts | 2 + extensions/matrix/src/approval-auth.test.ts | 23 +++++++++ extensions/matrix/src/approval-auth.ts | 24 +++++++++ extensions/matrix/src/channel.ts | 2 + .../mattermost/src/approval-auth.test.ts | 28 +++++++++++ extensions/mattermost/src/approval-auth.ts | 29 +++++++++++ extensions/mattermost/src/channel.ts | 2 + extensions/msteams/src/approval-auth.test.ts | 32 ++++++++++++ extensions/msteams/src/approval-auth.ts | 37 ++++++++++++++ extensions/msteams/src/channel.ts | 2 + .../nextcloud-talk/src/approval-auth.test.ts | 17 +++++++ .../nextcloud-talk/src/approval-auth.ts | 27 ++++++++++ extensions/nextcloud-talk/src/channel.ts | 2 + extensions/signal/src/approval-auth.test.ts | 32 ++++++++++++ extensions/signal/src/approval-auth.ts | 33 +++++++++++++ extensions/signal/src/channel.ts | 2 + extensions/slack/src/approval-auth.test.ts | 29 +++++++++++ extensions/slack/src/approval-auth.ts | 33 +++++++++++++ extensions/slack/src/channel.ts | 2 + .../synology-chat/src/approval-auth.test.ts | 17 +++++++ extensions/synology-chat/src/approval-auth.ts | 23 +++++++++ extensions/synology-chat/src/channel.ts | 2 + extensions/whatsapp/src/approval-auth.test.ts | 24 +++++++++ extensions/whatsapp/src/approval-auth.ts | 27 ++++++++++ extensions/whatsapp/src/channel.ts | 2 + extensions/zalo/src/approval-auth.test.ts | 17 +++++++ extensions/zalo/src/approval-auth.ts | 25 ++++++++++ extensions/zalo/src/channel.ts | 2 + src/plugin-sdk/approval-auth-helpers.test.ts | 49 +++++++++++++++++++ src/plugin-sdk/approval-auth-helpers.ts | 44 +++++++++++++++++ src/plugin-sdk/approval-runtime.ts | 1 + 37 files changed, 697 insertions(+) create mode 100644 extensions/feishu/src/approval-auth.test.ts create mode 100644 extensions/feishu/src/approval-auth.ts create mode 100644 extensions/googlechat/src/approval-auth.test.ts create mode 100644 extensions/googlechat/src/approval-auth.ts create mode 100644 extensions/matrix/src/approval-auth.test.ts create mode 100644 extensions/matrix/src/approval-auth.ts create mode 100644 extensions/mattermost/src/approval-auth.test.ts create mode 100644 extensions/mattermost/src/approval-auth.ts create mode 100644 extensions/msteams/src/approval-auth.test.ts create mode 100644 extensions/msteams/src/approval-auth.ts create mode 100644 extensions/nextcloud-talk/src/approval-auth.test.ts create mode 100644 extensions/nextcloud-talk/src/approval-auth.ts create mode 100644 extensions/signal/src/approval-auth.test.ts create mode 100644 extensions/signal/src/approval-auth.ts create mode 100644 extensions/slack/src/approval-auth.test.ts create mode 100644 extensions/slack/src/approval-auth.ts create mode 100644 extensions/synology-chat/src/approval-auth.test.ts create mode 100644 extensions/synology-chat/src/approval-auth.ts create mode 100644 extensions/whatsapp/src/approval-auth.test.ts create mode 100644 extensions/whatsapp/src/approval-auth.ts create mode 100644 extensions/zalo/src/approval-auth.test.ts create mode 100644 extensions/zalo/src/approval-auth.ts create mode 100644 src/plugin-sdk/approval-auth-helpers.test.ts create mode 100644 src/plugin-sdk/approval-auth-helpers.ts diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 82856fa0231..27cbc6009ad 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -43,6 +43,7 @@ Most channel plugins do not need approval-specific code. - Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery. - Use `approvals.delivery` only for native approval routing or fallback suppression. - Use `approvals.render` only when a channel truly needs custom approval payloads instead of the shared renderer. +- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic. For Slack, Matrix, Microsoft Teams, and similar chat channels, the default path is usually enough: core handles approvals and the plugin just exposes normal outbound and auth capabilities. diff --git a/extensions/feishu/src/approval-auth.test.ts b/extensions/feishu/src/approval-auth.test.ts new file mode 100644 index 00000000000..8406f88a87a --- /dev/null +++ b/extensions/feishu/src/approval-auth.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { feishuApprovalAuth } from "./approval-auth.js"; + +describe("feishuApprovalAuth", () => { + it("authorizes open_id approvers and ignores user_id-only allowlists", () => { + expect( + feishuApprovalAuth.authorizeActorAction({ + cfg: { channels: { feishu: { allowFrom: ["ou_owner"] } } }, + senderId: "ou_owner", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + feishuApprovalAuth.authorizeActorAction({ + cfg: { channels: { feishu: { allowFrom: ["user_123"] } } }, + senderId: "ou_attacker", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/feishu/src/approval-auth.ts b/extensions/feishu/src/approval-auth.ts new file mode 100644 index 00000000000..c0ad682a2b8 --- /dev/null +++ b/extensions/feishu/src/approval-auth.ts @@ -0,0 +1,24 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveFeishuAccount } from "./accounts.js"; +import { normalizeFeishuTarget } from "./targets.js"; + +function normalizeFeishuApproverId(value: string | number): string | undefined { + const normalized = normalizeFeishuTarget(String(value)); + const trimmed = normalized?.trim().toLowerCase(); + return trimmed?.startsWith("ou_") ? trimmed : undefined; +} + +export const feishuApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Feishu", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveFeishuAccount({ cfg, accountId }).config; + return resolveApprovalApprovers({ + allowFrom: account.allowFrom, + normalizeApprover: normalizeFeishuApproverId, + }); + }, + normalizeSenderId: (value) => normalizeFeishuApproverId(value), +}); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 9910027af5b..d50612c15ab 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -42,6 +42,7 @@ import { listEnabledFeishuAccounts, resolveDefaultFeishuAccountId, } from "./accounts.js"; +import { feishuApprovalAuth } from "./approval-auth.js"; import { FEISHU_CARD_INTERACTION_VERSION } from "./card-interaction.js"; import { createFeishuClient } from "./client.js"; import { FeishuConfigSchema } from "./config-schema.js"; @@ -612,6 +613,7 @@ export const feishuPlugin: ChannelPlugin { diff --git a/extensions/googlechat/src/approval-auth.test.ts b/extensions/googlechat/src/approval-auth.test.ts new file mode 100644 index 00000000000..a9aa9dcb9d3 --- /dev/null +++ b/extensions/googlechat/src/approval-auth.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { googleChatApprovalAuth } from "./approval-auth.js"; + +describe("googleChatApprovalAuth", () => { + it("authorizes stable users/* ids and ignores email-style approvers", () => { + expect( + googleChatApprovalAuth.authorizeActorAction({ + cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } }, + senderId: "users/123", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + googleChatApprovalAuth.authorizeActorAction({ + cfg: { channels: { googlechat: { dm: { allowFrom: ["owner@example.com"] } } } }, + senderId: "users/attacker", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/googlechat/src/approval-auth.ts b/extensions/googlechat/src/approval-auth.ts new file mode 100644 index 00000000000..fcb47c32c8b --- /dev/null +++ b/extensions/googlechat/src/approval-auth.ts @@ -0,0 +1,31 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveGoogleChatAccount } from "./accounts.js"; +import { isGoogleChatUserTarget, normalizeGoogleChatTarget } from "./targets.js"; + +function normalizeGoogleChatApproverId(value: string | number): string | undefined { + const normalized = normalizeGoogleChatTarget(String(value)); + if (!normalized || !isGoogleChatUserTarget(normalized)) { + return undefined; + } + const suffix = normalized.slice("users/".length).trim().toLowerCase(); + if (!suffix || suffix.includes("@")) { + return undefined; + } + return `users/${suffix}`; +} + +export const googleChatApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Google Chat", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveGoogleChatAccount({ cfg, accountId }).config; + return resolveApprovalApprovers({ + allowFrom: account.dm?.allowFrom, + defaultTo: account.defaultTo, + normalizeApprover: normalizeGoogleChatApproverId, + }); + }, + normalizeSenderId: (value) => normalizeGoogleChatApproverId(value), +}); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 03c0c00ffd5..6e5b6220572 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -46,6 +46,7 @@ import { type ResolvedGoogleChatAccount, } from "./accounts.js"; import { googlechatMessageActions } from "./actions.js"; +import { googleChatApprovalAuth } from "./approval-auth.js"; import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; import { getGoogleChatRuntime } from "./runtime.js"; import { googlechatSetupAdapter } from "./setup-core.js"; @@ -163,6 +164,7 @@ export const googlechatPlugin = createChatChannelPlugin({ }, }), }, + auth: googleChatApprovalAuth, groups: { resolveRequireMention: resolveGoogleChatGroupRequireMention, }, diff --git a/extensions/matrix/src/approval-auth.test.ts b/extensions/matrix/src/approval-auth.test.ts new file mode 100644 index 00000000000..8298f6d9822 --- /dev/null +++ b/extensions/matrix/src/approval-auth.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { matrixApprovalAuth } from "./approval-auth.js"; + +describe("matrixApprovalAuth", () => { + it("normalizes Matrix user ids before authorizing", () => { + const cfg = { + channels: { + matrix: { + dm: { allowFrom: ["matrix:@Owner:Example.org"] }, + }, + }, + }; + + expect( + matrixApprovalAuth.authorizeActorAction({ + cfg, + senderId: "@owner:example.org", + action: "approve", + approvalKind: "plugin", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/matrix/src/approval-auth.ts b/extensions/matrix/src/approval-auth.ts new file mode 100644 index 00000000000..a128332e696 --- /dev/null +++ b/extensions/matrix/src/approval-auth.ts @@ -0,0 +1,24 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; +import type { CoreConfig } from "./types.js"; + +function normalizeMatrixApproverId(value: string | number): string | undefined { + const normalized = normalizeMatrixUserId(String(value)); + return normalized || undefined; +} + +export const matrixApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Matrix", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); + return resolveApprovalApprovers({ + allowFrom: account.config.dm?.allowFrom, + normalizeApprover: normalizeMatrixApproverId, + }); + }, + normalizeSenderId: (value) => normalizeMatrixApproverId(value), +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index d9651d1a829..4d5e98b6245 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -35,6 +35,7 @@ import { createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { matrixMessageActions } from "./actions.js"; +import { matrixApprovalAuth } from "./approval-auth.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { resolveMatrixGroupRequireMention, @@ -308,6 +309,7 @@ export const matrixPlugin: ChannelPlugin = }, }), }, + auth: matrixApprovalAuth, groups: { resolveRequireMention: resolveMatrixGroupRequireMention, resolveToolPolicy: resolveMatrixGroupToolPolicy, diff --git a/extensions/mattermost/src/approval-auth.test.ts b/extensions/mattermost/src/approval-auth.test.ts new file mode 100644 index 00000000000..ec3ba05ee32 --- /dev/null +++ b/extensions/mattermost/src/approval-auth.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mattermostApprovalAuth } from "./approval-auth.js"; + +describe("mattermostApprovalAuth", () => { + it("authorizes stable Mattermost user ids and ignores usernames", () => { + expect( + mattermostApprovalAuth.authorizeActorAction({ + cfg: { + channels: { mattermost: { allowFrom: ["user:abcdefghijklmnopqrstuvwxyz"] } }, + }, + senderId: "abcdefghijklmnopqrstuvwxyz", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + mattermostApprovalAuth.authorizeActorAction({ + cfg: { + channels: { mattermost: { allowFrom: ["@owner"] } }, + }, + senderId: "attacker-user-id", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/mattermost/src/approval-auth.ts b/extensions/mattermost/src/approval-auth.ts new file mode 100644 index 00000000000..411c254e146 --- /dev/null +++ b/extensions/mattermost/src/approval-auth.ts @@ -0,0 +1,29 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveMattermostAccount } from "./mattermost/accounts.js"; + +const MATTERMOST_USER_ID_RE = /^[a-z0-9]{26}$/; + +function normalizeMattermostApproverId(value: string | number): string | undefined { + const normalized = String(value) + .trim() + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .trim() + .toLowerCase(); + return MATTERMOST_USER_ID_RE.test(normalized) ? normalized : undefined; +} + +export const mattermostApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Mattermost", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveMattermostAccount({ cfg, accountId }).config; + return resolveApprovalApprovers({ + allowFrom: account.allowFrom, + normalizeApprover: normalizeMattermostApproverId, + }); + }, + normalizeSenderId: (value) => normalizeMattermostApproverId(value), +}); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 0ccbd03d349..864dc26a4ae 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -20,6 +20,7 @@ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; +import { mattermostApprovalAuth } from "./approval-auth.js"; import { MattermostChannelConfigSchema } from "./config-surface.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; import { @@ -320,6 +321,7 @@ export const mattermostPlugin: ChannelPlugin = create }, }), }, + auth: mattermostApprovalAuth, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, diff --git a/extensions/msteams/src/approval-auth.test.ts b/extensions/msteams/src/approval-auth.test.ts new file mode 100644 index 00000000000..b00c4c221c5 --- /dev/null +++ b/extensions/msteams/src/approval-auth.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { msTeamsApprovalAuth } from "./approval-auth.js"; + +describe("msTeamsApprovalAuth", () => { + it("authorizes stable Teams user ids and ignores display-name allowlists", () => { + expect( + msTeamsApprovalAuth.authorizeActorAction({ + cfg: { + channels: { + msteams: { + allowFrom: ["user:123e4567-e89b-12d3-a456-426614174000"], + }, + }, + }, + senderId: "123e4567-e89b-12d3-a456-426614174000", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + msTeamsApprovalAuth.authorizeActorAction({ + cfg: { + channels: { msteams: { allowFrom: ["Owner Display"] } }, + }, + senderId: "attacker-aad", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/msteams/src/approval-auth.ts b/extensions/msteams/src/approval-auth.ts new file mode 100644 index 00000000000..d5c21ca2b0c --- /dev/null +++ b/extensions/msteams/src/approval-auth.ts @@ -0,0 +1,37 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import type { OpenClawConfig } from "../runtime-api.js"; +import { normalizeMSTeamsMessagingTarget } from "./resolve-allowlist.js"; + +const MSTEAMS_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function normalizeMSTeamsApproverId(value: string | number): string | undefined { + const normalized = normalizeMSTeamsMessagingTarget(String(value)); + if (!normalized?.startsWith("user:")) { + return undefined; + } + const id = normalized.slice("user:".length).trim().toLowerCase(); + return MSTEAMS_ID_RE.test(id) ? id : undefined; +} + +function resolveMSTeamsChannelConfig(cfg: OpenClawConfig) { + return cfg.channels?.msteams; +} + +export const msTeamsApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Microsoft Teams", + resolveApprovers: ({ cfg }) => { + const channel = resolveMSTeamsChannelConfig(cfg); + return resolveApprovalApprovers({ + allowFrom: channel?.allowFrom, + defaultTo: channel?.defaultTo, + normalizeApprover: normalizeMSTeamsApproverId, + }); + }, + normalizeSenderId: (value) => { + const trimmed = value.trim().toLowerCase(); + return MSTEAMS_ID_RE.test(trimmed) ? trimmed : undefined; + }, +}); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 702a2380651..87c480aea97 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -28,6 +28,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, } from "../runtime-api.js"; +import { msTeamsApprovalAuth } from "./approval-auth.js"; import { MSTeamsChannelConfigSchema } from "./config-schema.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import type { ProbeMSTeamsResult } from "./probe.js"; @@ -379,6 +380,7 @@ export const msteamsPlugin: ChannelPlugin { + it("matches Nextcloud Talk actor ids case-insensitively", () => { + const cfg = { channels: { "nextcloud-talk": { allowFrom: ["Owner"] } } }; + + expect( + nextcloudTalkApprovalAuth.authorizeActorAction({ + cfg, + senderId: "owner", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/nextcloud-talk/src/approval-auth.ts b/extensions/nextcloud-talk/src/approval-auth.ts new file mode 100644 index 00000000000..98b24109864 --- /dev/null +++ b/extensions/nextcloud-talk/src/approval-auth.ts @@ -0,0 +1,27 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import type { CoreConfig } from "./types.js"; + +function normalizeNextcloudTalkApproverId(value: string | number): string | undefined { + const normalized = String(value) + .trim() + .replace(/^(nextcloud-talk|nc-talk|nc):/i, "") + .trim() + .toLowerCase(); + return normalized || undefined; +} + +export const nextcloudTalkApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Nextcloud Talk", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return resolveApprovalApprovers({ + allowFrom: account.config.allowFrom, + normalizeApprover: normalizeNextcloudTalkApproverId, + }); + }, + normalizeSenderId: (value) => normalizeNextcloudTalkApproverId(value), +}); diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 226c6f1d07d..776aa18ccad 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -31,6 +31,7 @@ import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount, } from "./accounts.js"; +import { nextcloudTalkApprovalAuth } from "./approval-auth.js"; import { NextcloudTalkConfigSchema } from "./config-schema.js"; import { monitorNextcloudTalkProvider } from "./monitor.js"; import { @@ -139,6 +140,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, }), }, + auth: nextcloudTalkApprovalAuth, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); diff --git a/extensions/signal/src/approval-auth.test.ts b/extensions/signal/src/approval-auth.test.ts new file mode 100644 index 00000000000..f2693fd4803 --- /dev/null +++ b/extensions/signal/src/approval-auth.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { signalApprovalAuth } from "./approval-auth.js"; + +describe("signalApprovalAuth", () => { + it("authorizes phone and uuid approvers with stable sender ids", () => { + const cfg = { + channels: { + signal: { + allowFrom: ["uuid:ABCDEF12-3456-7890-ABCD-EF1234567890", "+1 (555) 123-0000"], + }, + }, + }; + + expect( + signalApprovalAuth.authorizeActorAction({ + cfg, + senderId: "uuid:abcdef12-3456-7890-abcd-ef1234567890", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + signalApprovalAuth.authorizeActorAction({ + cfg, + senderId: "+15551230000", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/signal/src/approval-auth.ts b/extensions/signal/src/approval-auth.ts new file mode 100644 index 00000000000..ac27f101358 --- /dev/null +++ b/extensions/signal/src/approval-auth.ts @@ -0,0 +1,33 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; +import { resolveSignalAccount } from "./accounts.js"; +import { looksLikeUuid } from "./identity.js"; +import { normalizeSignalMessagingTarget } from "./normalize.js"; + +function normalizeSignalApproverId(value: string | number): string | undefined { + const normalized = normalizeSignalMessagingTarget(String(value)); + if (!normalized || normalized.startsWith("group:") || normalized.startsWith("username:")) { + return undefined; + } + if (looksLikeUuid(normalized)) { + return `uuid:${normalized}`; + } + const e164 = normalizeE164(normalized); + return e164.length > 1 ? e164 : undefined; +} + +export const signalApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Signal", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveSignalAccount({ cfg, accountId }).config; + return resolveApprovalApprovers({ + allowFrom: account.allowFrom, + defaultTo: account.defaultTo, + normalizeApprover: normalizeSignalApproverId, + }); + }, + normalizeSenderId: (value) => normalizeSignalApproverId(value), +}); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index dae8ec7c271..5b4c18d6e0e 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -11,6 +11,7 @@ import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; +import { signalApprovalAuth } from "./approval-auth.js"; import { markdownToSignalTextChunks } from "./format.js"; import { signalMessageActions } from "./message-actions.js"; import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js"; @@ -228,6 +229,7 @@ export const signalPlugin: ChannelPlugin = setup: signalSetupAdapter, }), actions: signalMessageActions, + auth: signalApprovalAuth, allowlist: buildDmGroupAccountAllowlistAdapter({ channelId: "signal", resolveAccount: resolveSignalAccount, diff --git a/extensions/slack/src/approval-auth.test.ts b/extensions/slack/src/approval-auth.test.ts new file mode 100644 index 00000000000..574b9d604dc --- /dev/null +++ b/extensions/slack/src/approval-auth.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { slackApprovalAuth } from "./approval-auth.js"; + +describe("slackApprovalAuth", () => { + it("authorizes inferred Slack approvers by user id", () => { + const cfg = { channels: { slack: { allowFrom: ["U_OWNER"] } } }; + + expect( + slackApprovalAuth.authorizeActorAction({ + cfg, + senderId: "U_OWNER", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + slackApprovalAuth.authorizeActorAction({ + cfg, + senderId: "U_ATTACKER", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ + authorized: false, + reason: "❌ You are not authorized to approve exec requests on Slack.", + }); + }); +}); diff --git a/extensions/slack/src/approval-auth.ts b/extensions/slack/src/approval-auth.ts new file mode 100644 index 00000000000..61d45fc993f --- /dev/null +++ b/extensions/slack/src/approval-auth.ts @@ -0,0 +1,33 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveSlackAccount } from "./accounts.js"; +import { parseSlackTarget } from "./targets.js"; + +function normalizeSlackApproverId(value: string | number): string | undefined { + const trimmed = String(value).trim(); + if (!trimmed) { + return undefined; + } + try { + const target = parseSlackTarget(trimmed, { defaultKind: "user" }); + return target?.kind === "user" ? target.id : undefined; + } catch { + return /^[A-Z0-9]+$/i.test(trimmed) ? trimmed : undefined; + } +} + +export const slackApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Slack", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveSlackAccount({ cfg, accountId }).config; + return resolveApprovalApprovers({ + allowFrom: account.allowFrom, + extraAllowFrom: account.dm?.allowFrom, + defaultTo: account.defaultTo, + normalizeApprover: normalizeSlackApproverId, + }); + }, + normalizeSenderId: (value) => normalizeSlackApproverId(value), +}); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 58d6f459557..c1d91c460c2 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { } from "./accounts.js"; import type { SlackActionContext } from "./action-runtime.js"; import { resolveSlackAutoThreadId } from "./action-threading.js"; +import { slackApprovalAuth } from "./approval-auth.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackActions } from "./channel-actions.js"; import { resolveSlackChannelType } from "./channel-type.js"; @@ -281,6 +282,7 @@ export const slackPlugin: ChannelPlugin = crea }), resolveNames: resolveSlackAllowlistNames, }, + auth: slackApprovalAuth, groups: { resolveRequireMention: resolveSlackGroupRequireMention, resolveToolPolicy: resolveSlackGroupToolPolicy, diff --git a/extensions/synology-chat/src/approval-auth.test.ts b/extensions/synology-chat/src/approval-auth.test.ts new file mode 100644 index 00000000000..95d25472197 --- /dev/null +++ b/extensions/synology-chat/src/approval-auth.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { synologyChatApprovalAuth } from "./approval-auth.js"; + +describe("synologyChatApprovalAuth", () => { + it("authorizes numeric Synology Chat user ids", () => { + const cfg = { channels: { "synology-chat": { allowedUserIds: ["123"] } } }; + + expect( + synologyChatApprovalAuth.authorizeActorAction({ + cfg, + senderId: "123", + action: "approve", + approvalKind: "plugin", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/synology-chat/src/approval-auth.ts b/extensions/synology-chat/src/approval-auth.ts new file mode 100644 index 00000000000..bae9b536fb7 --- /dev/null +++ b/extensions/synology-chat/src/approval-auth.ts @@ -0,0 +1,23 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution"; +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveAccount } from "./accounts.js"; + +function normalizeSynologyChatApproverId(value: string | number): string | undefined { + const trimmed = String(value).trim(); + return /^\d+$/.test(trimmed) ? trimmed : undefined; +} + +export const synologyChatApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Synology Chat", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveAccount((cfg ?? {}) as OpenClawConfig, accountId); + return resolveApprovalApprovers({ + allowFrom: account.allowedUserIds, + normalizeApprover: normalizeSynologyChatApproverId, + }); + }, + normalizeSenderId: (value) => normalizeSynologyChatApproverId(value), +}); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index ca95b813be8..24788fd39b3 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -21,6 +21,7 @@ import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; import { listAccountIds, resolveAccount } from "./accounts.js"; +import { synologyChatApprovalAuth } from "./approval-auth.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { SynologyChatChannelConfigSchema } from "./config-schema.js"; import { @@ -224,6 +225,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin { config: { ...synologyChatConfigAdapter, }, + auth: synologyChatApprovalAuth, messaging: { normalizeTarget: (target: string) => { const trimmed = target.trim(); diff --git a/extensions/whatsapp/src/approval-auth.test.ts b/extensions/whatsapp/src/approval-auth.test.ts new file mode 100644 index 00000000000..324fd8072a3 --- /dev/null +++ b/extensions/whatsapp/src/approval-auth.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { whatsappApprovalAuth } from "./approval-auth.js"; + +describe("whatsappApprovalAuth", () => { + it("authorizes direct WhatsApp recipients and ignores groups", () => { + expect( + whatsappApprovalAuth.authorizeActorAction({ + cfg: { channels: { whatsapp: { allowFrom: ["+1 (555) 123-0000"] } } }, + senderId: "15551230000@s.whatsapp.net", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + whatsappApprovalAuth.authorizeActorAction({ + cfg: { channels: { whatsapp: { allowFrom: ["12345-67890@g.us"] } } }, + senderId: "+15551239999", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/whatsapp/src/approval-auth.ts b/extensions/whatsapp/src/approval-auth.ts new file mode 100644 index 00000000000..4d32eee94ca --- /dev/null +++ b/extensions/whatsapp/src/approval-auth.ts @@ -0,0 +1,27 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveWhatsAppAccount } from "./accounts.js"; +import { normalizeWhatsAppTarget } from "./runtime-api.js"; + +function normalizeWhatsAppApproverId(value: string | number): string | undefined { + const normalized = normalizeWhatsAppTarget(String(value)); + if (!normalized || normalized.endsWith("@g.us")) { + return undefined; + } + return normalized; +} + +export const whatsappApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "WhatsApp", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveWhatsAppAccount({ cfg, accountId }); + return resolveApprovalApprovers({ + allowFrom: account.allowFrom, + defaultTo: account.defaultTo, + normalizeApprover: normalizeWhatsAppApproverId, + }); + }, + normalizeSenderId: (value) => normalizeWhatsAppApproverId(value), +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index ec75f272ad0..a25c026bd97 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -9,6 +9,7 @@ import { // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { createWhatsAppLoginTool } from "./agent-tools-login.js"; +import { whatsappApprovalAuth } from "./approval-auth.js"; import type { WebChannelStatus } from "./auto-reply/types.js"; import { listWhatsAppDirectoryGroupsFromConfig, @@ -207,6 +208,7 @@ export const whatsappPlugin: ChannelPlugin = }, }, auth: { + ...whatsappApprovalAuth, login: async ({ cfg, accountId, runtime, verbose }) => { const resolvedAccountId = accountId?.trim() || diff --git a/extensions/zalo/src/approval-auth.test.ts b/extensions/zalo/src/approval-auth.test.ts new file mode 100644 index 00000000000..a718890fe31 --- /dev/null +++ b/extensions/zalo/src/approval-auth.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { zaloApprovalAuth } from "./approval-auth.js"; + +describe("zaloApprovalAuth", () => { + it("authorizes numeric Zalo user ids", () => { + const cfg = { channels: { zalo: { allowFrom: ["zl:123"] } } }; + + expect( + zaloApprovalAuth.authorizeActorAction({ + cfg, + senderId: "123", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); +}); diff --git a/extensions/zalo/src/approval-auth.ts b/extensions/zalo/src/approval-auth.ts new file mode 100644 index 00000000000..c8c6fb9b71b --- /dev/null +++ b/extensions/zalo/src/approval-auth.ts @@ -0,0 +1,25 @@ +import { + createResolvedApproverActionAuthAdapter, + resolveApprovalApprovers, +} from "openclaw/plugin-sdk/approval-runtime"; +import { resolveZaloAccount } from "./accounts.js"; + +function normalizeZaloApproverId(value: string | number): string | undefined { + const normalized = String(value) + .trim() + .replace(/^(zalo|zl):/i, "") + .trim(); + return /^\d+$/.test(normalized) ? normalized : undefined; +} + +export const zaloApprovalAuth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Zalo", + resolveApprovers: ({ cfg, accountId }) => { + const account = resolveZaloAccount({ cfg, accountId }).config; + return resolveApprovalApprovers({ + allowFrom: account.allowFrom, + normalizeApprover: normalizeZaloApproverId, + }); + }, + normalizeSenderId: (value) => normalizeZaloApproverId(value), +}); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 4a52abbe180..a4185f55a25 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -30,6 +30,7 @@ import { type ResolvedZaloAccount, } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; +import { zaloApprovalAuth } from "./approval-auth.js"; import { ZaloConfigSchema } from "./config-schema.js"; import type { ZaloProbeResult } from "./probe.js"; import { @@ -181,6 +182,7 @@ export const zaloPlugin: ChannelPlugin = }, }), }, + auth: zaloApprovalAuth, groups: { resolveRequireMention: () => true, }, diff --git a/src/plugin-sdk/approval-auth-helpers.test.ts b/src/plugin-sdk/approval-auth-helpers.test.ts new file mode 100644 index 00000000000..d257ee284a1 --- /dev/null +++ b/src/plugin-sdk/approval-auth-helpers.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { createResolvedApproverActionAuthAdapter } from "./approval-auth-helpers.js"; + +describe("createResolvedApproverActionAuthAdapter", () => { + it("falls back to generic same-chat auth when no approvers resolve", () => { + const auth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Slack", + resolveApprovers: () => [], + }); + + expect( + auth.authorizeActorAction({ + cfg: {}, + senderId: "U_OWNER", + action: "approve", + approvalKind: "exec", + }), + ).toEqual({ authorized: true }); + }); + + it("allows matching normalized approvers and rejects others", () => { + const auth = createResolvedApproverActionAuthAdapter({ + channelLabel: "Signal", + resolveApprovers: () => ["uuid:owner"], + normalizeSenderId: (value) => value.trim().toLowerCase(), + }); + + expect( + auth.authorizeActorAction({ + cfg: {}, + senderId: " UUID:OWNER ", + action: "approve", + approvalKind: "plugin", + }), + ).toEqual({ authorized: true }); + + expect( + auth.authorizeActorAction({ + cfg: {}, + senderId: "uuid:attacker", + action: "approve", + approvalKind: "plugin", + }), + ).toEqual({ + authorized: false, + reason: "❌ You are not authorized to approve plugin requests on Signal.", + }); + }); +}); diff --git a/src/plugin-sdk/approval-auth-helpers.ts b/src/plugin-sdk/approval-auth-helpers.ts new file mode 100644 index 00000000000..148eb1bf8d3 --- /dev/null +++ b/src/plugin-sdk/approval-auth-helpers.ts @@ -0,0 +1,44 @@ +import type { OpenClawConfig } from "./config-runtime.js"; + +type ApprovalKind = "exec" | "plugin"; + +function defaultNormalizeSenderId(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed || undefined; +} + +export function createResolvedApproverActionAuthAdapter(params: { + channelLabel: string; + resolveApprovers: (params: { cfg: OpenClawConfig; accountId?: string | null }) => string[]; + normalizeSenderId?: (value: string) => string | undefined; +}) { + const normalizeSenderId = params.normalizeSenderId ?? defaultNormalizeSenderId; + + return { + authorizeActorAction({ + cfg, + accountId, + senderId, + approvalKind, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; + action: "approve"; + approvalKind: ApprovalKind; + }) { + const approvers = params.resolveApprovers({ cfg, accountId }); + if (approvers.length === 0) { + return { authorized: true } as const; + } + const normalizedSenderId = senderId ? normalizeSenderId(senderId) : undefined; + if (normalizedSenderId && approvers.includes(normalizedSenderId)) { + return { authorized: true } as const; + } + return { + authorized: false, + reason: `❌ You are not authorized to approve ${approvalKind} requests on ${params.channelLabel}.`, + } as const; + }, + }; +} diff --git a/src/plugin-sdk/approval-runtime.ts b/src/plugin-sdk/approval-runtime.ts index 946c503c369..739ccd91848 100644 --- a/src/plugin-sdk/approval-runtime.ts +++ b/src/plugin-sdk/approval-runtime.ts @@ -31,6 +31,7 @@ export { type PluginApprovalRequestPayload, type PluginApprovalResolved, } from "../infra/plugin-approvals.js"; +export { createResolvedApproverActionAuthAdapter } from "./approval-auth-helpers.js"; export { createApproverRestrictedNativeApprovalAdapter } from "./approval-delivery-helpers.js"; export { resolveApprovalApprovers } from "./approval-approvers.js"; export {