From 976306641dc413eb838a338b2ac3d614833cb202 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 13:09:43 +0100 Subject: [PATCH] fix(matrix): resolve live allowlist updates --- docs/channels/matrix.md | 4 +- .../matrix/src/matrix/monitor/config.ts | 94 +++++++++++++++ .../matrix/monitor/handler.test-helpers.ts | 2 + .../matrix/src/matrix/monitor/handler.test.ts | 48 ++++++++ .../matrix/src/matrix/monitor/handler.ts | 111 ++++++++---------- 5 files changed, 194 insertions(+), 65 deletions(-) diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index f540eced2ab..27b1c09f04b 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -1014,7 +1014,7 @@ Live directory lookup uses the logged-in Matrix account: - `allowBots`: allow messages from other configured OpenClaw Matrix accounts (`true` or `"mentions"`). - `groupPolicy`: `open`, `allowlist`, or `disabled`. - `contextVisibility`: supplemental room-context visibility mode (`all`, `allowlist`, `allowlist_quote`). -- `groupAllowFrom`: allowlist of user IDs for room traffic. Entries should be full Matrix user IDs; unresolved names are ignored at runtime. +- `groupAllowFrom`: allowlist of user IDs for room traffic. Full Matrix user IDs are safest; exact directory matches are resolved at startup and when the allowlist changes while the monitor is running. Unresolved names are ignored. - `historyLimit`: max room messages to include as group history context. Falls back to `messages.groupChat.historyLimit`; if both are unset, the effective default is `0`. Set `0` to disable. - `replyToMode`: `off`, `first`, `all`, or `batched`. - `markdown`: optional Markdown rendering configuration for outbound Matrix text. @@ -1035,7 +1035,7 @@ Live directory lookup uses the logged-in Matrix account: - `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`. Alias entries are resolved to room IDs during invite handling; OpenClaw does not trust alias state claimed by the invited room. - `dm`: DM policy block (`enabled`, `policy`, `allowFrom`, `sessionScope`, `threadReplies`). - `dm.policy`: controls DM access after OpenClaw has joined the room and classified it as a DM. It does not change whether an invite is auto-joined. -- `dm.allowFrom`: entries should be full Matrix user IDs unless you already resolved them through live directory lookup. +- `dm.allowFrom`: allowlist of user IDs for DM traffic. Full Matrix user IDs are safest; exact directory matches are resolved at startup and when the allowlist changes while the monitor is running. Unresolved names are ignored. - `dm.sessionScope`: `per-user` (default) or `per-room`. Use `per-room` when you want each Matrix DM room to keep separate context even if the peer is the same. - `dm.threadReplies`: DM-only thread policy override (`off`, `inbound`, `always`). It overrides the top-level `threadReplies` setting for both reply placement and session isolation in DMs. - `execApprovals`: Matrix-native exec approval delivery (`enabled`, `approvers`, `target`, `agentFilter`, `sessionFilter`). diff --git a/extensions/matrix/src/matrix/monitor/config.ts b/extensions/matrix/src/matrix/monitor/config.ts index c2bade49d7b..d3e3d5226ef 100644 --- a/extensions/matrix/src/matrix/monitor/config.ts +++ b/extensions/matrix/src/matrix/monitor/config.ts @@ -75,6 +75,36 @@ function listResolvedMatrixAllowlistEntries(params: { return resolvedEntries; } +function normalizeConfiguredMatrixAllowlistEntries( + entries?: ReadonlyArray, +): string[] { + const normalized: string[] = []; + for (const entry of entries ?? []) { + const trimmed = String(entry).trim(); + if (trimmed) { + normalized.push(trimmed); + } + } + return normalized; +} + +function addUniqueMatrixAllowlistEntry(params: { + entries: string[]; + seen: Set; + entry: string; +}): void { + const trimmed = params.entry.trim(); + if (!trimmed) { + return; + } + const key = trimmed.toLowerCase(); + if (params.seen.has(key)) { + return; + } + params.seen.add(key); + params.entries.push(trimmed); +} + function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoomsConfig { const nextEntries: MatrixRoomsConfig = { ...entries }; for (const [roomKey, roomConfig] of Object.entries(entries)) { @@ -187,6 +217,70 @@ async function resolveMatrixMonitorUserAllowlist(params: { }; } +export async function resolveMatrixMonitorLiveUserAllowlist(params: { + cfg: CoreConfig; + accountId?: string | null; + entries?: ReadonlyArray; + startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[]; + runtime: RuntimeEnv; + resolveTargets?: ResolveMatrixTargetsFn; +}): Promise { + const liveEntries = normalizeConfiguredMatrixAllowlistEntries(params.entries); + if (liveEntries.length === 0) { + return []; + } + + const effective: string[] = []; + const seen = new Set(); + const startupByInput = new Map( + (params.startupResolvedEntries ?? []).map((entry) => [entry.input, entry.id] as const), + ); + const pending: string[] = []; + + for (const entry of liveEntries) { + const query = normalizeMatrixUserLookupEntry(entry); + if (entry === "*") { + addUniqueMatrixAllowlistEntry({ entries: effective, seen, entry }); + continue; + } + if (isMatrixQualifiedUserId(query)) { + addUniqueMatrixAllowlistEntry({ + entries: effective, + seen, + entry: normalizeMatrixUserId(query), + }); + continue; + } + const startupId = startupByInput.get(entry); + if (startupId) { + addUniqueMatrixAllowlistEntry({ entries: effective, seen, entry: startupId }); + continue; + } + pending.push(entry); + } + + if (pending.length === 0) { + return effective; + } + + const resolution = await resolveMatrixMonitorUserEntries({ + cfg: params.cfg, + accountId: params.accountId, + entries: pending, + runtime: params.runtime, + resolveTargets: params.resolveTargets ?? resolveMatrixTargets, + }); + const canonicalized = canonicalizeAllowlistWithResolvedIds({ + existing: pending, + resolvedMap: resolution.resolvedMap, + }); + for (const entry of filterResolvedMatrixAllowlistEntries(canonicalized)) { + addUniqueMatrixAllowlistEntry({ entries: effective, seen, entry }); + } + + return effective; +} + async function resolveMatrixMonitorRoomsConfig(params: { cfg: CoreConfig; accountId?: string | null; diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 1b78636f1e7..5dbcbe2f9c0 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -85,6 +85,7 @@ type MatrixHandlerTestHarnessOptions = { enqueueSystemEvent?: (...args: unknown[]) => void; getRoomInfo?: MatrixMonitorHandlerParams["getRoomInfo"]; getMemberDisplayName?: MatrixMonitorHandlerParams["getMemberDisplayName"]; + resolveLiveUserAllowlist?: MatrixMonitorHandlerParams["resolveLiveUserAllowlist"]; }; type MatrixHandlerTestHarness = { @@ -242,6 +243,7 @@ export function createMatrixHandlerTestHarness( getRoomInfo: options.getRoomInfo ?? (async () => ({ altAliases: [] })), getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"), needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false, + resolveLiveUserAllowlist: options.resolveLiveUserAllowlist, historyLimit: options.historyLimit ?? 0, }); diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 0bd94931a83..d1780acc79c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -1984,6 +1984,54 @@ describe("matrix monitor handler live allowlist reload", () => { expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); }); + it("accepts a DM sender added as a live-resolved display name", async () => { + const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const resolveLiveUserAllowlist = vi.fn( + async (params: { entries?: ReadonlyArray }) => { + const entries = (params.entries ?? []).map(String); + return entries.includes("Alice") ? ["@alice:example.org"] : []; + }, + ); + const cfg = { + channels: { + matrix: { + dm: { allowFrom: [] as string[] }, + }, + }, + }; + const { handler } = createMatrixHandlerTestHarness({ + cfg, + dmPolicy: "allowlist", + isDirectMessage: true, + allowFrom: [], + allowFromResolvedEntries: [], + dispatchReplyFromConfig, + resolveLiveUserAllowlist, + }); + + await sendLiveAllowlistMessage(handler, { + eventId: "$dm-live-name-before", + sender: "@alice:example.org", + body: "hello", + }); + expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + + cfg.channels.matrix.dm.allowFrom = ["Alice"]; + await sendLiveAllowlistMessage(handler, { + eventId: "$dm-live-name-after", + sender: "@alice:example.org", + body: "hello again", + }); + + expect(resolveLiveUserAllowlist).toHaveBeenLastCalledWith( + expect.objectContaining({ + accountId: "ops", + entries: ["Alice"], + }), + ); + expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + }); + it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => { const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const cfg = { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 76745956ccd..b41d378daf5 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -34,11 +34,13 @@ import { import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js"; -import { isMatrixQualifiedUserId } from "../target-ids.js"; import { resolveMatrixMonitorAccessState } from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js"; -import { normalizeMatrixUserId, resolveMatrixAllowListMatch } from "./allowlist.js"; -import type { MatrixResolvedAllowlistEntry } from "./config.js"; +import { resolveMatrixAllowListMatch } from "./allowlist.js"; +import { + resolveMatrixMonitorLiveUserAllowlist, + type MatrixResolvedAllowlistEntry, +} from "./config.js"; import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; @@ -191,63 +193,9 @@ export type MatrixMonitorHandlerParams = { ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; getMemberDisplayName: (roomId: string, userId: string) => Promise; needsRoomAliasesForConfig: boolean; + resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist; }; -function normalizeConfiguredMatrixAllowlistEntries( - entries?: ReadonlyArray, -): string[] { - const normalized: string[] = []; - for (const entry of entries ?? []) { - const trimmed = String(entry).trim(); - if (trimmed) { - normalized.push(trimmed); - } - } - return normalized; -} - -function isMatrixHotReloadAllowlistEntry(entry: string): boolean { - if (entry === "*") { - return true; - } - return isMatrixQualifiedUserId(normalizeMatrixUserId(entry)); -} - -function resolveEffectiveMatrixLiveAllowlist(params: { - liveEntries?: ReadonlyArray; - startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[]; -}): string[] { - const liveEntries = normalizeConfiguredMatrixAllowlistEntries(params.liveEntries); - const liveInputs = new Set(liveEntries); - const effective: string[] = []; - const seen = new Set(); - const add = (entry: string) => { - const trimmed = entry.trim(); - if (!trimmed) { - return; - } - const key = trimmed.toLowerCase(); - if (seen.has(key)) { - return; - } - seen.add(key); - effective.push(trimmed); - }; - - for (const entry of liveEntries) { - if (isMatrixHotReloadAllowlistEntry(entry)) { - add(entry); - } - } - for (const entry of params.startupResolvedEntries ?? []) { - if (liveInputs.has(entry.input)) { - add(entry.id); - } - } - - return effective; -} - function resolveMatrixMentionPrecheckText(params: { eventType: string; content: RoomMessageEventContent; @@ -439,6 +387,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam getRoomInfo, getMemberDisplayName, needsRoomAliasesForConfig, + resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist, } = params; const contextVisibilityMode = resolveChannelContextVisibilityMode({ cfg, @@ -449,6 +398,31 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam value: string[]; expiresAtMs: number; } | null = null; + type LiveAllowlistCacheEntry = { signature: string; entries: string[] }; + let liveDmAllowlistCache: LiveAllowlistCacheEntry | null = null; + let liveGroupAllowlistCache: LiveAllowlistCacheEntry | null = null; + const resolveCachedLiveAllowlist = async (params: { + cfg: CoreConfig; + entries?: ReadonlyArray; + startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[]; + cache: LiveAllowlistCacheEntry | null; + updateCache: (next: LiveAllowlistCacheEntry) => void; + }): Promise => { + const signature = JSON.stringify((params.entries ?? []).map((entry) => String(entry).trim())); + if (params.cache?.signature === signature) { + return params.cache.entries; + } + const entries = await resolveLiveUserAllowlist({ + cfg: params.cfg, + accountId, + entries: params.entries, + startupResolvedEntries: params.startupResolvedEntries, + runtime, + }); + const next = { signature, entries }; + params.updateCache(next); + return entries; + }; const pairingReplySentAtMsBySender = new Map(); const resolveThreadContext = createMatrixThreadContextResolver({ client, @@ -698,17 +672,28 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }; const storeAllowFrom = isDirectMessage ? await readStoreAllowFrom() : []; const roomUsers = roomConfig?.users ?? []; + const liveCfg = core.config.loadConfig() as CoreConfig; const liveAccountAllowlists = resolveMatrixAccountAllowlistConfig({ - cfg: core.config.loadConfig() as CoreConfig, + cfg: liveCfg, accountId, }); - const liveDmAllowFrom = resolveEffectiveMatrixLiveAllowlist({ - liveEntries: liveAccountAllowlists.dmAllowFrom, + const liveDmAllowFrom = await resolveCachedLiveAllowlist({ + cfg: liveCfg, + entries: liveAccountAllowlists.dmAllowFrom, startupResolvedEntries: allowFromResolvedEntries, + cache: liveDmAllowlistCache, + updateCache: (next) => { + liveDmAllowlistCache = next; + }, }); - const liveGroupAllowFrom = resolveEffectiveMatrixLiveAllowlist({ - liveEntries: liveAccountAllowlists.groupAllowFrom, + const liveGroupAllowFrom = await resolveCachedLiveAllowlist({ + cfg: liveCfg, + entries: liveAccountAllowlists.groupAllowFrom, startupResolvedEntries: groupAllowFromResolvedEntries, + cache: liveGroupAllowlistCache, + updateCache: (next) => { + liveGroupAllowlistCache = next; + }, }); const accessState = resolveMatrixMonitorAccessState({ allowFrom: liveDmAllowFrom,