room overlap access control for cross-room search
search_archive, get_room_context, and sol.search() (in run_script) enforce a configurable member overlap threshold. results from a room are only visible if >=25% of that room's members are also in the requesting room. system-level filter applied at the opensearch query layer — sol never sees results from excluded rooms.
This commit is contained in:
141
src/tools/mod.rs
141
src/tools/mod.rs
@@ -1,26 +1,37 @@
|
||||
pub mod bridge;
|
||||
pub mod devtools;
|
||||
pub mod identity;
|
||||
pub mod research;
|
||||
pub mod room_history;
|
||||
pub mod room_info;
|
||||
pub mod script;
|
||||
pub mod search;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use matrix_sdk::Client as MatrixClient;
|
||||
use matrix_sdk::RoomMemberships;
|
||||
use mistralai_client::v1::tool::Tool;
|
||||
use opensearch::OpenSearch;
|
||||
use serde_json::json;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::context::ResponseContext;
|
||||
use crate::persistence::Store;
|
||||
use crate::sdk::gitea::GiteaClient;
|
||||
use crate::sdk::kratos::KratosClient;
|
||||
|
||||
|
||||
pub struct ToolRegistry {
|
||||
opensearch: OpenSearch,
|
||||
matrix: MatrixClient,
|
||||
config: Arc<Config>,
|
||||
gitea: Option<Arc<GiteaClient>>,
|
||||
kratos: Option<Arc<KratosClient>>,
|
||||
mistral: Option<Arc<mistralai_client::v1::client::Client>>,
|
||||
store: Option<Arc<Store>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
@@ -29,12 +40,18 @@ impl ToolRegistry {
|
||||
matrix: MatrixClient,
|
||||
config: Arc<Config>,
|
||||
gitea: Option<Arc<GiteaClient>>,
|
||||
kratos: Option<Arc<KratosClient>>,
|
||||
mistral: Option<Arc<mistralai_client::v1::client::Client>>,
|
||||
store: Option<Arc<Store>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
opensearch,
|
||||
matrix,
|
||||
config,
|
||||
gitea,
|
||||
kratos,
|
||||
mistral,
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +59,11 @@ impl ToolRegistry {
|
||||
self.gitea.is_some()
|
||||
}
|
||||
|
||||
pub fn tool_definitions(gitea_enabled: bool) -> Vec<Tool> {
|
||||
pub fn has_kratos(&self) -> bool {
|
||||
self.kratos.is_some()
|
||||
}
|
||||
|
||||
pub fn tool_definitions(gitea_enabled: bool, kratos_enabled: bool) -> Vec<Tool> {
|
||||
let mut tools = vec![
|
||||
Tool::new(
|
||||
"search_archive".into(),
|
||||
@@ -172,14 +193,22 @@ impl ToolRegistry {
|
||||
if gitea_enabled {
|
||||
tools.extend(devtools::tool_definitions());
|
||||
}
|
||||
if kratos_enabled {
|
||||
tools.extend(identity::tool_definitions());
|
||||
}
|
||||
|
||||
// Research tool (depth 0 — orchestrator level)
|
||||
if let Some(def) = research::tool_definition(4, 0) {
|
||||
tools.push(def);
|
||||
}
|
||||
|
||||
tools
|
||||
}
|
||||
|
||||
/// Convert Sol's tool definitions to Mistral AgentTool format
|
||||
/// for use with the Agents API (orchestrator agent creation).
|
||||
pub fn agent_tool_definitions(gitea_enabled: bool) -> Vec<mistralai_client::v1::agents::AgentTool> {
|
||||
Self::tool_definitions(gitea_enabled)
|
||||
pub fn agent_tool_definitions(gitea_enabled: bool, kratos_enabled: bool) -> Vec<mistralai_client::v1::agents::AgentTool> {
|
||||
Self::tool_definitions(gitea_enabled, kratos_enabled)
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
mistralai_client::v1::agents::AgentTool::function(
|
||||
@@ -191,6 +220,60 @@ impl ToolRegistry {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the set of room IDs whose search results are visible from
|
||||
/// the requesting room, based on member overlap.
|
||||
///
|
||||
/// A room's results are visible if at least ROOM_OVERLAP_THRESHOLD of
|
||||
/// its members are also members of the requesting room. This is enforced
|
||||
/// at the query level — Sol never sees filtered-out results.
|
||||
async fn allowed_room_ids(&self, requesting_room_id: &str) -> Vec<String> {
|
||||
let rooms = self.matrix.joined_rooms();
|
||||
|
||||
// Get requesting room's member set
|
||||
let requesting_room = rooms.iter().find(|r| r.room_id().as_str() == requesting_room_id);
|
||||
let requesting_members: HashSet<String> = match requesting_room {
|
||||
Some(room) => match room.members(RoomMemberships::JOIN).await {
|
||||
Ok(members) => members.iter().map(|m| m.user_id().to_string()).collect(),
|
||||
Err(_) => return vec![requesting_room_id.to_string()],
|
||||
},
|
||||
None => return vec![requesting_room_id.to_string()],
|
||||
};
|
||||
|
||||
let mut allowed = Vec::new();
|
||||
for room in &rooms {
|
||||
let room_id = room.room_id().to_string();
|
||||
|
||||
// Always allow the requesting room itself
|
||||
if room_id == requesting_room_id {
|
||||
allowed.push(room_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let members: HashSet<String> = match room.members(RoomMemberships::JOIN).await {
|
||||
Ok(m) => m.iter().map(|m| m.user_id().to_string()).collect(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let overlap = members.intersection(&requesting_members).count();
|
||||
let ratio = overlap as f64 / members.len() as f64;
|
||||
|
||||
if ratio >= self.config.behavior.room_overlap_threshold as f64 {
|
||||
debug!(
|
||||
source_room = room_id.as_str(),
|
||||
overlap_pct = format!("{:.0}%", ratio * 100.0).as_str(),
|
||||
"Room passes overlap threshold"
|
||||
);
|
||||
allowed.push(room_id);
|
||||
}
|
||||
}
|
||||
|
||||
allowed
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -199,30 +282,36 @@ impl ToolRegistry {
|
||||
) -> anyhow::Result<String> {
|
||||
match name {
|
||||
"search_archive" => {
|
||||
let allowed = self.allowed_room_ids(&response_ctx.room_id).await;
|
||||
search::search_archive(
|
||||
&self.opensearch,
|
||||
&self.config.opensearch.index,
|
||||
arguments,
|
||||
&allowed,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"get_room_context" => {
|
||||
let allowed = self.allowed_room_ids(&response_ctx.room_id).await;
|
||||
room_history::get_room_context(
|
||||
&self.opensearch,
|
||||
&self.config.opensearch.index,
|
||||
arguments,
|
||||
&allowed,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"list_rooms" => room_info::list_rooms(&self.matrix).await,
|
||||
"get_room_members" => room_info::get_room_members(&self.matrix, arguments).await,
|
||||
"run_script" => {
|
||||
let allowed = self.allowed_room_ids(&response_ctx.room_id).await;
|
||||
script::run_script(
|
||||
&self.opensearch,
|
||||
&self.matrix,
|
||||
&self.config,
|
||||
arguments,
|
||||
response_ctx,
|
||||
allowed,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -233,7 +322,53 @@ impl ToolRegistry {
|
||||
anyhow::bail!("Gitea integration not configured")
|
||||
}
|
||||
}
|
||||
name if name.starts_with("identity_") => {
|
||||
if let Some(ref kratos) = self.kratos {
|
||||
identity::execute(kratos, name, arguments).await
|
||||
} else {
|
||||
anyhow::bail!("Identity (Kratos) integration not configured")
|
||||
}
|
||||
}
|
||||
"research" => {
|
||||
if let (Some(ref mistral), Some(ref store)) = (&self.mistral, &self.store) {
|
||||
anyhow::bail!("research tool requires execute_research() — call with room + event_id context")
|
||||
} else {
|
||||
anyhow::bail!("Research not configured (missing mistral client or store)")
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("Unknown tool: {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a research tool call with full context (room, event_id for threads).
|
||||
pub async fn execute_research(
|
||||
self: &Arc<Self>,
|
||||
arguments: &str,
|
||||
response_ctx: &ResponseContext,
|
||||
room: &matrix_sdk::room::Room,
|
||||
event_id: &ruma::OwnedEventId,
|
||||
current_depth: usize,
|
||||
) -> anyhow::Result<String> {
|
||||
let mistral = self
|
||||
.mistral
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Research not configured: missing Mistral client"))?;
|
||||
let store = self
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Research not configured: missing store"))?;
|
||||
|
||||
research::execute(
|
||||
arguments,
|
||||
&self.config,
|
||||
mistral,
|
||||
self,
|
||||
response_ctx,
|
||||
room,
|
||||
event_id,
|
||||
store,
|
||||
current_depth,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user