fix(security): gate slash/control commands

This commit is contained in:
Peter Steinberger
2026-01-17 06:49:17 +00:00
parent 7ed55682b7
commit 6a3ed5c850
22 changed files with 758 additions and 203 deletions

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import type { Bot } from "grammy";
import { resolveAckReaction } from "../agents/identity.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
@@ -6,15 +7,19 @@ import { formatInboundEnvelope } from "../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntry,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveMentionGating } from "../channels/mention-gating.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import {
buildGroupLabel,
buildSenderLabel,
@@ -29,6 +34,52 @@ import {
} from "./bot/helpers.js";
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import { upsertTelegramPairingRequest } from "./pairing-store.js";
import type { TelegramContext } from "./bot/types.js";
type TelegramMediaRef = { path: string; contentType?: string };
type TelegramMessageContextOptions = {
forceWasMentioned?: boolean;
messageIdOverride?: string;
};
type TelegramLogger = {
info: (obj: Record<string, unknown>, msg: string) => void;
};
type ResolveTelegramGroupConfig = (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
type ResolveGroupActivation = (params: {
chatId: string | number;
agentId?: string;
messageThreadId?: number;
sessionKey?: string;
}) => boolean | undefined;
type ResolveGroupRequireMention = (chatId: string | number) => boolean;
type BuildTelegramMessageContextParams = {
primaryCtx: TelegramContext;
allMedia: TelegramMediaRef[];
storeAllowFrom: string[];
options?: TelegramMessageContextOptions;
bot: Bot;
cfg: ClawdbotConfig;
account: { accountId: string };
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
dmPolicy: DmPolicy;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
ackReactionScope: "off" | "group-mentions" | "group-all" | "direct" | "all";
logger: TelegramLogger;
resolveGroupActivation: ResolveGroupActivation;
resolveGroupRequireMention: ResolveGroupRequireMention;
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
};
export const buildTelegramMessageContext = async ({
primaryCtx,
@@ -48,7 +99,7 @@ export const buildTelegramMessageContext = async ({
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
}) => {
}: BuildTelegramMessageContextParams) => {
const msg = primaryCtx.message;
recordChannelActivity({
channel: "telegram",
@@ -205,8 +256,10 @@ export const buildTelegramMessageContext = async ({
senderUsername,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandAuthorized =
useAccessGroups && !allowForCommands.hasEntries ? false : senderAllowedForCommands;
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
});
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
let placeholder = "";
@@ -227,7 +280,7 @@ export const buildTelegramMessageContext = async ({
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
const computedWasMentioned =
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
(botUsername ? hasBotMention(msg, botUsername) : false) ||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(

View File

@@ -1,4 +1,4 @@
// @ts-nocheck
import type { Bot, Context } from "grammy";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import {
@@ -16,6 +16,16 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type {
ReplyToMode,
TelegramAccountConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../config/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { deliverReplies } from "./bot/delivery.js";
import { buildInlineKeyboard } from "./send.js";
import {
@@ -27,6 +37,31 @@ import {
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import { readTelegramAllowFromStore } from "./pairing-store.js";
type TelegramNativeCommandContext = Context & { match?: string };
type RegisterTelegramNativeCommandsParams = {
bot: Bot;
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
accountId: string;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
replyToMode: ReplyToMode;
textLimit: number;
useAccessGroups: boolean;
nativeEnabled: boolean;
nativeSkillsEnabled: boolean;
nativeDisabledExplicit: boolean;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
shouldSkipUpdate: (ctx: unknown) => boolean;
opts: { token: string };
};
export const registerTelegramNativeCommands = ({
bot,
cfg,
@@ -45,7 +80,7 @@ export const registerTelegramNativeCommands = ({
resolveTelegramGroupConfig,
shouldSkipUpdate,
opts,
}) => {
}: RegisterTelegramNativeCommandsParams) => {
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled
@@ -74,24 +109,15 @@ export const registerTelegramNativeCommands = ({
];
if (allCommands.length > 0) {
const api = bot.api as unknown as {
setMyCommands?: (
commands: Array<{ command: string; description: string }>,
) => Promise<unknown>;
};
if (typeof api.setMyCommands === "function") {
api.setMyCommands(allCommands).catch((err) => {
bot.api.setMyCommands(allCommands).catch((err) => {
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
});
} else {
logVerbose("telegram: setMyCommands unavailable; skipping registration");
}
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
logVerbose("telegram: bot.command unavailable; skipping native handlers");
} else {
for (const command of nativeCommands) {
bot.command(command.name, async (ctx) => {
bot.command(command.name, async (ctx: TelegramNativeCommandContext) => {
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
@@ -172,18 +198,17 @@ export const registerTelegramNativeCommands = ({
: [];
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const allowFromConfigured = allowFromList.length > 0;
const commandAuthorized = allowFromConfigured
? allowFromList.includes("*") ||
(senderId && allowFromList.includes(senderId)) ||
(senderId && allowFromList.includes(`telegram:${senderId}`)) ||
(senderUsername &&
allowFromList.some(
(entry) =>
entry.toLowerCase() === senderUsername.toLowerCase() ||
entry.toLowerCase() === `@${senderUsername.toLowerCase()}`,
))
: !useAccessGroups;
const dmAllow = normalizeAllowFrom(allowFromList);
const senderAllowed = isSenderAllowed({
allow: dmAllow,
senderId,
senderUsername,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
return;
@@ -208,7 +233,7 @@ export const registerTelegramNativeCommands = ({
cfg,
})
: null;
if (menu) {
if (menu && commandDefinition) {
const title =
menu.title ??
`Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`;
@@ -316,15 +341,8 @@ export const registerTelegramNativeCommands = ({
}
}
} else if (nativeDisabledExplicit) {
const api = bot.api as unknown as {
setMyCommands?: (commands: []) => Promise<unknown>;
};
if (typeof api.setMyCommands === "function") {
api.setMyCommands([]).catch((err) => {
runtime.error?.(danger(`telegram clear commands failed: ${String(err)}`));
});
} else {
logVerbose("telegram: setMyCommands unavailable; skipping clear");
}
bot.api.setMyCommands([]).catch((err) => {
runtime.error?.(danger(`telegram clear commands failed: ${String(err)}`));
});
}
};