fix(matrix): resolve live allowlist updates

This commit is contained in:
Peter Steinberger
2026-04-20 13:09:43 +01:00
parent 9429b0976a
commit 976306641d
5 changed files with 194 additions and 65 deletions

View File

@@ -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`).

View File

@@ -75,6 +75,36 @@ function listResolvedMatrixAllowlistEntries(params: {
return resolvedEntries;
}
function normalizeConfiguredMatrixAllowlistEntries(
entries?: ReadonlyArray<string | number>,
): 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<string>;
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<string | number>;
startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
runtime: RuntimeEnv;
resolveTargets?: ResolveMatrixTargetsFn;
}): Promise<string[]> {
const liveEntries = normalizeConfiguredMatrixAllowlistEntries(params.entries);
if (liveEntries.length === 0) {
return [];
}
const effective: string[] = [];
const seen = new Set<string>();
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;

View File

@@ -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,
});

View File

@@ -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<string | number> }) => {
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 = {

View File

@@ -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<string>;
needsRoomAliasesForConfig: boolean;
resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
};
function normalizeConfiguredMatrixAllowlistEntries(
entries?: ReadonlyArray<string | number>,
): 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<string | number>;
startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
}): string[] {
const liveEntries = normalizeConfiguredMatrixAllowlistEntries(params.liveEntries);
const liveInputs = new Set(liveEntries);
const effective: string[] = [];
const seen = new Set<string>();
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<string | number>;
startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
cache: LiveAllowlistCacheEntry | null;
updateCache: (next: LiveAllowlistCacheEntry) => void;
}): Promise<string[]> => {
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<string, number>();
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,