refactor: standardize control command gating

This commit is contained in:
Peter Steinberger
2026-01-23 23:10:59 +00:00
parent 1113f17d4c
commit 07ce1d73ff
8 changed files with 82 additions and 74 deletions

View File

@@ -1,7 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveAckReaction } from "clawdbot/plugin-sdk"; import { resolveAckReaction, resolveControlCommandGate } from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -1346,18 +1346,19 @@ async function processMessage(
}) })
: false; : false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandAuthorized = isGroup const commandGate = resolveControlCommandGate({
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups,
useAccessGroups, authorizers: [
authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ],
], allowTextCommands: true,
}) hasControlCommand: hasControlCmd,
: dmAuthorized; });
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
// Block control commands from unauthorized senders in groups // Block control commands from unauthorized senders in groups
if (isGroup && hasControlCmd && !commandAuthorized) { if (isGroup && commandGate.shouldBlock) {
logVerbose( logVerbose(
core, core,
runtime, runtime,

View File

@@ -4,6 +4,7 @@ import {
createReplyPrefixContext, createReplyPrefixContext,
createTypingCallbacks, createTypingCallbacks,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
resolveControlCommandGate,
type RuntimeEnv, type RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js"; import type { CoreConfig, ReplyToMode } from "../../types.js";
@@ -378,20 +379,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName, userName: senderName,
}) })
: false; : false;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup }, { configured: groupAllowConfigured, allowed: senderAllowedForGroup },
], ],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
}); });
if ( const commandAuthorized = commandGate.commandAuthorized;
isRoom && if (isRoom && commandGate.shouldBlock) {
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
return; return;
} }
@@ -411,7 +411,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned && !wasMentioned &&
!hasExplicitMention && !hasExplicitMention &&
commandAuthorized && commandAuthorized &&
core.channel.text.hasControlCommand(bodyText); hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message"); logger.info({ roomId, reason: "no-mention" }, "skipping room message");

View File

@@ -13,6 +13,7 @@ import {
clearHistoryEntriesIfEnabled, clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveChannelMediaMaxBytes, resolveChannelMediaMaxBytes,
type HistoryEntry, type HistoryEntry,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -398,7 +399,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg, cfg,
surface: "mattermost", surface: "mattermost",
}); });
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({ const senderAllowedForCommands = isSenderAllowed({
senderId, senderId,
@@ -410,19 +412,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderName, senderName,
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
}); });
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = const commandAuthorized =
kind === "dm" kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized;
? dmPolicy === "open" || senderAllowedForCommands
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
});
if (kind === "dm") { if (kind === "dm") {
if (dmPolicy === "disabled") { if (dmPolicy === "disabled") {
@@ -483,7 +486,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
} }
} }
if (kind !== "dm" && isControlCommand && !commandAuthorized) { if (kind !== "dm" && commandGate.shouldBlock) {
logVerboseMessage( logVerboseMessage(
`mattermost: drop control command from unauthorized sender ${senderId}`, `mattermost: drop control command from unauthorized sender ${senderId}`,
); );

View File

@@ -3,6 +3,7 @@ import {
clearHistoryEntriesIfEnabled, clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveMentionGating, resolveMentionGating,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
type HistoryEntry, type HistoryEntry,
@@ -251,14 +252,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId, senderId,
senderName, senderName,
}); });
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
}); });
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) { const commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`); logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
return; return;
} }

View File

@@ -1,4 +1,4 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; import { resolveControlCommandGate, type ClawdbotConfig, type RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import { import {
@@ -118,7 +118,11 @@ export async function handleNextcloudTalkInbound(params: {
senderId, senderId,
senderName, senderName,
}).allowed; }).allowed;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ {
@@ -127,7 +131,10 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands, allowed: senderAllowedForCommands,
}, },
], ],
allowTextCommands,
hasControlCommand,
}); });
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) { if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({ const groupAllow = resolveNextcloudTalkGroupAllow({
@@ -188,12 +195,7 @@ export async function handleNextcloudTalkInbound(params: {
} }
} }
if ( if (isGroup && commandGate.shouldBlock) {
isGroup &&
allowTextCommands &&
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
commandAuthorized !== true
) {
runtime.log?.( runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`, `nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
); );
@@ -212,10 +214,6 @@ export async function handleNextcloudTalkInbound(params: {
wildcardConfig: roomMatch.wildcardConfig, wildcardConfig: roomMatch.wildcardConfig,
}) })
: false; : false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({ const mentionGate = resolveNextcloudTalkMentionGate({
isGroup, isGroup,
requireMention: shouldRequireMention, requireMention: shouldRequireMention,

View File

@@ -41,7 +41,7 @@ import {
} from "../../pairing/pairing-store.js"; } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js"; import { truncateUtf16Safe } from "../../utils.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { resolveIMessageAccount } from "../accounts.js"; import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient } from "../client.js"; import { createIMessageRpcClient } from "../client.js";
import { probeIMessage } from "../probe.js"; import { probeIMessage } from "../probe.js";
@@ -372,25 +372,23 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
chatIdentifier, chatIdentifier,
}) })
: false; : false;
const commandAuthorized = isGroup const hasControlCommandInMessage = hasControlCommand(messageText, cfg);
? resolveCommandAuthorizedFromAuthorizers({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
}) allowTextCommands: true,
: dmAuthorized; hasControlCommand: hasControlCommandInMessage,
if (isGroup && hasControlCommand(messageText, cfg) && !commandAuthorized) { });
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
if (isGroup && commandGate.shouldBlock) {
logVerbose(`imessage: drop control command from unauthorized sender ${sender}`); logVerbose(`imessage: drop control command from unauthorized sender ${sender}`);
return; return;
} }
const shouldBypassMention = const shouldBypassMention =
isGroup && isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
requireMention &&
!mentioned &&
commandAuthorized &&
hasControlCommand(messageText);
const effectiveWasMentioned = mentioned || shouldBypassMention; const effectiveWasMentioned = mentioned || shouldBypassMention;
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) { if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
logVerbose(`imessage: skipping group message (no mention)`); logVerbose(`imessage: skipping group message (no mention)`);

View File

@@ -134,6 +134,7 @@ export { createReplyPrefixContext } from "../channels/reply-prefix.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export type { NormalizedLocation } from "../channels/location.js"; export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export { export {
resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention, resolveDiscordGroupRequireMention,

View File

@@ -30,7 +30,7 @@ import {
} from "../../pairing/pairing-store.js"; } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { normalizeE164 } from "../../utils.js"; import { normalizeE164 } from "../../utils.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { import {
formatSignalPairingIdLine, formatSignalPairingIdLine,
formatSignalSenderDisplay, formatSignalSenderDisplay,
@@ -439,16 +439,18 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false;
const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow); const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow);
const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow); const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
const commandAuthorized = isGroup const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
? resolveCommandAuthorizedFromAuthorizers({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands },
], ],
}) allowTextCommands: true,
: dmAllowed; hasControlCommand: hasControlCommandInMessage,
if (isGroup && hasControlCommand(messageText, deps.cfg) && !commandAuthorized) { });
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAllowed;
if (isGroup && commandGate.shouldBlock) {
logVerbose(`signal: drop control command from unauthorized sender ${senderDisplay}`); logVerbose(`signal: drop control command from unauthorized sender ${senderDisplay}`);
return; return;
} }