From 564be6b4024e14bfe8adfbb47e31c4190ef8dac0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 22:36:05 +0100 Subject: [PATCH] refactor(channels): unify dm pairing policy flows --- .../src/matrix/monitor/access-policy.ts | 127 ++++++++++++++++++ .../matrix/src/matrix/monitor/handler.ts | 100 ++++---------- src/pairing/pairing-challenge.ts | 48 +++++++ src/plugin-sdk/index.ts | 1 + src/signal/monitor/access-policy.ts | 87 ++++++++++++ src/signal/monitor/event-handler.ts | 78 ++++------- src/slack/monitor/dm-auth.ts | 67 +++++++++ src/slack/monitor/message-handler/prepare.ts | 79 ++++------- src/slack/monitor/slash.ts | 108 ++++++--------- 9 files changed, 443 insertions(+), 252 deletions(-) create mode 100644 extensions/matrix/src/matrix/monitor/access-policy.ts create mode 100644 src/pairing/pairing-challenge.ts create mode 100644 src/signal/monitor/access-policy.ts create mode 100644 src/slack/monitor/dm-auth.ts diff --git a/extensions/matrix/src/matrix/monitor/access-policy.ts b/extensions/matrix/src/matrix/monitor/access-policy.ts new file mode 100644 index 00000000000..e937ba81848 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/access-policy.ts @@ -0,0 +1,127 @@ +import { + formatAllowlistMatchMeta, + issuePairingChallenge, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "openclaw/plugin-sdk"; +import { + normalizeMatrixAllowList, + resolveMatrixAllowListMatch, + resolveMatrixAllowListMatches, +} from "./allowlist.js"; + +type MatrixDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; +type MatrixGroupPolicy = "open" | "allowlist" | "disabled"; + +export async function resolveMatrixAccessState(params: { + isDirectMessage: boolean; + resolvedAccountId: string; + dmPolicy: MatrixDmPolicy; + groupPolicy: MatrixGroupPolicy; + allowFrom: string[]; + groupAllowFrom: Array; + senderId: string; + readStoreForDmPolicy: (provider: string, accountId: string) => Promise; +}) { + const storeAllowFrom = params.isDirectMessage + ? await readStoreAllowFromForDmPolicy({ + provider: "matrix", + accountId: params.resolvedAccountId, + dmPolicy: params.dmPolicy, + readStore: params.readStoreForDmPolicy, + }) + : []; + const normalizedGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom); + const senderGroupPolicy = + params.groupPolicy === "disabled" + ? "disabled" + : normalizedGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: !params.isDirectMessage, + dmPolicy: params.dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMatrixAllowListMatches({ + allowList: normalizeMatrixAllowList(allowFrom), + userId: params.senderId, + }), + }); + const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom); + const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom); + return { + access, + effectiveAllowFrom, + effectiveGroupAllowFrom, + groupAllowConfigured: effectiveGroupAllowFrom.length > 0, + }; +} + +export async function enforceMatrixDirectMessageAccess(params: { + dmEnabled: boolean; + dmPolicy: MatrixDmPolicy; + accessDecision: "allow" | "block" | "pairing"; + senderId: string; + senderName: string; + effectiveAllowFrom: string[]; + upsertPairingRequest: (input: { + id: string; + meta?: Record; + }) => Promise<{ + code: string; + created: boolean; + }>; + sendPairingReply: (text: string) => Promise; + logVerboseMessage: (message: string) => void; +}): Promise { + if (!params.dmEnabled) { + return false; + } + if (params.accessDecision === "allow") { + return true; + } + const allowMatch = resolveMatrixAllowListMatch({ + allowList: params.effectiveAllowFrom, + userId: params.senderId, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (params.accessDecision === "pairing") { + await issuePairingChallenge({ + channel: "matrix", + senderId: params.senderId, + senderIdLine: `Matrix user id: ${params.senderId}`, + meta: { name: params.senderName }, + upsertPairingRequest: params.upsertPairingRequest, + buildReplyText: ({ code }) => + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix ", + ].join("\n"), + sendPairingReply: params.sendPairingReply, + onCreated: () => { + params.logVerboseMessage( + `matrix pairing request sender=${params.senderId} name=${params.senderName ?? "unknown"} (${allowMatchMeta})`, + ); + }, + onReplyError: (err) => { + params.logVerboseMessage( + `matrix pairing reply failed for ${params.senderId}: ${String(err)}`, + ); + }, + }); + return false; + } + params.logVerboseMessage( + `matrix: blocked dm sender ${params.senderId} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`, + ); + return false; +} diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index fd1e969717d..fc441b83f9a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -7,9 +7,7 @@ import { formatAllowlistMatchMeta, logInboundDrop, logTypingFailure, - readStoreAllowFromForDmPolicy, resolveControlCommandGate, - resolveDmGroupAccessWithLists, type PluginRuntime, type RuntimeEnv, type RuntimeLogger, @@ -23,6 +21,7 @@ import { type PollStartContent, } from "../poll-types.js"; import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js"; +import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch, @@ -234,81 +233,34 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam senderId, senderUsername, }); - const storeAllowFrom = isDirectMessage - ? await readStoreAllowFromForDmPolicy({ - provider: "matrix", - accountId: resolvedAccountId, - dmPolicy, - readStore: pairing.readStoreForDmPolicy, - }) - : []; const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const normalizedGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); - const senderGroupPolicy = - groupPolicy === "disabled" - ? "disabled" - : normalizedGroupAllowFrom.length > 0 - ? "allowlist" - : "open"; - const access = resolveDmGroupAccessWithLists({ - isGroup: isRoom, - dmPolicy, - groupPolicy: senderGroupPolicy, - allowFrom, - groupAllowFrom: normalizedGroupAllowFrom, - storeAllowFrom, - groupAllowFromFallbackToAllowFrom: false, - isSenderAllowed: (allowFrom) => - resolveMatrixAllowListMatches({ - allowList: normalizeMatrixAllowList(allowFrom), - userId: senderId, - }), - }); - const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom); - const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom); - const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; + const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } = + await resolveMatrixAccessState({ + isDirectMessage, + resolvedAccountId, + dmPolicy, + groupPolicy, + allowFrom, + groupAllowFrom, + senderId, + readStoreForDmPolicy: pairing.readStoreForDmPolicy, + }); if (isDirectMessage) { - if (!dmEnabled) { - return; - } - if (access.decision !== "allow") { - const allowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveAllowFrom, - userId: senderId, - }); - const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (access.decision === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderId, - meta: { name: senderName }, - }); - if (created) { - logVerboseMessage( - `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, - ); - try { - await sendMessageMatrix( - `room:${roomId}`, - [ - "OpenClaw: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "openclaw pairing approve matrix ", - ].join("\n"), - { client }, - ); - } catch (err) { - logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); - } - } - } else { - logVerboseMessage( - `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } + const allowedDirectMessage = await enforceMatrixDirectMessageAccess({ + dmEnabled, + dmPolicy, + accessDecision: access.decision, + senderId, + senderName, + effectiveAllowFrom, + upsertPairingRequest: pairing.upsertPairingRequest, + sendPairingReply: async (text) => { + await sendMessageMatrix(`room:${roomId}`, text, { client }); + }, + logVerboseMessage, + }); + if (!allowedDirectMessage) { return; } } diff --git a/src/pairing/pairing-challenge.ts b/src/pairing/pairing-challenge.ts new file mode 100644 index 00000000000..8bf068f8d23 --- /dev/null +++ b/src/pairing/pairing-challenge.ts @@ -0,0 +1,48 @@ +import { buildPairingReply } from "./pairing-messages.js"; + +type PairingMeta = Record; + +export type PairingChallengeParams = { + channel: string; + senderId: string; + senderIdLine: string; + meta?: PairingMeta; + upsertPairingRequest: (params: { + id: string; + meta?: PairingMeta; + }) => Promise<{ code: string; created: boolean }>; + sendPairingReply: (text: string) => Promise; + buildReplyText?: (params: { code: string; senderIdLine: string }) => string; + onCreated?: (params: { code: string }) => void; + onReplyError?: (err: unknown) => void; +}; + +/** + * Shared pairing challenge issuance for DM pairing policy pathways. + * Ensures every channel follows the same create-if-missing + reply flow. + */ +export async function issuePairingChallenge( + params: PairingChallengeParams, +): Promise<{ created: boolean; code?: string }> { + const { code, created } = await params.upsertPairingRequest({ + id: params.senderId, + meta: params.meta, + }); + if (!created) { + return { created: false }; + } + params.onCreated?.({ code }); + const replyText = + params.buildReplyText?.({ code, senderIdLine: params.senderIdLine }) ?? + buildPairingReply({ + channel: params.channel, + idLine: params.senderIdLine, + code, + }); + try { + await params.sendPairingReply(replyText); + } catch (err) { + params.onReplyError?.(err); + } + return { created: true, code }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a4b32b182e9..6a0829c0b9f 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -217,6 +217,7 @@ export { } from "./group-access.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { extractToolSend } from "./tool-send.js"; export { diff --git a/src/signal/monitor/access-policy.ts b/src/signal/monitor/access-policy.ts new file mode 100644 index 00000000000..e836868ec8d --- /dev/null +++ b/src/signal/monitor/access-policy.ts @@ -0,0 +1,87 @@ +import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "../../security/dm-policy-shared.js"; +import { isSignalSenderAllowed, type SignalSender } from "../identity.js"; + +type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; +type SignalGroupPolicy = "open" | "allowlist" | "disabled"; + +export async function resolveSignalAccessState(params: { + accountId: string; + dmPolicy: SignalDmPolicy; + groupPolicy: SignalGroupPolicy; + allowFrom: string[]; + groupAllowFrom: string[]; + sender: SignalSender; +}) { + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: "signal", + accountId: params.accountId, + dmPolicy: params.dmPolicy, + }); + const resolveAccessDecision = (isGroup: boolean) => + resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowEntries) => isSignalSenderAllowed(params.sender, allowEntries), + }); + const dmAccess = resolveAccessDecision(false); + return { + resolveAccessDecision, + dmAccess, + effectiveDmAllow: dmAccess.effectiveAllowFrom, + effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom, + }; +} + +export async function handleSignalDirectMessageAccess(params: { + dmPolicy: SignalDmPolicy; + dmAccessDecision: "allow" | "block" | "pairing"; + senderId: string; + senderIdLine: string; + senderDisplay: string; + senderName?: string; + accountId: string; + sendPairingReply: (text: string) => Promise; + log: (message: string) => void; +}): Promise { + if (params.dmAccessDecision === "allow") { + return true; + } + if (params.dmAccessDecision === "block") { + if (params.dmPolicy !== "disabled") { + params.log(`Blocked signal sender ${params.senderDisplay} (dmPolicy=${params.dmPolicy})`); + } + return false; + } + if (params.dmPolicy === "pairing") { + await issuePairingChallenge({ + channel: "signal", + senderId: params.senderId, + senderIdLine: params.senderIdLine, + meta: { name: params.senderName }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "signal", + id, + accountId: params.accountId, + meta, + }), + sendPairingReply: params.sendPairingReply, + onCreated: () => { + params.log(`signal pairing request sender=${params.senderId}`); + }, + onReplyError: (err) => { + params.log(`signal pairing reply failed for ${params.senderId}: ${String(err)}`); + }, + }); + } + return false; +} diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index f5e89d8cb1c..9aea1f6433a 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -30,14 +30,8 @@ import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { mediaKindFromMime } from "../../media/constants.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { - DM_GROUP_ACCESS_REASON, - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "../../security/dm-policy-shared.js"; +import { DM_GROUP_ACCESS_REASON } from "../../security/dm-policy-shared.js"; import { normalizeE164 } from "../../utils.js"; import { formatSignalPairingIdLine, @@ -50,6 +44,7 @@ import { type SignalSender, } from "../identity.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; +import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js"; import type { SignalEnvelope, SignalEventHandlerDeps, @@ -454,24 +449,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const hasBodyContent = Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length); const senderDisplay = formatSignalSenderDisplay(sender); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "signal", - accountId: deps.accountId, - dmPolicy: deps.dmPolicy, - }); - const resolveAccessDecision = (isGroup: boolean) => - resolveDmGroupAccessWithLists({ - isGroup, + const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } = + await resolveSignalAccessState({ + accountId: deps.accountId, dmPolicy: deps.dmPolicy, groupPolicy: deps.groupPolicy, allowFrom: deps.allowFrom, groupAllowFrom: deps.groupAllowFrom, - storeAllowFrom, - isSenderAllowed: (allowEntries) => isSignalSenderAllowed(sender, allowEntries), + sender, }); - const dmAccess = resolveAccessDecision(false); - const effectiveDmAllow = dmAccess.effectiveAllowFrom; - const effectiveGroupAllow = dmAccess.effectiveGroupAllowFrom; if ( reaction && @@ -502,43 +488,25 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const isGroup = Boolean(groupId); if (!isGroup) { - if (dmAccess.decision === "block") { - if (deps.dmPolicy !== "disabled") { - logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`); - } - return; - } - if (dmAccess.decision === "pairing") { - if (deps.dmPolicy === "pairing") { - const senderId = senderAllowId; - const { code, created } = await upsertChannelPairingRequest({ - channel: "signal", - id: senderId, + const allowedDirectMessage = await handleSignalDirectMessageAccess({ + dmPolicy: deps.dmPolicy, + dmAccessDecision: dmAccess.decision, + senderId: senderAllowId, + senderIdLine, + senderDisplay, + senderName: envelope.sourceName ?? undefined, + accountId: deps.accountId, + sendPairingReply: async (text) => { + await sendMessageSignal(`signal:${senderRecipient}`, text, { + baseUrl: deps.baseUrl, + account: deps.account, + maxBytes: deps.mediaMaxBytes, accountId: deps.accountId, - meta: { name: envelope.sourceName ?? undefined }, }); - if (created) { - logVerbose(`signal pairing request sender=${senderId}`); - try { - await sendMessageSignal( - `signal:${senderRecipient}`, - buildPairingReply({ - channel: "signal", - idLine: senderIdLine, - code, - }), - { - baseUrl: deps.baseUrl, - account: deps.account, - maxBytes: deps.mediaMaxBytes, - accountId: deps.accountId, - }, - ); - } catch (err) { - logVerbose(`signal pairing reply failed for ${senderId}: ${String(err)}`); - } - } - } + }, + log: logVerbose, + }); + if (!allowedDirectMessage) { return; } } diff --git a/src/slack/monitor/dm-auth.ts b/src/slack/monitor/dm-auth.ts new file mode 100644 index 00000000000..f11a2aa51f7 --- /dev/null +++ b/src/slack/monitor/dm-auth.ts @@ -0,0 +1,67 @@ +import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; +import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { resolveSlackAllowListMatch } from "./allow-list.js"; +import type { SlackMonitorContext } from "./context.js"; + +export async function authorizeSlackDirectMessage(params: { + ctx: SlackMonitorContext; + accountId: string; + senderId: string; + allowFromLower: string[]; + resolveSenderName: (senderId: string) => Promise<{ name?: string }>; + sendPairingReply: (text: string) => Promise; + onDisabled: () => Promise | void; + onUnauthorized: (params: { allowMatchMeta: string; senderName?: string }) => Promise | void; + log: (message: string) => void; +}): Promise { + if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { + await params.onDisabled(); + return false; + } + if (params.ctx.dmPolicy === "open") { + return true; + } + + const sender = await params.resolveSenderName(params.senderId); + const senderName = sender?.name ?? undefined; + const allowMatch = resolveSlackAllowListMatch({ + allowList: params.allowFromLower, + id: params.senderId, + name: senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (allowMatch.allowed) { + return true; + } + + if (params.ctx.dmPolicy === "pairing") { + await issuePairingChallenge({ + channel: "slack", + senderId: params.senderId, + senderIdLine: `Your Slack user id: ${params.senderId}`, + meta: { name: senderName }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "slack", + id, + accountId: params.accountId, + meta, + }), + sendPairingReply: params.sendPairingReply, + onCreated: () => { + params.log( + `slack pairing request sender=${params.senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + }, + onReplyError: (err) => { + params.log(`slack pairing reply failed for ${params.senderId}: ${String(err)}`); + }, + }); + return false; + } + + await params.onUnauthorized({ allowMatchMeta, senderName }); + return false; +} diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 7b9f9f9d5ef..02ee265f7ca 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -19,7 +19,6 @@ import { shouldAckReaction as shouldAckReactionGate, type AckReactionScope, } from "../../../channels/ack-reactions.js"; -import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { logInboundDrop } from "../../../channels/logging.js"; @@ -28,8 +27,6 @@ import { recordInboundSession } from "../../../channels/session.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; -import { buildPairingReply } from "../../../pairing/pairing-messages.js"; -import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; @@ -42,6 +39,7 @@ import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { stripSlackMentionsForCommandDetection } from "../commands.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; +import { authorizeSlackDirectMessage } from "../dm-auth.js"; import { resolveSlackAttachmentContent, MAX_SLACK_MEDIA_FILES, @@ -137,59 +135,32 @@ export async function prepareSlackMessage(params: { logVerbose("slack: drop dm message (missing user id)"); return null; } - if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") { - logVerbose("slack: drop dm (dms disabled)"); + const allowed = await authorizeSlackDirectMessage({ + ctx, + accountId: account.accountId, + senderId: directUserId, + allowFromLower, + resolveSenderName: ctx.resolveUserName, + sendPairingReply: async (text) => { + await sendMessageSlack(message.channel, text, { + token: ctx.botToken, + client: ctx.app.client, + accountId: account.accountId, + }); + }, + onDisabled: () => { + logVerbose("slack: drop dm (dms disabled)"); + }, + onUnauthorized: ({ allowMatchMeta }) => { + logVerbose( + `Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`, + ); + }, + log: logVerbose, + }); + if (!allowed) { return null; } - if (ctx.dmPolicy !== "open") { - const allowMatch = resolveSlackAllowListMatch({ - allowList: allowFromLower, - id: directUserId, - allowNameMatching: ctx.allowNameMatching, - }); - const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (ctx.dmPolicy === "pairing") { - const sender = await ctx.resolveUserName(directUserId); - const senderName = sender?.name ?? undefined; - const { code, created } = await upsertChannelPairingRequest({ - channel: "slack", - id: directUserId, - accountId: account.accountId, - meta: { name: senderName }, - }); - if (created) { - logVerbose( - `slack pairing request sender=${directUserId} name=${ - senderName ?? "unknown" - } (${allowMatchMeta})`, - ); - try { - await sendMessageSlack( - message.channel, - buildPairingReply({ - channel: "slack", - idLine: `Your Slack user id: ${directUserId}`, - code, - }), - { - token: ctx.botToken, - client: ctx.app.client, - accountId: account.accountId, - }, - ); - } catch (err) { - logVerbose(`slack pairing reply failed for ${message.user}: ${String(err)}`); - } - } - } else { - logVerbose( - `Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`, - ); - } - return null; - } - } } const route = resolveAgentRoute({ diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 7567609ae0e..c494a3696e5 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -1,25 +1,18 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; import { danger, logVerbose } from "../../globals.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; -import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js"; import { chunkItems } from "../../utils/chunk-items.js"; import type { ResolvedSlackAccount } from "../accounts.js"; -import { - normalizeAllowList, - normalizeAllowListLower, - resolveSlackAllowListMatch, - resolveSlackUserAllowed, -} from "./allow-list.js"; +import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js"; +import { resolveSlackEffectiveAllowFrom } from "./auth.js"; import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js"; import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; import { normalizeSlackChannelType } from "./context.js"; +import { authorizeSlackDirectMessage } from "./dm-auth.js"; import { createSlackExternalArgMenuStore, SLACK_EXTERNAL_ARG_MENU_PREFIX, @@ -333,73 +326,50 @@ export async function registerSlackMonitorSlashCommands(params: { return; } - const storeAllowFrom = isDirectMessage - ? await readStoreAllowFromForDmPolicy({ - provider: "slack", - accountId: ctx.accountId, - dmPolicy: ctx.dmPolicy, - }) - : []; - const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); - const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom); + const { allowFromLower: effectiveAllowFromLower } = await resolveSlackEffectiveAllowFrom( + ctx, + { + includePairingStore: isDirectMessage, + }, + ); // Privileged command surface: compute CommandAuthorized, don't assume true. // Keep this aligned with the Slack message path (message-handler/prepare.ts). let commandAuthorized = false; let channelConfig: SlackChannelConfigResolved | null = null; if (isDirectMessage) { - if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") { - await respond({ - text: "Slack DMs are disabled.", - response_type: "ephemeral", - }); + const allowed = await authorizeSlackDirectMessage({ + ctx, + accountId: ctx.accountId, + senderId: command.user_id, + allowFromLower: effectiveAllowFromLower, + resolveSenderName: ctx.resolveUserName, + sendPairingReply: async (text) => { + await respond({ + text, + response_type: "ephemeral", + }); + }, + onDisabled: async () => { + await respond({ + text: "Slack DMs are disabled.", + response_type: "ephemeral", + }); + }, + onUnauthorized: async ({ allowMatchMeta }) => { + logVerbose( + `slack: blocked slash sender ${command.user_id} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`, + ); + await respond({ + text: "You are not authorized to use this command.", + response_type: "ephemeral", + }); + }, + log: logVerbose, + }); + if (!allowed) { return; } - if (ctx.dmPolicy !== "open") { - const sender = await ctx.resolveUserName(command.user_id); - const senderName = sender?.name ?? undefined; - const allowMatch = resolveSlackAllowListMatch({ - allowList: effectiveAllowFromLower, - id: command.user_id, - name: senderName, - allowNameMatching: ctx.allowNameMatching, - }); - const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (ctx.dmPolicy === "pairing") { - const { code, created } = await upsertChannelPairingRequest({ - channel: "slack", - id: command.user_id, - accountId: ctx.accountId, - meta: { name: senderName }, - }); - if (created) { - logVerbose( - `slack pairing request sender=${command.user_id} name=${ - senderName ?? "unknown" - } (${allowMatchMeta})`, - ); - await respond({ - text: buildPairingReply({ - channel: "slack", - idLine: `Your Slack user id: ${command.user_id}`, - code, - }), - response_type: "ephemeral", - }); - } - } else { - logVerbose( - `slack: blocked slash sender ${command.user_id} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`, - ); - await respond({ - text: "You are not authorized to use this command.", - response_type: "ephemeral", - }); - } - return; - } - } } if (isRoom) {