diff --git a/src/auto-reply/reply/elevated-allowlist-matcher.ts b/src/auto-reply/reply/elevated-allowlist-matcher.ts new file mode 100644 index 00000000000..7617b671391 --- /dev/null +++ b/src/auto-reply/reply/elevated-allowlist-matcher.ts @@ -0,0 +1,142 @@ +import { CHAT_CHANNEL_ORDER } from "../../channels/registry.js"; +import { normalizeAtHashSlug } from "../../shared/string-normalization.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; + +export type ExplicitElevatedAllowField = "id" | "from" | "e164" | "name" | "username" | "tag"; + +const EXPLICIT_ELEVATED_ALLOW_FIELDS = new Set([ + "id", + "from", + "e164", + "name", + "username", + "tag", +]); + +const SENDER_PREFIXES = [ + ...CHAT_CHANNEL_ORDER, + INTERNAL_MESSAGE_CHANNEL, + "user", + "group", + "channel", +]; +const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i"); + +export type AllowFromFormatter = (values: string[]) => string[]; + +export function stripSenderPrefix(value?: string): string { + if (!value) { + return ""; + } + const trimmed = value.trim(); + return trimmed.replace(SENDER_PREFIX_RE, ""); +} + +export function parseExplicitElevatedAllowEntry( + entry: string, +): { field: ExplicitElevatedAllowField; value: string } | null { + const separatorIndex = entry.indexOf(":"); + if (separatorIndex <= 0) { + return null; + } + const fieldRaw = entry.slice(0, separatorIndex).trim().toLowerCase(); + if (!EXPLICIT_ELEVATED_ALLOW_FIELDS.has(fieldRaw as ExplicitElevatedAllowField)) { + return null; + } + const value = entry.slice(separatorIndex + 1).trim(); + if (!value) { + return null; + } + return { + field: fieldRaw as ExplicitElevatedAllowField, + value, + }; +} + +function normalizeAllowToken(value?: string): string { + if (!value) { + return ""; + } + return value.trim().toLowerCase(); +} + +function slugAllowToken(value?: string): string { + return normalizeAtHashSlug(value); +} + +function addTokenVariants(tokens: Set, value: string): void { + if (!value) { + return; + } + tokens.add(value); + const normalized = normalizeAllowToken(value); + if (normalized) { + tokens.add(normalized); + } +} + +export function addFormattedTokens(params: { + formatAllowFrom: AllowFromFormatter; + values: string[]; + tokens: Set; +}): void { + const formatted = params.formatAllowFrom(params.values); + for (const entry of formatted) { + addTokenVariants(params.tokens, entry); + } +} + +export function matchesFormattedTokens(params: { + formatAllowFrom: AllowFromFormatter; + value: string; + includeStripped?: boolean; + tokens: Set; +}): boolean { + const probeTokens = new Set(); + const values = params.includeStripped + ? [params.value, stripSenderPrefix(params.value)].filter(Boolean) + : [params.value]; + addFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, + values, + tokens: probeTokens, + }); + for (const token of probeTokens) { + if (params.tokens.has(token)) { + return true; + } + } + return false; +} + +export function buildMutableTokens(value?: string): Set { + const tokens = new Set(); + const trimmed = value?.trim(); + if (!trimmed) { + return tokens; + } + addTokenVariants(tokens, trimmed); + const slugged = slugAllowToken(trimmed); + if (slugged) { + addTokenVariants(tokens, slugged); + } + return tokens; +} + +export function matchesMutableTokens(value: string, tokens: Set): boolean { + if (!value || tokens.size === 0) { + return false; + } + const probes = new Set(); + addTokenVariants(probes, value); + const slugged = slugAllowToken(value); + if (slugged) { + addTokenVariants(probes, slugged); + } + for (const probe of probes) { + if (tokens.has(probe)) { + return true; + } + } + return false; +} diff --git a/src/auto-reply/reply/reply-elevated.ts b/src/auto-reply/reply/reply-elevated.ts index 7e755fa0d70..1adfbc055ed 100644 --- a/src/auto-reply/reply/reply-elevated.ts +++ b/src/auto-reply/reply/reply-elevated.ts @@ -1,52 +1,20 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { getChannelDock } from "../../channels/dock.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; -import { CHAT_CHANNEL_ORDER } from "../../channels/registry.js"; import type { AgentElevatedAllowFromConfig, OpenClawConfig } from "../../config/config.js"; -import { normalizeAtHashSlug } from "../../shared/string-normalization.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { MsgContext } from "../templating.js"; +import { + type AllowFromFormatter, + type ExplicitElevatedAllowField, + addFormattedTokens, + buildMutableTokens, + matchesFormattedTokens, + matchesMutableTokens, + parseExplicitElevatedAllowEntry, + stripSenderPrefix, +} from "./elevated-allowlist-matcher.js"; export { formatElevatedUnavailableMessage } from "./elevated-unavailable.js"; -type ExplicitElevatedAllowField = "id" | "from" | "e164" | "name" | "username" | "tag"; - -const EXPLICIT_ELEVATED_ALLOW_FIELDS = new Set([ - "id", - "from", - "e164", - "name", - "username", - "tag", -]); - -function normalizeAllowToken(value?: string) { - if (!value) { - return ""; - } - return value.trim().toLowerCase(); -} - -function slugAllowToken(value?: string) { - return normalizeAtHashSlug(value); -} - -const SENDER_PREFIXES = [ - ...CHAT_CHANNEL_ORDER, - INTERNAL_MESSAGE_CHANNEL, - "user", - "group", - "channel", -]; -const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i"); - -function stripSenderPrefix(value?: string) { - if (!value) { - return ""; - } - const trimmed = value.trim(); - return trimmed.replace(SENDER_PREFIX_RE, ""); -} - function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, provider: string, @@ -59,122 +27,31 @@ function resolveElevatedAllowList( return Array.isArray(value) ? value : fallbackAllowFrom; } -function parseExplicitElevatedAllowEntry( - entry: string, -): { field: ExplicitElevatedAllowField; value: string } | null { - const separatorIndex = entry.indexOf(":"); - if (separatorIndex <= 0) { - return null; - } - const fieldRaw = entry.slice(0, separatorIndex).trim().toLowerCase(); - if (!EXPLICIT_ELEVATED_ALLOW_FIELDS.has(fieldRaw as ExplicitElevatedAllowField)) { - return null; - } - const value = entry.slice(separatorIndex + 1).trim(); - if (!value) { - return null; - } - return { - field: fieldRaw as ExplicitElevatedAllowField, - value, - }; -} - -function addTokenVariants(tokens: Set, value: string) { - if (!value) { - return; - } - tokens.add(value); - const normalized = normalizeAllowToken(value); - if (normalized) { - tokens.add(normalized); - } -} - -function addProviderFormattedTokens(params: { +function resolveAllowFromFormatter(params: { cfg: OpenClawConfig; provider: string; accountId?: string; - values: string[]; - tokens: Set; -}) { +}): AllowFromFormatter { const normalizedProvider = normalizeChannelId(params.provider); const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined; - const formatted = dock?.config?.formatAllowFrom - ? dock.config.formatAllowFrom({ - cfg: params.cfg, - accountId: params.accountId, - allowFrom: params.values, - }) - : params.values.map((entry) => String(entry).trim()).filter(Boolean); - for (const entry of formatted) { - addTokenVariants(params.tokens, entry); + const formatAllowFrom = dock?.config?.formatAllowFrom; + if (!formatAllowFrom) { + return (values) => values.map((entry) => String(entry).trim()).filter(Boolean); } -} - -function matchesProviderFormattedTokens(params: { - cfg: OpenClawConfig; - provider: string; - accountId?: string; - value: string; - includeStripped?: boolean; - tokens: Set; -}): boolean { - const probeTokens = new Set(); - const values = params.includeStripped - ? [params.value, stripSenderPrefix(params.value)].filter(Boolean) - : [params.value]; - addProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.accountId, - values, - tokens: probeTokens, - }); - for (const token of probeTokens) { - if (params.tokens.has(token)) { - return true; - } - } - return false; -} - -function buildMutableTokens(value?: string): Set { - const tokens = new Set(); - const trimmed = value?.trim(); - if (!trimmed) { - return tokens; - } - addTokenVariants(tokens, trimmed); - const slugged = slugAllowToken(trimmed); - if (slugged) { - addTokenVariants(tokens, slugged); - } - return tokens; -} - -function matchesMutableTokens(value: string, tokens: Set): boolean { - if (!value || tokens.size === 0) { - return false; - } - const probes = new Set(); - addTokenVariants(probes, value); - const slugged = slugAllowToken(value); - if (slugged) { - addTokenVariants(probes, slugged); - } - for (const probe of probes) { - if (tokens.has(probe)) { - return true; - } - } - return false; + return (values) => + formatAllowFrom({ + cfg: params.cfg, + accountId: params.accountId, + allowFrom: values, + }) + .map((entry) => String(entry).trim()) + .filter(Boolean); } function isApprovedElevatedSender(params: { - cfg: OpenClawConfig; provider: string; ctx: MsgContext; + formatAllowFrom: AllowFromFormatter; allowFrom?: AgentElevatedAllowFromConfig; fallbackAllowFrom?: Array; }): boolean { @@ -200,28 +77,22 @@ function isApprovedElevatedSender(params: { const senderE164Tokens = new Set(); if (params.ctx.SenderId?.trim()) { - addProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, + addFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, values: [params.ctx.SenderId, stripSenderPrefix(params.ctx.SenderId)].filter(Boolean), tokens: senderIdTokens, }); } if (params.ctx.From?.trim()) { - addProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, + addFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, values: [params.ctx.From, stripSenderPrefix(params.ctx.From)].filter(Boolean), tokens: senderFromTokens, }); } if (params.ctx.SenderE164?.trim()) { - addProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, + addFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, values: [params.ctx.SenderE164], tokens: senderE164Tokens, }); @@ -236,14 +107,38 @@ function isApprovedElevatedSender(params: { const senderUsernameTokens = buildMutableTokens(params.ctx.SenderUsername); const senderTagTokens = buildMutableTokens(params.ctx.SenderTag); + const explicitFieldMatchers: Record boolean> = { + id: (value) => + matchesFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, + value, + includeStripped: true, + tokens: senderIdTokens, + }), + from: (value) => + matchesFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, + value, + includeStripped: true, + tokens: senderFromTokens, + }), + e164: (value) => + matchesFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, + value, + tokens: senderE164Tokens, + }), + name: (value) => matchesMutableTokens(value, senderNameTokens), + username: (value) => matchesMutableTokens(value, senderUsernameTokens), + tag: (value) => matchesMutableTokens(value, senderTagTokens), + }; + for (const entry of allowTokens) { const explicitEntry = parseExplicitElevatedAllowEntry(entry); if (!explicitEntry) { if ( - matchesProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, + matchesFormattedTokens({ + formatAllowFrom: params.formatAllowFrom, value: entry, includeStripped: true, tokens: senderIdentityTokens, @@ -253,66 +148,8 @@ function isApprovedElevatedSender(params: { } continue; } - if (explicitEntry.field === "id") { - if ( - matchesProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, - value: explicitEntry.value, - includeStripped: true, - tokens: senderIdTokens, - }) - ) { - return true; - } - continue; - } - if (explicitEntry.field === "from") { - if ( - matchesProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, - value: explicitEntry.value, - includeStripped: true, - tokens: senderFromTokens, - }) - ) { - return true; - } - continue; - } - if (explicitEntry.field === "e164") { - if ( - matchesProviderFormattedTokens({ - cfg: params.cfg, - provider: params.provider, - accountId: params.ctx.AccountId, - value: explicitEntry.value, - tokens: senderE164Tokens, - }) - ) { - return true; - } - continue; - } - if (explicitEntry.field === "name") { - if (matchesMutableTokens(explicitEntry.value, senderNameTokens)) { - return true; - } - continue; - } - if (explicitEntry.field === "username") { - if (matchesMutableTokens(explicitEntry.value, senderUsernameTokens)) { - return true; - } - continue; - } - if ( - explicitEntry.field === "tag" && - matchesMutableTokens(explicitEntry.value, senderTagTokens) - ) { + const matchesExplicitField = explicitFieldMatchers[explicitEntry.field]; + if (matchesExplicitField(explicitEntry.value)) { return true; } } @@ -354,17 +191,20 @@ export function resolveElevatedPermissions(params: { } const normalizedProvider = normalizeChannelId(params.provider); - const dockFallbackAllowFrom = normalizedProvider - ? getChannelDock(normalizedProvider)?.elevated?.allowFromFallback?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }) - : undefined; - const fallbackAllowFrom = dockFallbackAllowFrom; - const globalAllowed = isApprovedElevatedSender({ + const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined; + const fallbackAllowFrom = dock?.elevated?.allowFromFallback?.({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); + const formatAllowFrom = resolveAllowFromFormatter({ cfg: params.cfg, + provider: params.provider, + accountId: params.ctx.AccountId, + }); + const globalAllowed = isApprovedElevatedSender({ provider: params.provider, ctx: params.ctx, + formatAllowFrom, allowFrom: globalConfig?.allowFrom, fallbackAllowFrom, }); @@ -378,9 +218,9 @@ export function resolveElevatedPermissions(params: { const agentAllowed = agentConfig?.allowFrom ? isApprovedElevatedSender({ - cfg: params.cfg, provider: params.provider, ctx: params.ctx, + formatAllowFrom, allowFrom: agentConfig.allowFrom, fallbackAllowFrom, })