From e9b467d6ade2d280a6b24b541cbddbf1d29f6d07 Mon Sep 17 00:00:00 2001 From: Argenis Date: Sun, 22 Mar 2026 14:41:43 -0400 Subject: [PATCH] feat(matrix): add allowed_rooms config for room-level gating (#4230) (#4260) Add an `allowed_rooms` field to MatrixConfig that controls which rooms the bot will accept messages from and join invites for. When the list is non-empty, messages from unlisted rooms are silently dropped and room invites are auto-rejected. When empty (default), all rooms are allowed, preserving backward compatibility. - Config: add `allowed_rooms: Vec` with `#[serde(default)]` - Message handler: replace disabled room_id filter with allowlist check - Invite handler: auto-accept allowed rooms, auto-reject others - Support both canonical room IDs and aliases, case-insensitive --- src/channels/matrix.rs | 185 ++++++++++++++++++++++++++++++++++- src/channels/mod.rs | 3 +- src/config/schema.rs | 7 ++ src/integrations/registry.rs | 1 + src/onboard/wizard.rs | 1 + 5 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs index 38e12d929..827ee84ca 100644 --- a/src/channels/matrix.rs +++ b/src/channels/matrix.rs @@ -8,6 +8,7 @@ use matrix_sdk::{ events::reaction::ReactionEventContent, events::receipt::ReceiptThread, events::relation::{Annotation, Thread}, + events::room::member::StrippedRoomMemberEvent, events::room::message::{ MessageType, OriginalSyncRoomMessageEvent, Relation, RoomMessageEventContent, }, @@ -32,6 +33,7 @@ pub struct MatrixChannel { access_token: String, room_id: String, allowed_users: Vec, + allowed_rooms: Vec, session_owner_hint: Option, session_device_id_hint: Option, zeroclaw_dir: Option, @@ -48,6 +50,7 @@ impl std::fmt::Debug for MatrixChannel { .field("homeserver", &self.homeserver) .field("room_id", &self.room_id) .field("allowed_users", &self.allowed_users) + .field("allowed_rooms", &self.allowed_rooms) .finish_non_exhaustive() } } @@ -121,7 +124,16 @@ impl MatrixChannel { room_id: String, allowed_users: Vec, ) -> Self { - Self::new_with_session_hint(homeserver, access_token, room_id, allowed_users, None, None) + Self::new_full( + homeserver, + access_token, + room_id, + allowed_users, + vec![], + None, + None, + None, + ) } pub fn new_with_session_hint( @@ -132,11 +144,12 @@ impl MatrixChannel { owner_hint: Option, device_id_hint: Option, ) -> Self { - Self::new_with_session_hint_and_zeroclaw_dir( + Self::new_full( homeserver, access_token, room_id, allowed_users, + vec![], owner_hint, device_id_hint, None, @@ -151,6 +164,28 @@ impl MatrixChannel { owner_hint: Option, device_id_hint: Option, zeroclaw_dir: Option, + ) -> Self { + Self::new_full( + homeserver, + access_token, + room_id, + allowed_users, + vec![], + owner_hint, + device_id_hint, + zeroclaw_dir, + ) + } + + pub fn new_full( + homeserver: String, + access_token: String, + room_id: String, + allowed_users: Vec, + allowed_rooms: Vec, + owner_hint: Option, + device_id_hint: Option, + zeroclaw_dir: Option, ) -> Self { let homeserver = homeserver.trim_end_matches('/').to_string(); let access_token = access_token.trim().to_string(); @@ -160,12 +195,18 @@ impl MatrixChannel { .map(|user| user.trim().to_string()) .filter(|user| !user.is_empty()) .collect(); + let allowed_rooms = allowed_rooms + .into_iter() + .map(|room| room.trim().to_string()) + .filter(|room| !room.is_empty()) + .collect(); Self { homeserver, access_token, room_id, allowed_users, + allowed_rooms, session_owner_hint: Self::normalize_optional_field(owner_hint), session_device_id_hint: Self::normalize_optional_field(device_id_hint), zeroclaw_dir, @@ -220,6 +261,21 @@ impl MatrixChannel { allowed_users.iter().any(|u| u.eq_ignore_ascii_case(sender)) } + /// Check whether a room (by its canonical ID) is in the allowed_rooms list. + /// If allowed_rooms is empty, all rooms are allowed. + fn is_room_allowed_static(allowed_rooms: &[String], room_id: &str) -> bool { + if allowed_rooms.is_empty() { + return true; + } + allowed_rooms + .iter() + .any(|r| r.eq_ignore_ascii_case(room_id)) + } + + fn is_room_allowed(&self, room_id: &str) -> bool { + Self::is_room_allowed_static(&self.allowed_rooms, room_id) + } + fn is_supported_message_type(msgtype: &str) -> bool { matches!(msgtype, "m.text" | "m.notice") } @@ -702,6 +758,7 @@ impl Channel for MatrixChannel { let target_room_for_handler = target_room.clone(); let my_user_id_for_handler = my_user_id.clone(); let allowed_users_for_handler = self.allowed_users.clone(); + let allowed_rooms_for_handler = self.allowed_rooms.clone(); let dedupe_for_handler = Arc::clone(&recent_event_cache); let homeserver_for_handler = self.homeserver.clone(); let access_token_for_handler = self.access_token.clone(); @@ -712,6 +769,7 @@ impl Channel for MatrixChannel { let target_room = target_room_for_handler.clone(); let my_user_id = my_user_id_for_handler.clone(); let allowed_users = allowed_users_for_handler.clone(); + let allowed_rooms = allowed_rooms_for_handler.clone(); let dedupe = Arc::clone(&dedupe_for_handler); let homeserver = homeserver_for_handler.clone(); let access_token = access_token_for_handler.clone(); @@ -725,6 +783,15 @@ impl Channel for MatrixChannel { return; } + // Room allowlist: skip messages from rooms not in the configured list + if !MatrixChannel::is_room_allowed_static(&allowed_rooms, room.room_id().as_ref()) { + tracing::debug!( + "Matrix: ignoring message from room {} (not in allowed_rooms)", + room.room_id() + ); + return; + } + if event.sender == my_user_id { return; } @@ -913,6 +980,45 @@ impl Channel for MatrixChannel { } }); + // Invite handler: auto-accept invites for allowed rooms, auto-reject others + let allowed_rooms_for_invite = self.allowed_rooms.clone(); + client.add_event_handler(move |event: StrippedRoomMemberEvent, room: Room| { + let allowed_rooms = allowed_rooms_for_invite.clone(); + async move { + // Only process invite events targeting us + if event.content.membership + != matrix_sdk::ruma::events::room::member::MembershipState::Invite + { + return; + } + + let room_id_str = room.room_id().to_string(); + + if MatrixChannel::is_room_allowed_static(&allowed_rooms, &room_id_str) { + // Room is allowed (or no allowlist configured): auto-accept + tracing::info!( + "Matrix: auto-accepting invite for allowed room {}", + room_id_str + ); + if let Err(error) = room.join().await { + tracing::warn!("Matrix: failed to auto-join room {}: {error}", room_id_str); + } + } else { + // Room is NOT in allowlist: auto-reject + tracing::info!( + "Matrix: auto-rejecting invite for room {} (not in allowed_rooms)", + room_id_str + ); + if let Err(error) = room.leave().await { + tracing::warn!( + "Matrix: failed to reject invite for room {}: {error}", + room_id_str + ); + } + } + } + }); + let sync_settings = SyncSettings::new().timeout(std::time::Duration::from_secs(30)); client .sync_with_result_callback(sync_settings, |sync_result| { @@ -1571,4 +1677,79 @@ mod tests { let resp: SyncResponse = serde_json::from_str(json).unwrap(); assert!(resp.rooms.join.is_empty()); } + + #[test] + fn empty_allowed_rooms_permits_all() { + let ch = make_channel(); + assert!(ch.is_room_allowed("!any:matrix.org")); + assert!(ch.is_room_allowed("!other:evil.org")); + } + + #[test] + fn allowed_rooms_filters_by_id() { + let ch = MatrixChannel::new_full( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec!["@user:m".to_string()], + vec!["!allowed:matrix.org".to_string()], + None, + None, + None, + ); + assert!(ch.is_room_allowed("!allowed:matrix.org")); + assert!(!ch.is_room_allowed("!forbidden:matrix.org")); + } + + #[test] + fn allowed_rooms_supports_aliases() { + let ch = MatrixChannel::new_full( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec!["@user:m".to_string()], + vec![ + "#ops:matrix.org".to_string(), + "!direct:matrix.org".to_string(), + ], + None, + None, + None, + ); + assert!(ch.is_room_allowed("!direct:matrix.org")); + assert!(ch.is_room_allowed("#ops:matrix.org")); + assert!(!ch.is_room_allowed("!other:matrix.org")); + } + + #[test] + fn allowed_rooms_case_insensitive() { + let ch = MatrixChannel::new_full( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec![], + vec!["!Room:Matrix.org".to_string()], + None, + None, + None, + ); + assert!(ch.is_room_allowed("!room:matrix.org")); + assert!(ch.is_room_allowed("!ROOM:MATRIX.ORG")); + } + + #[test] + fn allowed_rooms_trims_whitespace() { + let ch = MatrixChannel::new_full( + "https://m.org".to_string(), + "tok".to_string(), + "!r:m".to_string(), + vec![], + vec![" !room:matrix.org ".to_string(), " ".to_string()], + None, + None, + None, + ); + assert_eq!(ch.allowed_rooms.len(), 1); + assert!(ch.is_room_allowed("!room:matrix.org")); + } } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 59fb4f84e..114216ae6 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -3851,11 +3851,12 @@ fn collect_configured_channels( if let Some(ref mx) = config.channels_config.matrix { channels.push(ConfiguredChannel { display_name: "Matrix", - channel: Arc::new(MatrixChannel::new_with_session_hint_and_zeroclaw_dir( + channel: Arc::new(MatrixChannel::new_full( mx.homeserver.clone(), mx.access_token.clone(), mx.room_id.clone(), mx.allowed_users.clone(), + mx.allowed_rooms.clone(), mx.user_id.clone(), mx.device_id.clone(), config.config_path.parent().map(|path| path.to_path_buf()), diff --git a/src/config/schema.rs b/src/config/schema.rs index ac691d581..0c762364d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -5497,6 +5497,10 @@ pub struct MatrixConfig { pub room_id: String, /// Allowed Matrix user IDs. Empty = deny all. pub allowed_users: Vec, + /// Allowed Matrix room IDs or aliases. Empty = allow all rooms. + /// Supports canonical room IDs (`!abc:server`) and aliases (`#room:server`). + #[serde(default)] + pub allowed_rooms: Vec, /// Whether to interrupt an in-flight agent response when a new message arrives. #[serde(default)] pub interrupt_on_new_message: bool, @@ -10815,6 +10819,7 @@ default_temperature = 0.7 device_id: Some("DEVICE123".into()), room_id: "!room123:matrix.org".into(), allowed_users: vec!["@user:matrix.org".into()], + allowed_rooms: vec![], interrupt_on_new_message: false, }; let json = serde_json::to_string(&mc).unwrap(); @@ -10836,6 +10841,7 @@ default_temperature = 0.7 device_id: None, room_id: "!abc:synapse.local".into(), allowed_users: vec!["@admin:synapse.local".into(), "*".into()], + allowed_rooms: vec![], interrupt_on_new_message: false, }; let toml_str = toml::to_string(&mc).unwrap(); @@ -10929,6 +10935,7 @@ allowed_users = ["@ops:matrix.org"] device_id: None, room_id: "!r:m".into(), allowed_users: vec!["@u:m".into()], + allowed_rooms: vec![], interrupt_on_new_message: false, }), signal: None, diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index bb83121db..5f9348a40 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -891,6 +891,7 @@ mod tests { device_id: None, room_id: "!r:m".into(), allowed_users: vec![], + allowed_rooms: vec![], interrupt_on_new_message: false, }); let entries = all_integrations(); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index a336c5f1a..76f47003c 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -4204,6 +4204,7 @@ fn setup_channels() -> Result { device_id: detected_device_id, room_id, allowed_users, + allowed_rooms: vec![], interrupt_on_new_message: false, }); }