diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c5b0066..62aca46ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security: Matrix allowlists now require full MXIDs; ambiguous name resolution no longer grants access. Thanks @MegaManSec. - Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir. - Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed). - Onboarding: drop completion prompt now handled by install/update. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index c6a818fab..a196a68b6 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -148,12 +148,12 @@ Once verified, the bot can decrypt messages in encrypted rooms. - `openclaw pairing list matrix` - `openclaw pairing approve matrix ` - Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`. -- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available. +- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match. ## Rooms (groups) - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. -- Allowlist rooms with `channels.matrix.groups` (room IDs, aliases, or names): +- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): ```json5 { @@ -172,10 +172,10 @@ Once verified, the bot can decrypt messages in encrypted rooms. - `requireMention: false` enables auto-reply in that room. - `groups."*"` can set defaults for mention gating across rooms. -- `groupAllowFrom` restricts which senders can trigger the bot in rooms (optional). -- Per-room `users` allowlists can further restrict senders inside a specific room. -- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible. -- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. +- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs). +- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs). +- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match. +- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching. - Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`. - To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). - Legacy key: `channels.matrix.rooms` (same shape as `groups`). @@ -220,9 +220,9 @@ Provider options: - `channels.matrix.textChunkLimit`: outbound text chunk size (chars). - `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible. +- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible. - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). -- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages. +- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs). - `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. - `channels.matrix.groups`: group allowlist + per-room settings map. - `channels.matrix.rooms`: legacy group allowlist/config. diff --git a/docs/zh-CN/channels/matrix.md b/docs/zh-CN/channels/matrix.md index da2dffb82..4dcbace56 100644 --- a/docs/zh-CN/channels/matrix.md +++ b/docs/zh-CN/channels/matrix.md @@ -136,12 +136,12 @@ openclaw plugins install ./extensions/matrix - `openclaw pairing list matrix` - `openclaw pairing approve matrix ` - 公开私信:`channels.matrix.dm.policy="open"` 加上 `channels.matrix.dm.allowFrom=["*"]`。 -- `channels.matrix.dm.allowFrom` 接受用户 ID 或显示名称。向导在目录搜索可用时会将显示名称解析为用户 ID。 +- `channels.matrix.dm.allowFrom` 仅接受完整 Matrix 用户 ID(例如 `@user:server`)。向导仅在目录搜索得到唯一精确匹配时解析显示名称为用户 ID。 ## 房间(群组) - 默认:`channels.matrix.groupPolicy = "allowlist"`(提及门控)。使用 `channels.defaults.groupPolicy` 可在未设置时覆盖默认值。 -- 使用 `channels.matrix.groups` 允许列表中的房间(房间 ID、别名或名称): +- 使用 `channels.matrix.groups` 允许列表中的房间(房间 ID/别名;名称仅在目录搜索得到唯一精确匹配时解析为 ID): ```json5 { @@ -160,10 +160,10 @@ openclaw plugins install ./extensions/matrix - `requireMention: false` 启用该房间的自动回复。 - `groups."*"` 可以设置跨房间的提及门控默认值。 -- `groupAllowFrom` 限制哪些发送者可以在房间中触发机器人(可选)。 -- 按房间的 `users` 允许列表可以进一步限制特定房间内的发送者。 -- 配置向导会提示输入房间允许列表(房间 ID、别名或名称)并在可能时解析名称。 -- 启动时,OpenClaw 将允许列表中的房间/用户名称解析为 ID 并记录映射;未解析的条目保持原样。 +- `groupAllowFrom` 限制哪些发送者可以在房间中触发机器人(完整 Matrix 用户 ID)。 +- 按房间的 `users` 允许列表可以进一步限制特定房间内的发送者(使用完整 Matrix 用户 ID)。 +- 配置向导会提示输入房间允许列表(房间 ID、别名或名称),仅在精确且唯一匹配时解析名称。 +- 启动时,OpenClaw 将允许列表中的房间/用户名称解析为 ID 并记录映射;未解析的条目不会参与允许列表匹配。 - 邀请默认自动加入;通过 `channels.matrix.autoJoin` 和 `channels.matrix.autoJoinAllowlist` 控制。 - 要**不允许任何房间**,设置 `channels.matrix.groupPolicy: "disabled"`(或保持空的允许列表)。 - 旧版键:`channels.matrix.rooms`(与 `groups` 结构相同)。 @@ -208,9 +208,9 @@ openclaw plugins install ./extensions/matrix - `channels.matrix.textChunkLimit`:出站文本分块大小(字符)。 - `channels.matrix.chunkMode`:`length`(默认)或 `newline`,在按长度分块之前按空行(段落边界)分割。 - `channels.matrix.dm.policy`:`pairing | allowlist | open | disabled`(默认:pairing)。 -- `channels.matrix.dm.allowFrom`:私信允许列表(用户 ID 或显示名称)。`open` 需要 `"*"`。向导在可能时将名称解析为 ID。 +- `channels.matrix.dm.allowFrom`:私信允许列表(完整 Matrix 用户 ID)。`open` 需要 `"*"`。向导在可能时将名称解析为 ID。 - `channels.matrix.groupPolicy`:`allowlist | open | disabled`(默认:allowlist)。 -- `channels.matrix.groupAllowFrom`:群组消息的允许发送者列表。 +- `channels.matrix.groupAllowFrom`:群组消息的允许发送者列表(完整 Matrix 用户 ID)。 - `channels.matrix.allowlistOnly`:强制对私信 + 房间执行允许列表规则。 - `channels.matrix.groups`:群组允许列表 + 按房间设置映射。 - `channels.matrix.rooms`:旧版群组允许列表/配置。 diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index eb67c49ce..366f74ade 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -24,7 +24,7 @@ import { type ResolvedMatrixAccount, } from "./matrix/accounts.js"; import { resolveMatrixAuth } from "./matrix/client.js"; -import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js"; +import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; import { probeMatrix } from "./matrix/probe.js"; import { sendMessageMatrix } from "./matrix/send.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; @@ -144,7 +144,7 @@ export const matrixPlugin: ChannelPlugin = { }), resolveAllowFrom: ({ cfg }) => ((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)), - formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom), + formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom), }, security: { resolveDmPolicy: ({ account }) => ({ @@ -153,11 +153,7 @@ export const matrixPlugin: ChannelPlugin = { policyPath: "channels.matrix.dm.policy", allowFromPath: "channels.matrix.dm.allowFrom", approveHint: formatPairingApproveHint("matrix"), - normalizeEntry: (raw) => - raw - .replace(/^matrix:/i, "") - .trim() - .toLowerCase(), + normalizeEntry: (raw) => normalizeMatrixUserId(raw), }), collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; diff --git a/extensions/matrix/src/matrix/monitor/allowlist.test.ts b/extensions/matrix/src/matrix/monitor/allowlist.test.ts new file mode 100644 index 000000000..d91ef71ce --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/allowlist.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js"; + +describe("resolveMatrixAllowListMatch", () => { + it("matches full user IDs and prefixes", () => { + const userId = "@Alice:Example.org"; + const direct = resolveMatrixAllowListMatch({ + allowList: normalizeMatrixAllowList(["@alice:example.org"]), + userId, + }); + expect(direct.allowed).toBe(true); + expect(direct.matchSource).toBe("id"); + + const prefixedMatrix = resolveMatrixAllowListMatch({ + allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]), + userId, + }); + expect(prefixedMatrix.allowed).toBe(true); + expect(prefixedMatrix.matchSource).toBe("prefixed-id"); + + const prefixedUser = resolveMatrixAllowListMatch({ + allowList: normalizeMatrixAllowList(["user:@alice:example.org"]), + userId, + }); + expect(prefixedUser.allowed).toBe(true); + expect(prefixedUser.matchSource).toBe("prefixed-user"); + }); + + it("ignores display names and localparts", () => { + const match = resolveMatrixAllowListMatch({ + allowList: normalizeMatrixAllowList(["alice", "Alice"]), + userId: "@alice:example.org", + }); + expect(match.allowed).toBe(false); + }); + + it("matches wildcard", () => { + const match = resolveMatrixAllowListMatch({ + allowList: normalizeMatrixAllowList(["*"]), + userId: "@alice:example.org", + }); + expect(match.allowed).toBe(true); + expect(match.matchSource).toBe("wildcard"); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts index b110dc9ef..754f3ee24 100644 --- a/extensions/matrix/src/matrix/monitor/allowlist.ts +++ b/extensions/matrix/src/matrix/monitor/allowlist.ts @@ -4,22 +4,71 @@ function normalizeAllowList(list?: Array) { return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean); } -export function normalizeAllowListLower(list?: Array) { - return normalizeAllowList(list).map((entry) => entry.toLowerCase()); +function normalizeMatrixUser(raw?: string | null): string { + const value = (raw ?? "").trim(); + if (!value) { + return ""; + } + if (!value.startsWith("@") || !value.includes(":")) { + return value.toLowerCase(); + } + const withoutAt = value.slice(1); + const splitIndex = withoutAt.indexOf(":"); + if (splitIndex === -1) { + return value.toLowerCase(); + } + const localpart = withoutAt.slice(0, splitIndex).toLowerCase(); + const server = withoutAt.slice(splitIndex + 1).toLowerCase(); + if (!server) { + return value.toLowerCase(); + } + return `@${localpart}:${server.toLowerCase()}`; } -function normalizeMatrixUser(raw?: string | null): string { - return (raw ?? "").trim().toLowerCase(); +export function normalizeMatrixUserId(raw?: string | null): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) { + return ""; + } + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("matrix:")) { + return normalizeMatrixUser(trimmed.slice("matrix:".length)); + } + if (lowered.startsWith("user:")) { + return normalizeMatrixUser(trimmed.slice("user:".length)); + } + return normalizeMatrixUser(trimmed); +} + +function normalizeMatrixAllowListEntry(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return trimmed; + } + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("matrix:")) { + return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`; + } + if (lowered.startsWith("user:")) { + return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`; + } + return normalizeMatrixUser(trimmed); +} + +export function normalizeMatrixAllowList(list?: Array) { + return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry)); } export type MatrixAllowListMatch = AllowlistMatch< - "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart" + "wildcard" | "id" | "prefixed-id" | "prefixed-user" >; export function resolveMatrixAllowListMatch(params: { allowList: string[]; userId?: string; - userName?: string; }): MatrixAllowListMatch { const allowList = params.allowList; if (allowList.length === 0) { @@ -29,14 +78,10 @@ export function resolveMatrixAllowListMatch(params: { return { allowed: true, matchKey: "*", matchSource: "wildcard" }; } const userId = normalizeMatrixUser(params.userId); - const userName = normalizeMatrixUser(params.userName); - const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : ""; const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [ { value: userId, source: "id" }, { value: userId ? `matrix:${userId}` : "", source: "prefixed-id" }, { value: userId ? `user:${userId}` : "", source: "prefixed-user" }, - { value: userName, source: "name" }, - { value: localPart, source: "localpart" }, ]; for (const candidate of candidates) { if (!candidate.value) { @@ -53,10 +98,6 @@ export function resolveMatrixAllowListMatch(params: { return { allowed: false }; } -export function resolveMatrixAllowListMatches(params: { - allowList: string[]; - userId?: string; - userName?: string; -}) { +export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) { return resolveMatrixAllowListMatch(params).allowed; } diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 6f45f5ed3..d88ad3523 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -23,9 +23,9 @@ import { sendTypingMatrix, } from "../send.js"; import { + normalizeMatrixAllowList, resolveMatrixAllowListMatch, resolveMatrixAllowListMatches, - normalizeAllowListLower, } from "./allowlist.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; @@ -236,12 +236,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const storeAllowFrom = await core.channel.pairing .readAllowFromStore("matrix") .catch(() => []); - const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]); + const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const effectiveGroupAllowFrom = normalizeAllowListLower([ - ...groupAllowFrom, - ...storeAllowFrom, - ]); + const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; if (isDirectMessage) { @@ -252,7 +249,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const allowMatch = resolveMatrixAllowListMatch({ allowList: effectiveAllowFrom, userId: senderId, - userName: senderName, }); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); if (!allowMatch.allowed) { @@ -297,9 +293,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const roomUsers = roomConfig?.users ?? []; if (isRoom && roomUsers.length > 0) { const userMatch = resolveMatrixAllowListMatch({ - allowList: normalizeAllowListLower(roomUsers), + allowList: normalizeMatrixAllowList(roomUsers), userId: senderId, - userName: senderName, }); if (!userMatch.allowed) { logVerboseMessage( @@ -314,7 +309,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const groupAllowMatch = resolveMatrixAllowListMatch({ allowList: effectiveGroupAllowFrom, userId: senderId, - userName: senderName, }); if (!groupAllowMatch.allowed) { logVerboseMessage( @@ -387,21 +381,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const senderAllowedForCommands = resolveMatrixAllowListMatches({ allowList: effectiveAllowFrom, userId: senderId, - userName: senderName, }); const senderAllowedForGroup = groupAllowConfigured ? resolveMatrixAllowListMatches({ allowList: effectiveGroupAllowFrom, userId: senderId, - userName: senderName, }) : false; const senderAllowedForRoomUsers = isRoom && roomUsers.length > 0 ? resolveMatrixAllowListMatches({ - allowList: normalizeAllowListLower(roomUsers), + allowList: normalizeMatrixAllowList(roomUsers), userId: senderId, - userName: senderName, }) : false; const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 4ac87b251..aae5f00d5 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -10,6 +10,7 @@ import { resolveSharedMatrixClient, stopSharedClient, } from "../client.js"; +import { normalizeMatrixUserId } from "./allowlist.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; @@ -68,68 +69,94 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi .replace(/^(room|channel):/i, "") .trim(); const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); + const resolveUserAllowlist = async ( + label: string, + list?: Array, + ): Promise => { + let allowList = list ?? []; + if (allowList.length === 0) { + return allowList; + } + const entries = allowList + .map((entry) => normalizeUserEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (entries.length === 0) { + return allowList; + } + const mapping: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + const pending: string[] = []; + for (const entry of entries) { + if (isMatrixUserId(entry)) { + additions.push(normalizeMatrixUserId(entry)); + continue; + } + pending.push(entry); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending, + kind: "user", + runtime, + }); + for (const entry of resolved) { + if (entry.resolved && entry.id) { + const normalizedId = normalizeMatrixUserId(entry.id); + additions.push(normalizedId); + mapping.push(`${entry.input}→${normalizedId}`); + } else { + unresolved.push(entry.input); + } + } + } + allowList = mergeAllowlist({ existing: allowList, additions }); + summarizeMapping(label, mapping, unresolved, runtime); + if (unresolved.length > 0) { + runtime.log?.( + `${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`, + ); + } + return allowList; + }; const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + let groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms; - if (allowFrom.length > 0) { - const entries = allowFrom - .map((entry) => normalizeUserEntry(String(entry))) - .filter((entry) => entry && entry !== "*"); - if (entries.length > 0) { - const mapping: string[] = []; - const unresolved: string[] = []; - const additions: string[] = []; - const pending: string[] = []; - for (const entry of entries) { - if (isMatrixUserId(entry)) { - additions.push(entry); - continue; - } - pending.push(entry); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg, - inputs: pending, - kind: "user", - runtime, - }); - for (const entry of resolved) { - if (entry.resolved && entry.id) { - additions.push(entry.id); - mapping.push(`${entry.input}→${entry.id}`); - } else { - unresolved.push(entry.input); - } - } - } - allowFrom = mergeAllowlist({ existing: allowFrom, additions }); - summarizeMapping("matrix users", mapping, unresolved, runtime); - } - } + allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom); + groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom); if (roomsConfig && Object.keys(roomsConfig).length > 0) { - const entries = Object.keys(roomsConfig).filter((key) => key !== "*"); const mapping: string[] = []; const unresolved: string[] = []; - const nextRooms = { ...roomsConfig }; - const pending: Array<{ input: string; query: string }> = []; - for (const entry of entries) { + const nextRooms: Record = {}; + if (roomsConfig["*"]) { + nextRooms["*"] = roomsConfig["*"]; + } + const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> = + []; + for (const [entry, roomConfig] of Object.entries(roomsConfig)) { + if (entry === "*") { + continue; + } const trimmed = entry.trim(); if (!trimmed) { continue; } const cleaned = normalizeRoomEntry(trimmed); - if (cleaned.startsWith("!") && cleaned.includes(":")) { + if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) { if (!nextRooms[cleaned]) { - nextRooms[cleaned] = roomsConfig[entry]; + nextRooms[cleaned] = roomConfig; + } + if (cleaned !== entry) { + mapping.push(`${entry}→${cleaned}`); } - mapping.push(`${entry}→${cleaned}`); continue; } - pending.push({ input: entry, query: trimmed }); + pending.push({ input: entry, query: trimmed, config: roomConfig }); } if (pending.length > 0) { const resolved = await resolveMatrixTargets({ @@ -145,7 +172,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } if (entry.resolved && entry.id) { if (!nextRooms[entry.id]) { - nextRooms[entry.id] = roomsConfig[source.input]; + nextRooms[entry.id] = source.config; } mapping.push(`${source.input}→${entry.id}`); } else { @@ -155,6 +182,25 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } roomsConfig = nextRooms; summarizeMapping("matrix rooms", mapping, unresolved, runtime); + if (unresolved.length > 0) { + runtime.log?.( + "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.", + ); + } + } + if (roomsConfig && Object.keys(roomsConfig).length > 0) { + const nextRooms = { ...roomsConfig }; + for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) { + const users = roomConfig?.users ?? []; + if (users.length === 0) { + continue; + } + const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users); + if (resolvedUsers !== users) { + nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers }; + } + } + roomsConfig = nextRooms; } cfg = { @@ -167,6 +213,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi ...cfg.channels?.matrix?.dm, allowFrom, }, + ...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}), ...(roomsConfig ? { groups: roomsConfig } : {}), }, }, diff --git a/extensions/matrix/src/matrix/monitor/rooms.test.ts b/extensions/matrix/src/matrix/monitor/rooms.test.ts new file mode 100644 index 000000000..21fe5a904 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/rooms.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveMatrixRoomConfig } from "./rooms.js"; + +describe("resolveMatrixRoomConfig", () => { + it("matches room IDs and aliases, not names", () => { + const rooms = { + "!room:example.org": { allow: true }, + "#alias:example.org": { allow: true }, + "Project Room": { allow: true }, + }; + + const byId = resolveMatrixRoomConfig({ + rooms, + roomId: "!room:example.org", + aliases: [], + name: "Project Room", + }); + expect(byId.allowed).toBe(true); + expect(byId.matchKey).toBe("!room:example.org"); + + const byAlias = resolveMatrixRoomConfig({ + rooms, + roomId: "!other:example.org", + aliases: ["#alias:example.org"], + name: "Other Room", + }); + expect(byAlias.allowed).toBe(true); + expect(byAlias.matchKey).toBe("#alias:example.org"); + + const byName = resolveMatrixRoomConfig({ + rooms: { "Project Room": { allow: true } }, + roomId: "!different:example.org", + aliases: [], + name: "Project Room", + }); + expect(byName.allowed).toBe(false); + expect(byName.config).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/rooms.ts b/extensions/matrix/src/matrix/monitor/rooms.ts index ed705e837..2200ad0c1 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.ts @@ -22,7 +22,6 @@ export function resolveMatrixRoomConfig(params: { params.roomId, `room:${params.roomId}`, ...params.aliases, - params.name ?? "", ); const { entry: matched, diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts new file mode 100644 index 000000000..de2ef8151 --- /dev/null +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -0,0 +1,48 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { resolveMatrixTargets } from "./resolve-targets.js"; + +vi.mock("./directory-live.js", () => ({ + listMatrixDirectoryPeersLive: vi.fn(), + listMatrixDirectoryGroupsLive: vi.fn(), +})); + +describe("resolveMatrixTargets (users)", () => { + beforeEach(() => { + vi.mocked(listMatrixDirectoryPeersLive).mockReset(); + }); + + it("resolves exact unique display name matches", async () => { + const matches: ChannelDirectoryEntry[] = [ + { kind: "user", id: "@alice:example.org", name: "Alice" }, + ]; + vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); + + const [result] = await resolveMatrixTargets({ + cfg: {}, + inputs: ["Alice"], + kind: "user", + }); + + expect(result?.resolved).toBe(true); + expect(result?.id).toBe("@alice:example.org"); + }); + + it("does not resolve ambiguous or non-exact matches", async () => { + const matches: ChannelDirectoryEntry[] = [ + { kind: "user", id: "@alice:example.org", name: "Alice" }, + { kind: "user", id: "@alice:evil.example", name: "Alice" }, + ]; + vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); + + const [result] = await resolveMatrixTargets({ + cfg: {}, + inputs: ["Alice"], + kind: "user", + }); + + expect(result?.resolved).toBe(false); + expect(result?.note).toMatch(/use full Matrix ID/i); + }); +}); diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts index a184247e1..3cb9c6e69 100644 --- a/extensions/matrix/src/resolve-targets.ts +++ b/extensions/matrix/src/resolve-targets.ts @@ -28,6 +28,52 @@ function pickBestGroupMatch( return matches[0]; } +function pickBestUserMatch( + matches: ChannelDirectoryEntry[], + query: string, +): ChannelDirectoryEntry | undefined { + if (matches.length === 0) { + return undefined; + } + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const exact = matches.filter((match) => { + const id = match.id.trim().toLowerCase(); + const name = match.name?.trim().toLowerCase(); + const handle = match.handle?.trim().toLowerCase(); + return normalized === id || normalized === name || normalized === handle; + }); + if (exact.length === 1) { + return exact[0]; + } + return undefined; +} + +function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string { + if (matches.length === 0) { + return "no matches"; + } + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return "empty input"; + } + const exact = matches.filter((match) => { + const id = match.id.trim().toLowerCase(); + const name = match.name?.trim().toLowerCase(); + const handle = match.handle?.trim().toLowerCase(); + return normalized === id || normalized === name || normalized === handle; + }); + if (exact.length === 0) { + return "no exact match; use full Matrix ID"; + } + if (exact.length > 1) { + return "multiple exact matches; use full Matrix ID"; + } + return "no exact match; use full Matrix ID"; +} + export async function resolveMatrixTargets(params: { cfg: unknown; inputs: string[]; @@ -52,13 +98,13 @@ export async function resolveMatrixTargets(params: { query: trimmed, limit: 5, }); - const best = matches[0]; + const best = pickBestUserMatch(matches, trimmed); results.push({ input, resolved: Boolean(best?.id), id: best?.id, name: best?.name, - note: matches.length > 1 ? "multiple matches; chose first" : undefined, + note: best ? undefined : describeUserMatchFailure(matches, trimmed), }); } catch (err) { params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index f03734130..f16ebfa19 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -7,7 +7,7 @@ export type MatrixDmConfig = { enabled?: boolean; /** Direct message access policy (default: pairing). */ policy?: DmPolicy; - /** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */ + /** Allowlist for DM senders (matrix user IDs or "*"). */ allowFrom?: Array; }; @@ -22,7 +22,7 @@ export type MatrixRoomConfig = { tools?: { allow?: string[]; deny?: string[] }; /** If true, reply without mention requirements. */ autoReply?: boolean; - /** Optional allowlist for room senders (user IDs or localparts). */ + /** Optional allowlist for room senders (matrix user IDs). */ users?: Array; /** Optional skill filter for this room. */ skills?: string[]; @@ -61,7 +61,7 @@ export type MatrixConfig = { allowlistOnly?: boolean; /** Group message policy (default: allowlist). */ groupPolicy?: GroupPolicy; - /** Allowlist for group senders (user IDs or localparts). */ + /** Allowlist for group senders (matrix user IDs). */ groupAllowFrom?: Array; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; @@ -79,9 +79,9 @@ export type MatrixConfig = { autoJoinAllowlist?: Array; /** Direct message policy + allowlist overrides. */ dm?: MatrixDmConfig; - /** Room config allowlist keyed by room ID, alias, or name. */ + /** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */ groups?: Record; - /** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */ + /** Room config allowlist keyed by room ID or alias. Legacy; use groups. */ rooms?: Record; /** Per-action tool gating (default: true for all). */ actions?: MatrixActionConfig;