refactor(security): split elevated allowFrom matcher internals

This commit is contained in:
Peter Steinberger
2026-02-22 22:19:07 +01:00
parent 2081b3a3c4
commit 33a43a151d
2 changed files with 214 additions and 232 deletions

View File

@@ -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<ExplicitElevatedAllowField>([
"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<string>, 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<string>;
}): 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<string>;
}): boolean {
const probeTokens = new Set<string>();
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<string> {
const tokens = new Set<string>();
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<string>): boolean {
if (!value || tokens.size === 0) {
return false;
}
const probes = new Set<string>();
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;
}

View File

@@ -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<ExplicitElevatedAllowField>([
"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<string>, 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<string>;
}) {
}): 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<string>;
}): boolean {
const probeTokens = new Set<string>();
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<string> {
const tokens = new Set<string>();
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<string>): boolean {
if (!value || tokens.size === 0) {
return false;
}
const probes = new Set<string>();
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<string | number>;
}): boolean {
@@ -200,28 +77,22 @@ function isApprovedElevatedSender(params: {
const senderE164Tokens = new Set<string>();
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<ExplicitElevatedAllowField, (value: string) => 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,
})