Skip to content

bug(matrix): isStrictDirectMembership classifies 2-person rooms as DMs regardless of is_direct=false or groups[] config — requireMention silently bypassed #85017

@paulmeier

Description

@paulmeier

Summary

extensions/matrix/src/matrix/direct-room.ts classifies any 2-member room (bot + 1 user) as a "strict direct room" using a pure member-count heuristic. This happens before per-room/per-group config is consulted, so:

  • A room explicitly configured under channels.matrix.groups[<roomId>] with requireMention: true is still treated as a DM at runtime.
  • A room created with is_direct: false and a name is still treated as a DM.
  • DM mode bypasses requireMention, so the bot responds to every message in those rooms regardless of mention status.

The CHANGELOG entry from #57124 ("honor explicit local is_direct: false for discovered DM candidates") was a partial fix — only for inherited/discovered DM candidates, not for the runtime isStrictDirectRoom path that drives mention/skill/session routing.

Affected code

// extensions/matrix/src/matrix/direct-room.ts
export function isStrictDirectMembership(params: {
  selfUserId?: string | null;
  remoteUserId?: string | null;
  joinedMembers?: readonly string[] | null;
}): boolean {
  // ...
  return Boolean(
    selfUserId &&
    remoteUserId &&
    joinedMembers.length === 2 &&        // ← pure heuristic
    joinedMembers.includes(selfUserId) &&
    joinedMembers.includes(remoteUserId),
  );
}

inspectMatrixDirectRoomEvidence does read the is_direct membership flag via hasDirectMatrixMemberFlag, but the result (memberStateFlag) is never used to negate the strict flag — even when explicitly false. isStrictDirectRoom returns .strict directly without considering is_direct: false or local config.

Reproduction (verified on 2026.5.19)

  1. Configure OpenClaw with:

    "channels.matrix": {
      "enabled": true,
      "encryption": true,
      "dm": {
        "enabled": true,
        "policy": "allowlist",
        "allowFrom": ["@user:server"],
        "sessionScope": "per-room"
      },
      "groupPolicy": "open",
      "groups": {
        "!roomId:server": {
          "requireMention": true,
          "skills": ["..."]
        }
      }
    }
  2. Create a Matrix room via the client API with:

    {
      "name": "parents",
      "preset": "private_chat",
      "is_direct": false,
      "initial_state": [
        { "type": "m.room.encryption", "state_key": "", "content": { "algorithm": "m.megolm.v1.aes-sha2" } }
      ],
      "invite": ["@user:server"]
    }
  3. Invite the bot, bot joins → room has 2 members. The room has a name, is_direct: false on the membership events, and is explicitly in OpenClaw's groups[] map.

  4. From @user:server, send a plain message (no @bot mention) → bot responds despite requireMention: true.

  5. Add a third member (any user) to the same room → restart not required, eventually joinedMembers.length === 3 so isStrictDirectMembership returns falserequireMention: true is now honored: plain messages ignored, only @bot ... triggers a reply.

The workaround "add a third member" confirms the diagnosis: the 2-person count is the sole DM classifier.

Why this matters

The pattern "private 1:1 room with the bot that should still require mentions" is reasonable: a personal scratch room for an admin, a held-aside room before adding family/team members, an OPS room where the user wants explicit invocation. The current code makes this impossible without inviting a placeholder third user.

The deeper issue: groups[<roomId>] config has no effect for any 2-person room. The schema's rooms vs groups distinction is silently broken for the smallest rooms.

Proposed fix shape

In isStrictDirectRoom (or its callers), treat as NOT a strict direct room when any of the following hold:

  1. The bot's own m.room.member event has is_direct: false (already readable via hasDirectMatrixMemberFlag — just needs to be honored as a veto, not an augmentation).
  2. The room's room_id is explicitly listed in channels.matrix.groups[].
  3. (Optional, more conservative) The room has an m.room.name set — DMs are conventionally nameless.

Suggested minimal patch in inspectMatrixDirectRoomEvidence:

const memberStateFlag = await hasDirectMatrixMemberFlag(params.client, params.roomId, selfUserId);
// Explicit local override: is_direct === false negates the strict-DM classification
if (memberStateFlag === false) {
  return {
    joinedMembers,
    strict: false,
    viaMemberState: false,
    memberStateFlag,
  };
}

Then a separate change in the channel runtime to check groups[roomId] presence before routing through DM logic.

Environment

  • OpenClaw: 2026.5.19
  • Matrix homeserver: Synapse (latest)
  • Matrix plugin: bundled @openclaw/matrix
  • Node: as shipped in ghcr.io/openclaw/openclaw:latest

Related

Happy to draft a PR if the maintainers concur on the fix shape — wanted to file the issue first to get the design discussion in the right place.

Metadata

Metadata

Assignees

Labels

P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-live-reproClawSweeper needs live local, crabbox, or manual validation to confirm this issue.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.issue-rating: 🐚 platinum hermitGood issue quality with a plausible reproduction path needing some confirmation.

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions