refactor: add approval auth capabilities to more channels

This commit is contained in:
Peter Steinberger 2026-03-30 09:03:41 +09:00
parent 63cbc097b5
commit c2cbdea28c
No known key found for this signature in database
37 changed files with 697 additions and 0 deletions

View File

@ -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.

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedFeishuAccount, FeishuProbeResul
},
}),
},
auth: feishuApprovalAuth,
actions: {
describeMessageTool: describeFeishuMessageTool,
handleAction: async (ctx) => {

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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,
},

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedMatrixAccount, MatrixProbe> =
},
}),
},
auth: matrixApprovalAuth,
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedMattermostAccount> = create
},
}),
},
auth: mattermostApprovalAuth,
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},

View File

@ -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 });
});
});

View File

@ -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;
},
});

View File

@ -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<ResolvedMSTeamsAccount, ProbeMSTeamsRe
configured: account.configured,
}),
},
auth: msTeamsApprovalAuth,
setup: msteamsSetupAdapter,
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { nextcloudTalkApprovalAuth } from "./approval-auth.js";
describe("nextcloudTalkApprovalAuth", () => {
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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedNextcloudTalkAccount> =
},
}),
},
auth: nextcloudTalkApprovalAuth,
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedSignalAccount, SignalProbe> =
setup: signalSetupAdapter,
}),
actions: signalMessageActions,
auth: signalApprovalAuth,
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "signal",
resolveAccount: resolveSignalAccount,

View File

@ -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.",
});
});
});

View File

@ -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),
});

View File

@ -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<ResolvedSlackAccount, SlackProbe> = crea
}),
resolveNames: resolveSlackAllowlistNames,
},
auth: slackApprovalAuth,
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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();

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedWhatsAppAccount> =
},
},
auth: {
...whatsappApprovalAuth,
login: async ({ cfg, accountId, runtime, verbose }) => {
const resolvedAccountId =
accountId?.trim() ||

View File

@ -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 });
});
});

View File

@ -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),
});

View File

@ -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<ResolvedZaloAccount, ZaloProbeResult> =
},
}),
},
auth: zaloApprovalAuth,
groups: {
resolveRequireMention: () => true,
},

View File

@ -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.",
});
});
});

View File

@ -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;
},
};
}

View File

@ -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 {