fix(matrix): gate name-based allowlist resolution [AI] (#79007)

* fix: gate matrix mutable name resolution

* addressing codex review

* addressing codex review

* addressing codex review

* address feedback

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* chore: drop unrelated matrix fix churn

* chore: refresh channel config metadata

* chore: remove unrelated formatting churn

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-11 10:47:27 +05:30
committed by GitHub
parent c17b0e50e4
commit 93ff72a5e8
14 changed files with 498 additions and 39 deletions
+1
View File
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(matrix): gate name-based allowlist resolution [AI]. (#79007) Thanks @pgondhi987.
- Slack: include the bot's own root/parent message in new thread sessions so in-thread replies reach the agent with the parent text the user is responding to, instead of only `reply_to_id` metadata. Fixes #79338. Thanks @sxxtony.
- Memory: skip managed dreaming cron reconciliation warnings for ordinary cron and heartbeat hook contexts that cannot manage Gateway cron. (#77027) Thanks @rubencu.
- Cron: treat Codex app-server turn acceptance, CLI process spawn, and tool starts as execution milestones, preventing isolated runs from tripping the early startup watchdog after work has begun.
+9 -5
View File
@@ -110,8 +110,9 @@ To accept every invite, use `autoJoin: "always"`.
DM and room allowlists are best populated with stable IDs:
- DMs (`dm.allowFrom`, `groupAllowFrom`, `groups.<room>.users`): use `@user:server`. Display names only resolve when the homeserver directory returns exactly one match.
- Rooms (`groups`, `autoJoinAllowlist`): use `!room:server` or `#alias:server`. Names are resolved best-effort against joined rooms; unresolved entries are ignored at runtime.
- DMs (`dm.allowFrom`, `groupAllowFrom`, `groups.<room>.users`): use `@user:server`. Display names are ignored by default because they are mutable; set `dangerouslyAllowNameMatching: true` only when you explicitly need compatibility with display-name entries.
- Room allowlist keys (`groups`, legacy `rooms`): use `!room:server` or `#alias:server`. Plain room names are ignored by default; set `dangerouslyAllowNameMatching: true` only when you explicitly need compatibility with joined-room name lookup.
- Invite allowlists (`autoJoinAllowlist`): use `!room:server`, `#alias:server`, or `*`. Plain room names are rejected.
### Account ID normalization
@@ -823,12 +824,14 @@ keys are not a reliable source for Matrix delivery IDs.
Live directory lookup uses the logged-in Matrix account:
- User lookups query the Matrix user directory on that homeserver.
- Room lookups accept explicit room IDs and aliases directly, then fall back to searching joined room names for that account.
- Joined-room name lookup is best-effort. If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
- Room lookups accept explicit room IDs and aliases directly. Joined-room name lookup is best-effort and only applies to runtime room allowlists when `dangerouslyAllowNameMatching: true` is set.
- If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
## Configuration reference
Allowlist-style fields (`groupAllowFrom`, `dm.allowFrom`, `groups.<room>.users`) accept full Matrix user IDs (safest). Exact directory matches are resolved at startup and whenever the allowlist changes while the monitor is running; entries that cannot be resolved are ignored at runtime. Room allowlists prefer room IDs or aliases for the same reason.
Allowlist-style user fields (`groupAllowFrom`, `dm.allowFrom`, `groups.<room>.users`) accept full Matrix user IDs (safest). Non-ID user entries are ignored by default. If you set `dangerouslyAllowNameMatching: true`, exact Matrix directory display-name matches are resolved at startup and whenever the allowlist changes while the monitor is running; entries that cannot be resolved are ignored at runtime.
Room allowlist keys (`groups`, legacy `rooms`) should be room IDs or aliases. Plain room-name keys are ignored by default; `dangerouslyAllowNameMatching: true` restores best-effort lookup against joined room names.
### Account and connection
@@ -864,6 +867,7 @@ Allowlist-style fields (`groupAllowFrom`, `dm.allowFrom`, `groups.<room>.users`)
- `dm.threadReplies`: DM-only override for reply threading (`"off"`, `"inbound"`, `"always"`).
- `allowBots`: accept messages from other configured Matrix bot accounts (`true` or `"mentions"`).
- `allowlistOnly`: when `true`, forces all active DM policies (except `"disabled"`) and `"open"` group policies to `"allowlist"`. Does not change `"disabled"` policies.
- `dangerouslyAllowNameMatching`: when `true`, allows Matrix display-name directory lookup for user allowlist entries and joined-room name lookup for room allowlist keys. Prefer full `@user:server` IDs and room IDs or aliases.
- `autoJoin`: `"always"`, `"allowlist"`, or `"off"`. Default: `"off"`. Applies to every Matrix invite, including DM-style invites.
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `"allowlist"`. Alias entries are resolved against the homeserver, not against state claimed by the invited room.
- `contextVisibility`: supplemental context visibility (`"all"` default, `"allowlist"`, `"allowlist_quote"`).
@@ -43,6 +43,15 @@ describe("MatrixConfigSchema SecretInput", () => {
expect(result.success).toBe(true);
});
it("accepts the Matrix name matching compatibility flag", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
dangerouslyAllowNameMatching: true,
});
expect(result.success).toBe(true);
});
it("accepts room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
+1
View File
@@ -105,6 +105,7 @@ export const MatrixConfigSchema = z.object({
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
groupPolicy: GroupPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
+4
View File
@@ -1,6 +1,10 @@
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const matrixChannelConfigUiHints = {
dangerouslyAllowNameMatching: {
label: "Matrix Display Name Matching",
help: "Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable.",
},
"streaming.progress.label": {
label: "Matrix Progress Label",
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
@@ -81,6 +81,54 @@ describe("resolveMatrixMonitorAccessState", () => {
});
});
it("treats unresolved configured room allowlists as configured but nonmatching", async () => {
const groupState = await resolveMatrixMonitorAccessState({
allowFrom: [],
storeAllowFrom: [],
groupAllowFrom: ["Alice"],
roomUsers: [],
senderId: "@alice:example.org",
isRoom: true,
groupPolicy: "allowlist",
});
const roomState = await resolveMatrixMonitorAccessState({
allowFrom: [],
storeAllowFrom: [],
groupAllowFrom: [],
roomUsers: ["Dana"],
senderId: "@dana:example.org",
isRoom: true,
groupPolicy: "open",
});
expect(groupState.effectiveGroupAllowFrom).toEqual(["alice"]);
expect(groupState.messageIngress.ingress.decision).toBe("block");
expect(groupState.messageIngress.ingress.reasonCode).toBe("group_policy_not_allowlisted");
expect(roomState.effectiveRoomUsers).toEqual(["dana"]);
expect(roomState.messageIngress.ingress.decision).toBe("block");
expect(roomState.messageIngress.ingress.reasonCode).toBe("group_policy_not_allowlisted");
await expect(
resolveMatrixMonitorCommandAccess(groupState, {
useAccessGroups: true,
allowTextCommands: true,
hasControlCommand: true,
}),
).resolves.toMatchObject({
authorized: false,
shouldBlockControlCommand: true,
});
await expect(
resolveMatrixMonitorCommandAccess(roomState, {
useAccessGroups: true,
allowTextCommands: true,
hasControlCommand: true,
}),
).resolves.toMatchObject({
authorized: false,
shouldBlockControlCommand: true,
});
});
it("authorizes room control commands through the shared ingress command gate", async () => {
const state = await resolveMatrixMonitorAccessState({
allowFrom: [],
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../runtime-api.js";
import type { CoreConfig, MatrixRoomConfig } from "../../types.js";
import { resolveMatrixMonitorConfig } from "./config.js";
import { resolveMatrixMonitorConfig, resolveMatrixMonitorLiveUserAllowlist } from "./config.js";
type MatrixRoomsConfig = Record<string, MatrixRoomConfig>;
@@ -14,6 +14,16 @@ function createRuntime() {
return runtime;
}
function createConfig(params?: { dangerouslyAllowNameMatching?: boolean }): CoreConfig {
return {
channels: {
matrix: {
dangerouslyAllowNameMatching: params?.dangerouslyAllowNameMatching,
},
},
} as CoreConfig;
}
describe("resolveMatrixMonitorConfig", () => {
it("canonicalizes resolved user aliases and room keys without keeping stale aliases", async () => {
const runtime = createRuntime();
@@ -50,7 +60,7 @@ describe("resolveMatrixMonitorConfig", () => {
};
const result = await resolveMatrixMonitorConfig({
cfg: {} as CoreConfig,
cfg: createConfig({ dangerouslyAllowNameMatching: true }),
accountId: "ops",
allowFrom: ["matrix:@Alice:Example.org", "Bob"],
groupAllowFrom: ["user:@Carol:Example.org"],
@@ -110,7 +120,7 @@ describe("resolveMatrixMonitorConfig", () => {
);
const result = await resolveMatrixMonitorConfig({
cfg: {} as CoreConfig,
cfg: createConfig({ dangerouslyAllowNameMatching: true }),
accountId: "ops",
allowFrom: ["user:Ghost"],
groupAllowFrom: ["matrix:@known:example.org"],
@@ -146,7 +156,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(resolveTargets).toHaveBeenCalledTimes(2);
expect(runtime.log).toHaveBeenCalledWith("matrix dm allowlist unresolved: user:Ghost");
expect(runtime.log).toHaveBeenCalledWith(
"matrix dm allowlist entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.",
"matrix dm allowlist entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender.",
);
expect(runtime.log).toHaveBeenCalledWith("matrix rooms unresolved: channel:Project X");
expect(runtime.log).toHaveBeenCalledWith(
@@ -170,7 +180,7 @@ describe("resolveMatrixMonitorConfig", () => {
);
const result = await resolveMatrixMonitorConfig({
cfg: {} as CoreConfig,
cfg: createConfig({ dangerouslyAllowNameMatching: true }),
accountId: "ops",
roomsConfig: {
"#allowed:example.org": {
@@ -194,4 +204,134 @@ describe("resolveMatrixMonitorConfig", () => {
}),
);
});
it("does not resolve mutable allowlist entries or room names by default", async () => {
const runtime = createRuntime();
const resolveTargets = vi.fn(
async ({ kind, inputs }: { inputs: string[]; kind: "user" | "group" }) => {
if (kind === "group") {
return inputs.map((input) =>
input === "#ops:example.org"
? { input, resolved: true, id: "!ops:example.org" }
: { input, resolved: false },
);
}
return inputs.map((input) => ({ input, resolved: true, id: `@${input}:example.org` }));
},
);
const result = await resolveMatrixMonitorConfig({
cfg: createConfig(),
accountId: "ops",
allowFrom: ["Alice", "matrix:@Bob:Example.org"],
groupAllowFrom: ["Carol"],
roomsConfig: {
General: {
enabled: true,
users: ["Dana"],
},
"#ops:example.org": {
enabled: true,
users: ["user:@Erin:Example.org", "Frank"],
},
},
runtime,
resolveTargets,
});
expect(result.allowFrom).toEqual(["@bob:example.org"]);
expect(result.allowFromResolvedEntries).toEqual([
{ input: "matrix:@Bob:Example.org", id: "@bob:example.org" },
]);
expect(result.groupAllowFrom).toEqual(["Carol"]);
expect(result.roomsConfig).toEqual({
"!ops:example.org": {
enabled: true,
users: ["@erin:example.org", "Frank"],
},
});
expect(resolveTargets).toHaveBeenCalledTimes(1);
expect(resolveTargets).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
kind: "group",
inputs: ["#ops:example.org"],
}),
);
expect(runtime.log).toHaveBeenCalledWith("matrix dm allowlist unresolved: Alice");
expect(runtime.log).toHaveBeenCalledWith(
"matrix dm allowlist entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender. To match Matrix display names, set channels.matrix.dangerouslyAllowNameMatching=true.",
);
expect(runtime.log).toHaveBeenCalledWith("matrix group allowlist unresolved: Carol");
expect(runtime.log).toHaveBeenCalledWith(
"matrix group allowlist entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender. To match Matrix display names, set channels.matrix.dangerouslyAllowNameMatching=true.",
);
expect(runtime.log).toHaveBeenCalledWith("matrix room users unresolved: Frank");
expect(runtime.log).toHaveBeenCalledWith(
"matrix room users entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender. To match Matrix display names, set channels.matrix.dangerouslyAllowNameMatching=true.",
);
});
it("does not resolve mutable live allowlist entries by default", async () => {
const runtime = createRuntime();
const resolveTargets = vi.fn(async () => [
{ input: "Alice", resolved: true, id: "@alice:example.org" },
]);
const result = await resolveMatrixMonitorLiveUserAllowlist({
cfg: createConfig(),
accountId: "ops",
entries: ["Alice", "matrix:@Bob:Example.org", "*"],
startupResolvedEntries: [{ input: "Alice", id: "@startup-alice:example.org" }],
runtime,
resolveTargets,
});
expect(result).toEqual(["@bob:example.org", "*"]);
expect(resolveTargets).not.toHaveBeenCalled();
});
it("keeps unresolved live group allowlist entries configured for fail-closed matching", async () => {
const runtime = createRuntime();
const resolveTargets = vi.fn(async () => [
{ input: "Alice", resolved: true, id: "@alice:example.org" },
]);
const result = await resolveMatrixMonitorLiveUserAllowlist({
cfg: createConfig(),
accountId: "ops",
entries: ["Alice", "matrix:@Bob:Example.org"],
failClosedOnUnresolved: true,
startupResolvedEntries: [{ input: "Alice", id: "@startup-alice:example.org" }],
runtime,
resolveTargets,
});
expect(result).toEqual(["Alice", "@bob:example.org"]);
expect(resolveTargets).not.toHaveBeenCalled();
});
it("resolves mutable live allowlist entries when name matching is enabled", async () => {
const runtime = createRuntime();
const resolveTargets = vi.fn(async () => [
{ input: "Alice", resolved: true, id: "@alice:example.org" },
]);
const result = await resolveMatrixMonitorLiveUserAllowlist({
cfg: createConfig({ dangerouslyAllowNameMatching: true }),
accountId: "ops",
entries: ["Alice", "matrix:@Bob:Example.org", "*"],
runtime,
resolveTargets,
});
expect(result).toEqual(["@bob:example.org", "*", "@alice:example.org"]);
expect(resolveTargets).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
kind: "user",
inputs: ["Alice"],
}),
);
});
});
+145 -17
View File
@@ -1,5 +1,7 @@
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import type { CoreConfig, MatrixRoomConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../account-config.js";
import { isMatrixQualifiedUserId } from "../target-ids.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import {
@@ -51,6 +53,10 @@ function filterResolvedMatrixAllowlistEntries(entries: string[]): string[] {
});
}
function filterFailClosedMatrixAllowlistEntries(entries: string[]): string[] {
return entries.filter((entry) => entry.trim().length > 0);
}
function listResolvedMatrixAllowlistEntries(params: {
entries: Array<string | number>;
resolvedMap: Map<string, { resolved: boolean; id?: string }>;
@@ -88,6 +94,18 @@ function normalizeConfiguredMatrixAllowlistEntries(
return normalized;
}
function isMatrixDangerousNameMatchingEnabled(params: {
cfg: CoreConfig;
accountId?: string | null;
}): boolean {
return isDangerousNameMatchingEnabled(
resolveMatrixAccountConfig({
cfg: params.cfg,
accountId: params.accountId,
}),
);
}
function addUniqueMatrixAllowlistEntry(params: {
entries: string[];
seen: Set<string>;
@@ -105,19 +123,76 @@ function addUniqueMatrixAllowlistEntry(params: {
params.entries.push(trimmed);
}
function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoomsConfig {
const nextEntries: MatrixRoomsConfig = { ...entries };
for (const [roomKey, roomConfig] of Object.entries(entries)) {
const users = roomConfig?.users;
if (!Array.isArray(users)) {
function resolveStableMatrixMonitorUserEntries(entries: Array<string | number>) {
const directMatches: Array<{ input: string; resolved: boolean; id?: string }> = [];
for (const entry of entries) {
const input = String(entry).trim();
if (!input) {
continue;
}
nextEntries[roomKey] = {
...roomConfig,
users: filterResolvedMatrixAllowlistEntries(users.map(String)),
};
const query = normalizeMatrixUserLookupEntry(input);
if (!query || query === "*") {
continue;
}
directMatches.push(
isMatrixQualifiedUserId(query)
? {
input,
resolved: true,
id: normalizeMatrixUserId(query),
}
: {
input,
resolved: false,
},
);
}
return nextEntries;
return buildAllowlistResolutionSummary(directMatches);
}
function logStableMatrixAllowlistUnresolved(params: {
label: string;
unresolved: string[];
runtime: RuntimeEnv;
}): void {
if (params.unresolved.length === 0) {
return;
}
summarizeMapping(params.label, [], params.unresolved, params.runtime);
params.runtime.log?.(
`${params.label} entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender. To match Matrix display names, set channels.matrix.dangerouslyAllowNameMatching=true.`,
);
}
function resolveStableMatrixMonitorUserAllowlist(params: {
allowList: string[];
failClosedOnUnresolved?: boolean;
label: string;
runtime: RuntimeEnv;
}): MatrixResolvedUserAllowlist {
const allowList = params.allowList;
const resolution = resolveStableMatrixMonitorUserEntries(allowList);
const canonicalized = canonicalizeAllowlistWithResolvedIds({
existing: allowList,
resolvedMap: resolution.resolvedMap,
});
logStableMatrixAllowlistUnresolved({
label: params.label,
unresolved: resolution.unresolved,
runtime: params.runtime,
});
return {
entries: params.failClosedOnUnresolved
? filterFailClosedMatrixAllowlistEntries(canonicalized)
: filterResolvedMatrixAllowlistEntries(canonicalized),
resolvedEntries: listResolvedMatrixAllowlistEntries({
entries: allowList,
resolvedMap: resolution.resolvedMap,
}),
};
}
async function resolveMatrixMonitorUserEntries(params: {
@@ -181,6 +256,7 @@ async function resolveMatrixMonitorUserAllowlist(params: {
accountId?: string | null;
label: string;
list?: Array<string | number>;
failClosedOnUnresolved?: boolean;
runtime: RuntimeEnv;
resolveTargets: ResolveMatrixTargetsFn;
}): Promise<MatrixResolvedUserAllowlist> {
@@ -188,6 +264,19 @@ async function resolveMatrixMonitorUserAllowlist(params: {
if (allowList.length === 0) {
return { entries: allowList, resolvedEntries: [] };
}
if (
!isMatrixDangerousNameMatchingEnabled({
cfg: params.cfg,
accountId: params.accountId,
})
) {
return resolveStableMatrixMonitorUserAllowlist({
allowList,
failClosedOnUnresolved: params.failClosedOnUnresolved,
label: params.label,
runtime: params.runtime,
});
}
const resolution = await resolveMatrixMonitorUserEntries({
cfg: params.cfg,
@@ -204,12 +293,14 @@ async function resolveMatrixMonitorUserAllowlist(params: {
summarizeMapping(params.label, resolution.mapping, resolution.unresolved, params.runtime);
if (resolution.unresolved.length > 0) {
params.runtime.log?.(
`${params.label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
`${params.label} entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender.`,
);
}
return {
entries: filterResolvedMatrixAllowlistEntries(canonicalized),
entries: params.failClosedOnUnresolved
? filterFailClosedMatrixAllowlistEntries(canonicalized)
: filterResolvedMatrixAllowlistEntries(canonicalized),
resolvedEntries: listResolvedMatrixAllowlistEntries({
entries: allowList,
resolvedMap: resolution.resolvedMap,
@@ -221,6 +312,7 @@ export async function resolveMatrixMonitorLiveUserAllowlist(params: {
cfg: CoreConfig;
accountId?: string | null;
entries?: ReadonlyArray<string | number>;
failClosedOnUnresolved?: boolean;
startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
runtime: RuntimeEnv;
resolveTargets?: ResolveMatrixTargetsFn;
@@ -230,6 +322,10 @@ export async function resolveMatrixMonitorLiveUserAllowlist(params: {
return [];
}
const allowNameMatching = isMatrixDangerousNameMatchingEnabled({
cfg: params.cfg,
accountId: params.accountId,
});
const effective: string[] = [];
const seen = new Set<string>();
const startupByInput = new Map(
@@ -252,11 +348,17 @@ export async function resolveMatrixMonitorLiveUserAllowlist(params: {
continue;
}
const startupId = startupByInput.get(entry);
if (startupId) {
if (allowNameMatching && startupId) {
addUniqueMatrixAllowlistEntry({ entries: effective, seen, entry: startupId });
continue;
}
pending.push(entry);
if (allowNameMatching) {
pending.push(entry);
continue;
}
if (params.failClosedOnUnresolved) {
addUniqueMatrixAllowlistEntry({ entries: effective, seen, entry });
}
}
if (pending.length === 0) {
@@ -274,7 +376,10 @@ export async function resolveMatrixMonitorLiveUserAllowlist(params: {
existing: pending,
resolvedMap: resolution.resolvedMap,
});
for (const entry of filterResolvedMatrixAllowlistEntries(canonicalized)) {
const resolvedEntries = params.failClosedOnUnresolved
? filterFailClosedMatrixAllowlistEntries(canonicalized)
: filterResolvedMatrixAllowlistEntries(canonicalized);
for (const entry of resolvedEntries) {
addUniqueMatrixAllowlistEntry({ entries: effective, seen, entry });
}
@@ -296,6 +401,10 @@ async function resolveMatrixMonitorRoomsConfig(params: {
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms: MatrixRoomsConfig = {};
const allowNameMatching = isMatrixDangerousNameMatchingEnabled({
cfg: params.cfg,
accountId: params.accountId,
});
if (roomsConfig["*"]) {
nextRooms["*"] = roomsConfig["*"];
}
@@ -323,6 +432,10 @@ async function resolveMatrixMonitorRoomsConfig(params: {
}
continue;
}
if (!cleaned.startsWith("#") && !allowNameMatching) {
unresolved.push(input);
continue;
}
pending.push({ input, query: cleaned, config: roomConfig });
}
@@ -365,6 +478,20 @@ async function resolveMatrixMonitorRoomsConfig(params: {
if (roomUsers.size === 0) {
return nextRooms;
}
if (!allowNameMatching) {
const resolution = resolveStableMatrixMonitorUserEntries(Array.from(roomUsers));
logStableMatrixAllowlistUnresolved({
label: "matrix room users",
unresolved: resolution.unresolved,
runtime: params.runtime,
});
const patched = patchAllowlistUsersInConfigEntries({
entries: nextRooms,
resolvedMap: resolution.resolvedMap,
strategy: "canonicalize",
});
return patched;
}
const resolution = await resolveMatrixMonitorUserEntries({
cfg: params.cfg,
@@ -376,7 +503,7 @@ async function resolveMatrixMonitorRoomsConfig(params: {
summarizeMapping("matrix room users", resolution.mapping, resolution.unresolved, params.runtime);
if (resolution.unresolved.length > 0) {
params.runtime.log?.(
"matrix room users entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.",
"matrix room users entries must be full Matrix IDs (example: @user:server). Unresolved entries will not match any sender.",
);
}
@@ -385,7 +512,7 @@ async function resolveMatrixMonitorRoomsConfig(params: {
resolvedMap: resolution.resolvedMap,
strategy: "canonicalize",
});
return sanitizeMatrixRoomUserAllowlists(patched);
return patched;
}
export async function resolveMatrixMonitorConfig(params: {
@@ -419,6 +546,7 @@ export async function resolveMatrixMonitorConfig(params: {
accountId: params.accountId,
label: "matrix group allowlist",
list: params.groupAllowFrom,
failClosedOnUnresolved: true,
runtime: params.runtime,
resolveTargets,
}),
@@ -1998,6 +1998,23 @@ describe("matrix monitor handler live allowlist reload", () => {
);
};
const isLiveNameMatchingEnabled = (cfg: unknown): boolean => {
const matrix = (cfg as { channels?: { matrix?: { dangerouslyAllowNameMatching?: boolean } } })
.channels?.matrix;
return matrix?.dangerouslyAllowNameMatching === true;
};
type LiveNameMatchingResolveParams = {
cfg: unknown;
entries?: ReadonlyArray<string | number>;
};
const countLiveAllowlistCallsForEntries = (
calls: Array<[LiveNameMatchingResolveParams]>,
entries: string[],
): number =>
calls.filter(
([params]) => JSON.stringify((params.entries ?? []).map(String)) === JSON.stringify(entries),
).length;
it("accepts a DM sender added to live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
@@ -2149,6 +2166,7 @@ describe("matrix monitor handler live allowlist reload", () => {
const cfg = {
channels: {
matrix: {
dangerouslyAllowNameMatching: true,
dm: { allowFrom: ["Alice"] },
},
},
@@ -2190,6 +2208,7 @@ describe("matrix monitor handler live allowlist reload", () => {
const cfg = {
channels: {
matrix: {
dangerouslyAllowNameMatching: true,
dm: { allowFrom: [] as string[] },
},
},
@@ -2227,6 +2246,92 @@ describe("matrix monitor handler live allowlist reload", () => {
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("refreshes cached live display-name allowlists when name matching is disabled", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
);
const cfg = {
channels: {
matrix: {
dangerouslyAllowNameMatching: true,
dm: { allowFrom: ["Alice"] },
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
resolveLiveUserAllowlist,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-live-name-disable-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dangerouslyAllowNameMatching = false;
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-live-name-disable-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
2,
);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("refreshes cached live display-name allowlists when name matching is enabled", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
);
const cfg = {
channels: {
matrix: {
dangerouslyAllowNameMatching: false,
dm: { allowFrom: ["Alice"] },
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
resolveLiveUserAllowlist,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-live-name-enable-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
cfg.channels.matrix.dangerouslyAllowNameMatching = true;
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-live-name-enable-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
2,
);
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 = {
@@ -16,6 +16,7 @@ import {
evaluateSupplementalContextVisibility,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/context-visibility-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
@@ -31,7 +32,10 @@ import type {
MatrixStreamingMode,
ReplyToMode,
} from "../../types.js";
import { resolveMatrixAccountAllowlistConfig } from "../account-config.js";
import {
resolveMatrixAccountAllowlistConfig,
resolveMatrixAccountConfig,
} from "../account-config.js";
import { formatMatrixErrorMessage } from "../errors.js";
import { isMatrixMediaSizeLimitError } from "../media-errors.js";
import {
@@ -437,11 +441,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const resolveCachedLiveAllowlist = async (params: {
cfg: CoreConfig;
entries?: ReadonlyArray<string | number>;
failClosedOnUnresolved?: boolean;
startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
cache: LiveAllowlistCacheEntry | null;
updateCache: (next: LiveAllowlistCacheEntry) => void;
}): Promise<string[]> => {
const signature = JSON.stringify((params.entries ?? []).map((entry) => String(entry).trim()));
const accountConfig = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const signature = JSON.stringify({
entries: (params.entries ?? []).map((entry) => String(entry).trim()),
failClosedOnUnresolved: params.failClosedOnUnresolved === true,
dangerouslyAllowNameMatching: isDangerousNameMatchingEnabled(accountConfig),
});
if (params.cache?.signature === signature) {
return params.cache.entries;
}
@@ -449,6 +459,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
cfg: params.cfg,
accountId,
entries: params.entries,
failClosedOnUnresolved: params.failClosedOnUnresolved,
startupResolvedEntries: params.startupResolvedEntries,
runtime,
});
@@ -725,6 +736,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const liveGroupAllowFrom = await resolveCachedLiveAllowlist({
cfg: liveCfg,
entries: liveAccountAllowlists.groupAllowFrom,
failClosedOnUnresolved: true,
startupResolvedEntries: groupAllowFromResolvedEntries,
cache: liveGroupAllowlistCache,
updateCache: (next) => {
+1
View File
@@ -6,6 +6,7 @@ export const matrixSingleAccountKeysToMove = [
"initialSyncLimit",
"encryption",
"allowlistOnly",
"dangerouslyAllowNameMatching",
"allowBots",
"blockStreaming",
"replyToMode",
+4
View File
@@ -187,6 +187,7 @@ describe("matrixSetupAdapter", () => {
userId: "@default:example.org",
accessToken: "default-token",
deviceName: "Default device",
dangerouslyAllowNameMatching: true,
},
},
} as CoreConfig;
@@ -205,11 +206,13 @@ describe("matrixSetupAdapter", () => {
expect(next.channels?.matrix?.homeserver).toBeUndefined();
expect(next.channels?.matrix?.userId).toBeUndefined();
expect(next.channels?.matrix?.accessToken).toBeUndefined();
expect(next.channels?.matrix?.dangerouslyAllowNameMatching).toBeUndefined();
expectFields(next.channels?.matrix?.accounts?.default, {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accessToken: "default-token",
deviceName: "Default device",
dangerouslyAllowNameMatching: true,
});
expectFields(next.channels?.matrix?.accounts?.ops, {
name: "Ops",
@@ -218,6 +221,7 @@ describe("matrixSetupAdapter", () => {
userId: "@ops:example.org",
accessToken: "ops-token",
});
expect(next.channels?.matrix?.accounts?.ops?.dangerouslyAllowNameMatching).toBeUndefined();
});
it("reuses an existing raw default-account key during promotion", () => {
+2
View File
@@ -139,6 +139,8 @@ export type MatrixConfig = {
encryption?: boolean;
/** If true, enforce allowlists for groups + DMs regardless of policy. */
allowlistOnly?: boolean;
/** Break-glass compatibility mode for resolving mutable Matrix display names and room names in allowlists. */
dangerouslyAllowNameMatching?: boolean;
/**
* Allow messages from other configured Matrix bot accounts.
* true accepts all configured bot senders; "mentions" requires they mention this bot.
File diff suppressed because one or more lines are too long