mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const matrixSingleAccountKeysToMove = [
|
||||
"initialSyncLimit",
|
||||
"encryption",
|
||||
"allowlistOnly",
|
||||
"dangerouslyAllowNameMatching",
|
||||
"allowBots",
|
||||
"blockStreaming",
|
||||
"replyToMode",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user