fix(matrix): isolate room context and restore reliable inbound dispatch#18505
Conversation
28299d5 to
d02f364
Compare
May 7, 2026 UpdateThis PR has been refreshed after the Branch Refresh
Follow-Up Fix
Local ValidationPassed: scripts/run_tests.sh \
tests/gateway/test_matrix.py \
tests/gateway/test_matrix_project_context_isolation.py \
tests/gateway/test_matrix_exec_approval.py \
tests/gateway/test_matrix_voice.py \
-q -o 'addopts='Result: Also passed: python -m py_compile \
gateway/platforms/matrix.py \
gateway/run.py \
gateway/session.py
git diff --check |
4c9eef2 to
30b6f89
Compare
History cleanupRebased and cleaned commit history for reviewability.
|
30b6f89 to
86279e4
Compare
v2026.5.16 refreshRebased this Matrix PR stack onto current upstream main after the v2026.5.16 / v0.14.0 release. Notes:
|
Review focus after v2026.5.16 refreshThis remains the intended first merge target for the Matrix series. I marked the follow-up PRs (#18506, #18507, and #23815) as draft stacked previews so this PR can be reviewed independently. Scope in this PR
Out of scope
Current validation
I can split this further if maintainers prefer, but this is the smallest unit that fully fixes the room/session isolation class of bugs. |
d8bb64a to
e84fef4
Compare
2dd2639 to
b16b54b
Compare
01371be to
3cd9dfe
Compare
|
Rebased this PR onto current Notes:
The custom Synapse/AgentFirst metadata PR (#23815) has been retired from the active stack to keep this series focused on upstreamable core Matrix functionality. |
Move Matrix /status scope output and /resume same-room guards from the pre-refactor gateway/run.py into gateway/slash_commands.py so PR NousResearch#18505 foundation behavior survives the upstream god-file decomposition. Uses i18n keys for Matrix resume/status messages. Preserves upstream session.py fixes (role_authorized, DM user_id isolation).
3cd9dfe to
9604a6b
Compare
Document why Hermes uses an explicit sync loop with handle_sync() rather than client.start(), aligning with upstream NousResearch#7914 diagnostics while preserving Hermes background maintenance tasks.
The Matrix /resume and /status slash-command keys added in the foundation PR must exist in every supported locale file. tests/agent/test_i18n.py asserts key and placeholder parity across catalogs. Non-English locales use English strings as interim placeholders until community translators can localize them.
Jun 10, 2026 rebase refresh (post–
|
| Item | Value |
|---|---|
Base (main) |
a72bb037 |
| Branch head | 3a9439c19 |
Commits ahead of main |
8 |
| Mergeable | clean (no conflicts) |
Rebase highlights
- Gateway mixin port: Matrix
/statusscope output and/resumesame-room guards now live ingateway/slash_commands.py, matching the upstream god-file decomposition. Upstream session fixes (role_authorized, DMuser_idisolation) are preserved. - Scope reframing vs current
main: upstream already shipshandle_sync()inbound dispatch (Matrix gateway can send messages but doesn't receive/respond to incoming messages #7914 lineage; supersedes the closedclient.start()approach in fix(gateway): start Matrix mautrix syncer #37807). This PR’s primary merge value is room/session isolation —MatrixRoomIdentity,MATRIX_SESSION_SCOPE, project-room context boundaries, and fail-closed/resume— not re-claiming inbound dispatch alone. - i18n parity fix: added the new Matrix
/resume+/statuscatalog keys to all 15 non-English locale files (3a9439c19).tests/agent/test_i18n.pynow passes (47/47). - AgentFirst (feat(matrix): add optional AgentFirstModule metadata support #23815) remains out of scope — fork-only metadata integration.
Local validation (.venv, Jun 10 2026)
.venv/bin/python -m py_compile \
gateway/platforms/matrix.py gateway/run.py gateway/session.py gateway/slash_commands.py
git diff --check origin/main...feature/matrix-foundation
.venv/bin/python -m pytest \
tests/gateway/test_matrix.py \
tests/gateway/test_matrix_project_context_isolation.py \
tests/gateway/test_matrix_exec_approval.py \
tests/gateway/test_matrix_approval_reaction_fail_closed.py \
tests/agent/test_i18n.py -qResult: 377 passed (Matrix + i18n subset)
Suggested review focus (PR 1/3)
MatrixRoomIdentity/m.directvs named-room DM classificationMATRIX_SESSION_SCOPE=auto|room|threadsession-key behavior- Project A ↔ Project B isolation tests (
test_matrix_project_context_isolation.py) - Matrix
/resumesame-room guard +--cross-room/--allescape hatches inslash_commands.py - Matrix scope block in
/status
PRs #18506 and #18507 remain draft stacked previews until this foundation PR lands. Happy to split further if maintainers prefer a smaller first merge unit.
austinpickett
left a comment
There was a problem hiding this comment.
Review: APPROVE (still valid — no rebase needed)
Reviewed core logic only (gateway/platforms/matrix.py, session.py, config.py, slash_commands.py); skipped the 16 locales/*.yaml + 2 website/docs files as i18n/docs noise.
Currency / mergeability
Despite the May-01 open date, this is current: head 3a9439c is rebased onto a72bb037, which is the present origin/main tip (0 commits between merge-base and main). GitHub reports MERGEABLE. No rebase required.
Core claim verified — room-isolation bug is still present on main
The 'isolate room context' fix is not superseded. On current main:
- DM misclassification still live:
get_chat_infouseschat_type = "dm" if await self._is_dm_room(chat_id) else "group"— purely_dm_rooms/m.directcache, with no named-room override. A named two-member project room can still be misclassified as a DM. - No
MATRIX_SESSION_SCOPE(auto|room|thread) on main. - No
MatrixRoomIdentity/is_direct_account_dataauthority logic. - No cross-room
/resumeguard inslash_commands.py(no same-room/--cross-room/--allhandling).
This PR adds exactly these: has_explicit_name so named rooms win over stale m.direct; scope routing at matrix.py ~L1529-1541; room-boundary system-prompt injection in session.py (build_session_context_prompt); fail-closed cross-room resume. The PR body's own framing is accurate — it does not re-claim handle_sync() inbound dispatch (already on main via #7914); merge value is the room/session-isolation class of bugs.
Notes / non-blocking
matrix.pyis +1499/-290 in this single file — large but cohesive (new class + scope plumbing). Recommend the stack (#18506/#18507) stays draft until this lands to avoid re-rebasing churn.session.pychange correctly honorsredact_pii(_hash_chat_id) for room/thread IDs in the prompt — good.
Verdict: APPROVE. Bug confirmed still live on main, change is well-scoped, branch is current and mergeable.
…quire_mention Revert the early MATRIX_ALLOWED_USERS gate in _on_room_message so inbound sender authorization stays in gateway authz like main. Parse require_mention from config.extra (platforms.matrix / top-level matrix yaml) with env fallback, matching thread_require_mention and fixing Forge when require_mention is set only in profile config.yaml.
|
Pushed two narrowly scoped fixes on
Commit: |
austinpickett
left a comment
There was a problem hiding this comment.
Code Review Summary
PR #18505 — fix(matrix): isolate room context and restore reliable inbound dispatch
Priority: P1
Verdict: Request Changes
Critical
** exposes full — potential PII / info-leak in shared rooms**
In gateway/slash_commands.py, the new Matrix scope block appends:
t("gateway.status.matrix_scope_key", session_key=session_key)Session keys typically encode the room ID and/or user ID. Exposing the raw key in a shared/non-DM Matrix room leaks routing metadata to all room participants. Either truncate (e.g. first 8 chars + …) or gate this line on the DM/admin context check that guards other sensitive /status fields.
Warnings
1. Type annotation mismatch on _MatrixModelPickerPrompt.bot_reaction_events
@dataclass
class _MatrixModelPickerPrompt:
bot_reaction_events: dict[str, str] = None # ← annotation says dict, default is NoneThe __post_init__ guard correctly initialises the field at runtime, but type checkers (mypy/pyright) will flag this as dict[str, str] | None conflict on the annotation. Fix: either use field(default_factory=dict) (cleanest) or annotate as dict[str, str] | None = None.
2. PR scope significantly exceeds the stated P1 bug fix — increases review risk
The 2159-line diff bundles code clearly belonging to PR 2/3 and PR 3/3:
_MatrixHtmlSanitizer(~70 lines) — Matrix rendering safety; PR 3/3's own scope per the stack note_MatrixModelPickerPrompt+_MATRIX_MODEL_PICKER_REACTIONS— reaction/model-picker UI; PR 2/3 scope_MATRIX_CAPABILITIESdict +get_matrix_capabilities()— capability registry; informational feature- Env var stubs for PR 2/3 tools:
MATRIX_TOOLS_ALLOW_REDACTION,MATRIX_TOOLS_ALLOW_INVITES,MATRIX_TOOLS_ALLOW_ROOM_CREATE,MATRIX_ALLOW_PUBLIC_ROOMS
These are additive, non-breaking additions, but they enlarge the blast radius of a P1 merge and make it harder to bisect if a regression surfaces. Recommend extracting them — or at minimum tag the bundled items explicitly in the PR description so merge reviewers know what can be reverted independently.
3. Non-English locale files contain untranslated English strings
All 15 non-English locales (af, de, es, fr, ga, hu, it, ja, ko, pt, ru, tr, uk, zh-hant, zh) appear to carry the new Matrix-specific keys verbatim in English (including the full message strings, not just placeholders). This is acceptable for a P1 merge but should be tracked as a follow-up localization task.
4. _gateway_session_origin_for_id accesses _entries (private attribute)
entries = getattr(self.session_store, "_entries", {}) or {}Fragile coupling to session store internals. If _entries is renamed or restructured the cross-room guard silently returns None and the room boundary check degrades to allow_cross_room=False behavior (which is fail-closed, so no safety regression — but it would silently break UX). Consider exposing a public lookup_by_session_id() method on the session store.
Looks Good
Core room isolation: MatrixRoomIdentity + m.direct DM classification ✅
The named-room-wins-over-stale-account-data logic in _resolve_room_identity() is correct. conflict=True when is_direct and has_explicit_name is a good diagnostic flag. Cache invalidation on _refresh_dm_cache() (clearing both dicts) is properly placed.
_dispatch_sync handles both sync and async handle_sync() ✅
tasks = client.handle_sync(sync_data)
if inspect.isawaitable(tasks):
tasks = await tasks
if tasks:
await asyncio.gather(*tasks)Correct. The inspect.isawaitable guard future-proofs against upstream API changes and the gather correctly fans out concurrent task execution.
/resume same-room guard is fail-closed ✅
Cross-room resume requires explicit --cross-room or --all flags. Default behavior blocks. shlex.split for argument parsing is the right call here (handles quoted session names with spaces). The --all and --cross-room escape hatches are well-scoped.
config.py cleans up duplicate allowed_rooms registration ✅
Old code had free_response_rooms AND a duplicate ar block both reaching MATRIX_ALLOWED_ROOMS. The refactor deduplicates correctly and adds the missing allowed_users YAML-to-env mapping.
i18n catalog parity (all 16 locales updated) ✅
All locale files carry the new Matrix-specific slash-command strings in lockstep. No locale is left missing keys that would cause t() fallback failures at runtime.
Room identity LRU-style eviction is correct for the scale ✅
O(n) eviction at 256-entry capacity is negligible. The oldest-timestamp eviction is correct and deterministic.
Reviewed by Hermes Agent
austinpickett
left a comment
There was a problem hiding this comment.
Code Review Summary
PR #18505 — fix(matrix): isolate room context and restore reliable inbound dispatch
Priority: P1
Verdict: Request Changes
Critical
/status exposes full session_key — potential PII / info-leak in shared rooms
In gateway/slash_commands.py, the new Matrix scope block appends:
t("gateway.status.matrix_scope_key", session_key=session_key)Session keys typically encode the room ID and/or user ID. Exposing the raw key in a shared/non-DM Matrix room leaks routing metadata to all room participants. Either truncate (e.g. first 8 chars + …) or gate this line on the DM/admin context check that guards other sensitive /status fields.
Warnings
1. Type annotation mismatch on _MatrixModelPickerPrompt.bot_reaction_events
@dataclass
class _MatrixModelPickerPrompt:
bot_reaction_events: dict[str, str] = None # annotation says dict, default is NoneThe __post_init__ guard correctly initialises the field at runtime, but mypy/pyright will flag this as a dict[str, str] | None conflict. Fix: use field(default_factory=dict) (cleanest) or annotate as dict[str, str] | None = None.
2. PR scope significantly exceeds the stated P1 bug fix — increases review risk
The 2159-line diff bundles code clearly belonging to PR 2/3 and PR 3/3:
_MatrixHtmlSanitizer(~70 lines) — Matrix rendering safety; PR 3/3 scope per the stack note_MatrixModelPickerPrompt+_MATRIX_MODEL_PICKER_REACTIONS— reaction/model-picker UI; PR 2/3 scope_MATRIX_CAPABILITIESdict +get_matrix_capabilities()— capability registry- Env var stubs for PR 2/3 tools:
MATRIX_TOOLS_ALLOW_REDACTION,MATRIX_TOOLS_ALLOW_INVITES,MATRIX_TOOLS_ALLOW_ROOM_CREATE,MATRIX_ALLOW_PUBLIC_ROOMS
These are additive/non-breaking, but they enlarge the blast radius of a P1 merge and make it harder to bisect if a regression surfaces. Recommend extracting them or explicitly tagging them in the PR description so merge reviewers know what can be reverted independently.
3. Non-English locale files contain untranslated English strings
All 15 non-English locales (af, de, es, fr, ga, hu, it, ja, ko, pt, ru, tr, uk, zh-hant, zh) carry the new Matrix-specific keys verbatim in English. Acceptable for a P1 merge but should be tracked as a follow-up i18n task.
4. _gateway_session_origin_for_id accesses _entries (private attribute)
entries = getattr(self.session_store, "_entries", {}) or {}Fragile coupling to session store internals. If _entries is renamed, the same-room guard silently degrades to fail-closed behavior (safe but silently broken UX). Consider exposing a public lookup_by_session_id() on the session store.
Looks Good
Core room isolation: MatrixRoomIdentity + m.direct DM classification ✅
Named-room-wins-over-stale-account-data logic in _resolve_room_identity() is correct. conflict=True when is_direct and has_explicit_name is a good diagnostic flag. Cache invalidation on _refresh_dm_cache() is correctly placed.
_dispatch_sync handles both sync and async handle_sync() ✅
tasks = client.handle_sync(sync_data)
if inspect.isawaitable(tasks):
tasks = await tasks
if tasks:
await asyncio.gather(*tasks)Correct. The inspect.isawaitable guard future-proofs against upstream API changes; gather correctly fans out concurrent task execution.
/resume same-room guard is fail-closed ✅
Cross-room resume requires explicit --cross-room or --all flags. shlex.split for argument parsing correctly handles quoted session names with spaces. The escape hatches are well-scoped.
config.py cleans up duplicate allowed_rooms registration ✅
Old code had a duplicate ar block both reaching MATRIX_ALLOWED_ROOMS. The refactor deduplicates correctly and adds the missing allowed_users YAML-to-env mapping.
i18n catalog parity (all 16 locales updated) ✅
All locale files carry the new Matrix-specific keys in lockstep — no locale is left missing keys that would cause t() fallback failures at runtime.
Room identity LRU-style eviction is correct for the scale ✅
O(n) eviction at 256-entry capacity is negligible. Oldest-timestamp eviction is correct and deterministic.
Reviewed by Hermes Agent
|
Hermes Agent automated review summary: 2159-line diff across 26 files reviewed. One critical issue: the new Two structural warnings: (1) Core room isolation work ( |
Jun 11 follow-up: review fixes pushedAddressed the concrete review items and refreshed this foundation layer:
Validation:
|
austinpickett
left a comment
There was a problem hiding this comment.
All three blockers from my last review are resolved:
/statussession_key leak — closed. The_session_key_fingerprint()helper now emitssha256:<12>and the test explicitly assertssession_key not in resultand the truncated prefix is absent too. ✓bot_reaction_eventsannotation/default mismatch — fixed withfield(default_factory=dict). ✓/resumereaching into_entries— replaced with the publicSessionStore.lookup_by_session_id()helper, with a fallback for test doubles. ✓
354 passed, 1 skipped on the local Matrix suite; CI green. Approving the foundation layer — stacked PRs 2/3 and 3/3 can proceed.
|
The _MatrixHtmlSanitizer is too aggressive — it strips Before #18505, _markdown_to_html() returned the markdown library output directly with no sanitizer — all standard HTML (including tables) rendered correctly. The new sanitizer regressed that. |
|
Thanks for catching this. Agreed: the sanitizer should preserve Markdown table structure while still dropping unsafe attributes. Opened a focused follow-up fix here: #44848 The fix adds |
Summary
Stacked PR 1/3 for the Matrix gateway parity series.
This PR focuses on Matrix foundation correctness and safety:
MatrixRoomIdentity,m.directauthority)MATRIX_SESSION_SCOPE=auto|room|thread/resumesame-room safety (fail-closed;--cross-room/--allescape hatches)/statushandle_sync()dispatchFollow-up PRs in this stack add Matrix-native tools/interactions (#18506) and then rendering/media/E2EE/diagnostics/docs (#18507).
Why
Matrix project rooms need stable room/session boundaries. A named two-member room such as "Project - Project B" should not be treated as a DM unless Matrix
m.directaccount data says it is direct. A message inside Project B also must not silently resume or act on Project A context.Changes
Matrix adapter
MatrixRoomIdentitywithm.direct-aware DM classification (named rooms win over stale account data).SessionSource.MATRIX_SESSION_SCOPE=auto|room|thread.Gateway/session
/resumesame-room guard and/statusscope output intogateway/slash_commands.py(upstream mixin decomposition).role_authorizedand DMuser_idisolation fixes.Tests
/resumeand cross-room resume tests.Security / privacy
Validation
.venv/bin/python -m py_compile gateway/platforms/matrix.py gateway/run.py gateway/session.py gateway/slash_commands.py.venv/bin/python -m pytest tests/gateway/test_matrix.py tests/gateway/test_matrix_project_context_isolation.py tests/gateway/test_matrix_exec_approval.py tests/gateway/test_matrix_approval_reaction_fail_closed.py tests/agent/test_i18n.py -q→ 377 passedgit diff --checkmain@a72bb037(Jun 10, 2026); branch head3a9439c19Stack
Supersedes #17702 by incorporating named-room identity preservation into a broader Matrix foundation PR with project-room isolation.
Related to #7914 (Matrix inbound sync/dispatch hardening on top of upstream
handle_sync).AgentFirst metadata (#23815) is intentionally out of scope (fork-only).