diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 443c5da..438afa6 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -13,7 +13,7 @@ use domain::ports::{ AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, }; -use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; +use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use infrastructure::{TemplateToolProvider, TicketToolProvider}; use serde_json::Value; @@ -62,6 +62,22 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker { .map_or_else(Vec::new, |inner| inner.tools()) } + async fn tools_for_context( + &self, + project_root: &str, + requester: &str, + ) -> Result, ToolInvocationError> { + let inner = self + .inner + .lock() + .expect("mutex sain") + .clone() + .ok_or_else(|| { + ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned()) + })?; + inner.tools_for_context(project_root, requester).await + } + async fn call(&self, name: &str, args_json: &str) -> Result { let inner = self .inner @@ -102,14 +118,38 @@ impl ToolInvoker for AppOpenAiToolInvoker { fn tools(&self) -> Vec { infrastructure::orchestrator::mcp::catalogue() .into_iter() - .map(|tool| ToolSpec { - name: tool.name.to_owned(), - description: tool.description.to_owned(), - input_schema: tool.input_schema, - }) + .map(tool_def_to_spec) .collect() } + async fn tools_for_context( + &self, + project_root: &str, + requester: &str, + ) -> Result, ToolInvocationError> { + let ephemeral_policy = self.policies.get_policy(requester); + let project = self.find_project(project_root).await?; + let surface = infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface( + Some(self.mcp_tool_permissions.as_ref()), + &project, + requester, + ephemeral_policy.as_ref(), + ) + .await + .map_err(|e| { + ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}")) + })?; + Ok( + infrastructure::orchestrator::mcp::policy::effective_tool_catalogue( + ephemeral_policy.as_ref(), + &surface, + ) + .into_iter() + .map(tool_def_to_spec) + .collect(), + ) + } + async fn call(&self, name: &str, args_json: &str) -> Result { let value: Value = serde_json::from_str(args_json) .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; @@ -140,18 +180,7 @@ impl ToolInvoker for AppOpenAiToolInvoker { if let Some(policy) = &ephemeral_policy { enforce_policy(policy, &requester, name, &value)?; } - let project = self - .projects - .list_projects() - .await - .map_err(|e| ToolInvocationError::Execution(e.to_string()))? - .into_iter() - .find(|project| project.root.as_str() == project_root) - .ok_or_else(|| { - ToolInvocationError::Execution(format!( - "projet introuvable pour root `{project_root}`" - )) - })?; + let project = self.find_project(&project_root).await?; enforce_durable_tool_policy( self.mcp_tool_permissions.as_ref(), &project, @@ -209,6 +238,30 @@ impl ToolInvoker for AppOpenAiToolInvoker { } } +impl AppOpenAiToolInvoker { + async fn find_project(&self, project_root: &str) -> Result { + self.projects + .list_projects() + .await + .map_err(|e| ToolInvocationError::Execution(e.to_string()))? + .into_iter() + .find(|project| project.root.as_str() == project_root) + .ok_or_else(|| { + ToolInvocationError::Execution(format!( + "projet introuvable pour root `{project_root}`" + )) + }) + } +} + +fn tool_def_to_spec(tool: infrastructure::orchestrator::mcp::ToolDef) -> ToolSpec { + ToolSpec { + name: tool.name.to_owned(), + description: tool.description.to_owned(), + input_schema: tool.input_schema, + } +} + async fn enforce_durable_tool_policy( store: &dyn McpToolPermissionStore, project: &Project, @@ -239,38 +292,17 @@ async fn durable_tool_policy( ephemeral_policy: Option<&AgentToolPolicy>, requester: &str, ) -> Result, ToolInvocationError> { - let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); - let policy = if let Some(agent_id) = requester_agent_id(requester) { - let doc = store - .load_mcp_tool_permissions(project) - .await - .map_err(|e| { - ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}")) - })?; - doc.effective_policy( - agent_id, - infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS, - &known_tools, - ) - .map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))? - } else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() { - McpToolPolicy::read_only( - infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS, - &known_tools, - ) - .map_err(|e| { - ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}")) - })? - } else { - return Ok(None); - }; - Ok(Some(policy)) -} - -fn requester_agent_id(requester: &str) -> Option { - uuid::Uuid::parse_str(requester) - .ok() - .map(AgentId::from_uuid) + infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface( + Some(store), + project, + requester, + ephemeral_policy, + ) + .await + .map(|surface| surface.durable_policy) + .map_err(|e| { + ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}")) + }) } fn enforce_policy( @@ -643,6 +675,46 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + #[tokio::test] + async fn openai_tools_list_matches_durable_agent_policy() { + let temp = std::env::temp_dir().join(format!( + "idea-app-tauri-openai-mcp-tools-list-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(84)); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(allow_doc( + agent, + &["idea_memory_read", "idea_skill_read", "idea_create_skill"], + )), + Arc::new(FakeTicketTools::default()), + Arc::new(FakeTemplateTools::default()), + ); + + let names = invoker + .tools_for_context("/tmp/project", &agent.to_string()) + .await + .expect("tools list resolves") + .into_iter() + .map(|tool| tool.name) + .collect::>(); + + assert_eq!( + names, + vec![ + "idea_memory_read".to_owned(), + "idea_skill_read".to_owned(), + "idea_create_skill".to_owned(), + ] + ); + + let _ = std::fs::remove_dir_all(temp); + } + #[tokio::test] async fn openai_durable_agent_override_allows_explicit_write_tool() { let temp = std::env::temp_dir().join(format!( diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 58342bc..086e991 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -4967,59 +4967,14 @@ mod mcp_serve_peer_tests { assert_eq!(resp["id"], json!(1)); let tools = resp["result"]["tools"].as_array().expect("tools array"); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); - for expected in [ - "idea_list_agents", - "idea_ask_agent", - "idea_ask_agents", - "idea_run_in_background", - "idea_launch_agent", - "idea_stop_agent", - "idea_update_context", - "idea_create_skill", - // FileGuard-mediated context/memory tools (cadrage C7). - "idea_context_read", - "idea_context_propose", - "idea_memory_read", - "idea_memory_write", - // Skill-awareness : lecture à la demande du corps d'un skill. - "idea_skill_read", - // Conversation inter-agent headless : réponse inline capturée depuis le Final. - "idea_ask_agent", - // Live-state (programme live-state, lot LS4). - "idea_workstate_read", - "idea_workstate_set", - // Public ticket tools (Issue domain). - "idea_ticket_create", - "idea_ticket_read", - "idea_ticket_list", - "idea_ticket_update", - "idea_ticket_update_status", - "idea_ticket_update_priority", - "idea_ticket_bulk_update_status", - "idea_ticket_bulk_update_priority", - "idea_ticket_bulk_delete", - "idea_ticket_read_carnet", - "idea_ticket_update_carnet", - "idea_ticket_link", - "idea_ticket_unlink", - "idea_sprint_list", - // Public template tools. - "idea_template_list", - "idea_template_read", - "idea_template_create", - "idea_template_update", - "idea_template_delete", - ] { - assert!( - names.contains(&expected), - "missing tool {expected}; got {names:?}" - ); - } + let expected_names: Vec<&str> = infrastructure::orchestrator::mcp::catalogue() + .into_iter() + .map(|tool| tool.name) + .collect(); assert!(!names.contains(&"idea_reply")); assert_eq!( - tools.len(), - 34, - "exactly the thirty-four exposed idea_* tools; got {names:?}" + names, expected_names, + "tools/list must expose exactly the canonical MCP catalogue" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index e59e81b..939a612 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -13,7 +13,7 @@ use domain::ports::{ AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, }; -use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; +use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use infrastructure::{TemplateToolProvider, TicketToolProvider}; use serde_json::Value; @@ -62,6 +62,22 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker { .map_or_else(Vec::new, |inner| inner.tools()) } + async fn tools_for_context( + &self, + project_root: &str, + requester: &str, + ) -> Result, ToolInvocationError> { + let inner = self + .inner + .lock() + .expect("mutex sain") + .clone() + .ok_or_else(|| { + ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned()) + })?; + inner.tools_for_context(project_root, requester).await + } + async fn call(&self, name: &str, args_json: &str) -> Result { let inner = self .inner @@ -102,14 +118,38 @@ impl ToolInvoker for AppOpenAiToolInvoker { fn tools(&self) -> Vec { infrastructure::orchestrator::mcp::catalogue() .into_iter() - .map(|tool| ToolSpec { - name: tool.name.to_owned(), - description: tool.description.to_owned(), - input_schema: tool.input_schema, - }) + .map(tool_def_to_spec) .collect() } + async fn tools_for_context( + &self, + project_root: &str, + requester: &str, + ) -> Result, ToolInvocationError> { + let ephemeral_policy = self.policies.get_policy(requester); + let project = self.find_project(project_root).await?; + let surface = infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface( + Some(self.mcp_tool_permissions.as_ref()), + &project, + requester, + ephemeral_policy.as_ref(), + ) + .await + .map_err(|e| { + ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}")) + })?; + Ok( + infrastructure::orchestrator::mcp::policy::effective_tool_catalogue( + ephemeral_policy.as_ref(), + &surface, + ) + .into_iter() + .map(tool_def_to_spec) + .collect(), + ) + } + async fn call(&self, name: &str, args_json: &str) -> Result { let value: Value = serde_json::from_str(args_json) .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; @@ -140,18 +180,7 @@ impl ToolInvoker for AppOpenAiToolInvoker { if let Some(policy) = &ephemeral_policy { enforce_policy(policy, &requester, name, &value)?; } - let project = self - .projects - .list_projects() - .await - .map_err(|e| ToolInvocationError::Execution(e.to_string()))? - .into_iter() - .find(|project| project.root.as_str() == project_root) - .ok_or_else(|| { - ToolInvocationError::Execution(format!( - "projet introuvable pour root `{project_root}`" - )) - })?; + let project = self.find_project(&project_root).await?; enforce_durable_tool_policy( self.mcp_tool_permissions.as_ref(), &project, @@ -209,6 +238,30 @@ impl ToolInvoker for AppOpenAiToolInvoker { } } +impl AppOpenAiToolInvoker { + async fn find_project(&self, project_root: &str) -> Result { + self.projects + .list_projects() + .await + .map_err(|e| ToolInvocationError::Execution(e.to_string()))? + .into_iter() + .find(|project| project.root.as_str() == project_root) + .ok_or_else(|| { + ToolInvocationError::Execution(format!( + "projet introuvable pour root `{project_root}`" + )) + }) + } +} + +fn tool_def_to_spec(tool: infrastructure::orchestrator::mcp::ToolDef) -> ToolSpec { + ToolSpec { + name: tool.name.to_owned(), + description: tool.description.to_owned(), + input_schema: tool.input_schema, + } +} + async fn enforce_durable_tool_policy( store: &dyn McpToolPermissionStore, project: &Project, @@ -239,38 +292,17 @@ async fn durable_tool_policy( ephemeral_policy: Option<&AgentToolPolicy>, requester: &str, ) -> Result, ToolInvocationError> { - let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); - let policy = if let Some(agent_id) = requester_agent_id(requester) { - let doc = store - .load_mcp_tool_permissions(project) - .await - .map_err(|e| { - ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}")) - })?; - doc.effective_policy( - agent_id, - infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS, - &known_tools, - ) - .map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))? - } else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() { - McpToolPolicy::read_only( - infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS, - &known_tools, - ) - .map_err(|e| { - ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}")) - })? - } else { - return Ok(None); - }; - Ok(Some(policy)) -} - -fn requester_agent_id(requester: &str) -> Option { - uuid::Uuid::parse_str(requester) - .ok() - .map(AgentId::from_uuid) + infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface( + Some(store), + project, + requester, + ephemeral_policy, + ) + .await + .map(|surface| surface.durable_policy) + .map_err(|e| { + ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}")) + }) } #[cfg(test)] @@ -796,6 +828,42 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + #[tokio::test] + async fn openai_tools_list_matches_durable_agent_policy() { + let temp = + std::env::temp_dir().join(format!("idea-openai-mcp-tools-list-{}", Uuid::new_v4())); + let core = crate::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(182)); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(allow_doc( + agent, + &["idea_ticket_list", "idea_ticket_update_carnet"], + )), + Arc::new(FakeTicketTools::default()), + Arc::new(FakeTemplateTools::default()), + ); + + let tools = invoker + .tools_for_context("/tmp/project", &agent.to_string()) + .await + .expect("tools list should resolve durable policy"); + let names = tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + + assert!(names.contains(&"idea_ticket_list")); + assert!(names.contains(&"idea_ticket_update_carnet")); + assert!(!names.contains(&"idea_ask_agent")); + assert!(!names.contains(&"idea_ticket_update")); + assert!(!names.contains(&"idea_run_in_background")); + + let _ = std::fs::remove_dir_all(temp); + } + #[tokio::test] async fn openai_durable_agent_override_allows_explicit_write_tool() { let temp = diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 8ee845b..e10b22c 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -660,6 +660,29 @@ pub trait ToolInvoker: Send + Sync { /// Liste des outils exposés au modèle. fn tools(&self) -> Vec; + /// Liste des outils exposés dans le contexte déjà lié par l'adapter appelant. + /// + /// Les invokers simples peuvent conserver le comportement historique via + /// [`Self::tools`]. Les wrappers qui portent déjà l'identité requester et le + /// projet peuvent surcharger cette méthode pour refléter les permissions + /// effectives au moment où les outils sont exposés au modèle. + async fn tools_for_bound_context(&self) -> Result, ToolInvocationError> { + Ok(self.tools()) + } + + /// Liste des outils exposés pour un projet et un requester explicites. + /// + /// Sert aux adapters qui savent résoudre la policy durable à partir du root + /// projet et de l'identité agent avant d'injecter les tools dans une requête + /// OpenAI-compatible. + async fn tools_for_context( + &self, + _project_root: &str, + _requester: &str, + ) -> Result, ToolInvocationError> { + Ok(self.tools()) + } + /// Appelle un outil avec ses arguments JSON bruts. /// /// # Errors diff --git a/crates/infrastructure/src/orchestrator/mcp/policy.rs b/crates/infrastructure/src/orchestrator/mcp/policy.rs index 77baa83..f135470 100644 --- a/crates/infrastructure/src/orchestrator/mcp/policy.rs +++ b/crates/infrastructure/src/orchestrator/mcp/policy.rs @@ -1,10 +1,12 @@ -//! In-memory MCP tool policy registry. +//! MCP tool policy helpers and in-memory live policy registry. use std::collections::HashMap; use std::sync::RwLock; -use domain::AgentToolPolicy; -use domain::AgentToolPolicyStore; +use domain::ports::McpToolPermissionStore; +use domain::{AgentId, AgentToolPolicy, AgentToolPolicyStore, McpToolPolicy, Project, StoreError}; + +use super::tools::{self, ToolDef}; /// Stores per-requester MCP tool policies for live assistant sessions. #[derive(Default)] @@ -53,6 +55,106 @@ impl AgentToolPolicyStore for ToolPolicyRegistry { } } +/// Effective MCP tool surface resolved for one requester. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveToolSurface { + /// Durable MCP policy when one applies. + pub durable_policy: Option, + /// Whether the read-only fallback was used because the requester could not be + /// resolved to an agent policy. + pub used_read_only_fallback: bool, +} + +impl EffectiveToolSurface { + /// Returns whether `tool` is allowed by the durable policy, when present. + #[must_use] + pub fn permits_durable(&self, tool: &str) -> bool { + self.durable_policy + .as_ref() + .map_or(true, |policy| policy.permits(tool)) + } +} + +/// Resolves the durable MCP tool policy for the given requester. +/// +/// This is the shared source of truth for the surfaces that expose IdeA tools to +/// agents. Agent UUID requesters use `.ideai/mcp-tool-permissions.json`; +/// anonymous/legacy requesters and non-agent requesters without an ephemeral +/// policy fall back to the canonical read-only policy. Non-agent requesters with +/// an ephemeral policy (ticket assistants) are intentionally governed only by +/// that narrower session policy. +/// +/// # Errors +/// [`StoreError`] on store load failure or invalid persisted policy. +pub async fn resolve_effective_tool_surface( + store: Option<&dyn McpToolPermissionStore>, + project: &Project, + requester: &str, + ephemeral_policy: Option<&AgentToolPolicy>, +) -> Result { + let Some(store) = store else { + return Ok(EffectiveToolSurface { + durable_policy: None, + used_read_only_fallback: false, + }); + }; + + let known_tools = tools::classified_tool_names(); + if let Some(agent_id) = requester_agent_id(requester) { + let doc = store.load_mcp_tool_permissions(project).await?; + let policy = doc + .effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools) + .map_err(|err| StoreError::Invalid(err.to_string()))?; + return Ok(EffectiveToolSurface { + durable_policy: Some(policy), + used_read_only_fallback: false, + }); + } + + if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() { + application::diag!( + "[mcp] unresolved requester `{}` uses read-only tool fallback", + if requester.is_empty() { + "mcp" + } else { + requester + }, + ); + let policy = McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools) + .map_err(|err| StoreError::Invalid(err.to_string()))?; + return Ok(EffectiveToolSurface { + durable_policy: Some(policy), + used_read_only_fallback: true, + }); + } + + Ok(EffectiveToolSurface { + durable_policy: None, + used_read_only_fallback: false, + }) +} + +/// Filters the canonical catalogue by the effective ephemeral + durable policies. +#[must_use] +pub fn effective_tool_catalogue( + ephemeral_policy: Option<&AgentToolPolicy>, + surface: &EffectiveToolSurface, +) -> Vec { + tools::catalogue() + .into_iter() + .filter(|tool| { + ephemeral_policy.map_or(true, |policy| policy.permits(tool.name)) + && surface.permits_durable(tool.name) + }) + .collect() +} + +fn requester_agent_id(requester: &str) -> Option { + uuid::Uuid::parse_str(requester) + .ok() + .map(AgentId::from_uuid) +} + #[cfg(test)] mod tests { use domain::IssueRef; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index bd3605f..d1038af 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -347,25 +347,20 @@ impl McpServer { /// The `tools/list` result: the catalogue as MCP tool descriptors. async fn tools_list_result(&self) -> Result { let ephemeral_policy = self.ephemeral_tool_policy(); - let durable_policy = self.durable_tool_policy().await?; - let tools: Vec = tools::catalogue() - .into_iter() - .filter(|t| { - ephemeral_policy - .as_ref() - .map_or(true, |policy| policy.permits(t.name)) - && durable_policy - .as_ref() - .map_or(true, |policy| policy.permits(t.name)) - }) - .map(|t| { - json!({ - "name": t.name, - "description": t.description, - "inputSchema": t.input_schema, + let surface = self + .effective_tool_surface(ephemeral_policy.as_ref()) + .await?; + let tools: Vec = + super::policy::effective_tool_catalogue(ephemeral_policy.as_ref(), &surface) + .into_iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema, + }) }) - }) - .collect(); + .collect(); Ok(json!({ "tools": tools })) } @@ -376,53 +371,30 @@ impl McpServer { } async fn durable_tool_policy(&self) -> Result, JsonRpcError> { - let Some(store) = &self.mcp_tool_permissions else { - return Ok(None); - }; - - let known_tools = tools::classified_tool_names(); - let policy = if let Some(agent_id) = self.requester_agent_id() { - let doc = store - .load_mcp_tool_permissions(&self.project) - .await - .map_err(|e| { - JsonRpcError::new( - error_codes::INTERNAL_ERROR, - format!("failed to load MCP tool permissions: {e}"), - ) - })?; - doc.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools) - .map_err(|e| { - JsonRpcError::new( - error_codes::INTERNAL_ERROR, - format!("invalid MCP tool permissions: {e}"), - ) - })? - } else if self.requester.is_empty() - || self.requester == "mcp" - || self.ephemeral_tool_policy().is_none() - { - // Anonymous/legacy peers cannot be mapped to an agent override. Fail - // closed to the canonical read-only policy. Non-agent requesters with an - // ephemeral policy (ticket assistants) are governed by that narrower, - // session-scoped policy instead of the durable per-agent store. - McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools).map_err(|e| { - JsonRpcError::new( - error_codes::INTERNAL_ERROR, - format!("invalid read-only MCP tool fallback: {e}"), - ) - })? - } else { - return Ok(None); - }; - - Ok(Some(policy)) + let ephemeral_policy = self.ephemeral_tool_policy(); + Ok(self + .effective_tool_surface(ephemeral_policy.as_ref()) + .await? + .durable_policy) } - fn requester_agent_id(&self) -> Option { - uuid::Uuid::parse_str(&self.requester) - .ok() - .map(AgentId::from_uuid) + async fn effective_tool_surface( + &self, + ephemeral_policy: Option<&AgentToolPolicy>, + ) -> Result { + super::policy::resolve_effective_tool_surface( + self.mcp_tool_permissions.as_deref(), + &self.project, + &self.requester, + ephemeral_policy, + ) + .await + .map_err(|e| { + JsonRpcError::new( + error_codes::INTERNAL_ERROR, + format!("failed to resolve MCP tool permissions: {e}"), + ) + }) } fn enforce_durable_tool_policy( diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 2252afe..3a0395a 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -41,6 +41,12 @@ impl ToolInvoker for ProjectScopedToolInvoker { self.inner.tools() } + async fn tools_for_bound_context(&self) -> Result, ToolInvocationError> { + self.inner + .tools_for_context(&self.project_root, &self.requester) + .await + } + async fn call(&self, name: &str, args_json: &str) -> Result { let mut value: Value = serde_json::from_str(args_json) .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; @@ -257,6 +263,7 @@ mod tests { #[derive(Default)] struct RecordingToolInvoker { call: Mutex>, + tools_context: Mutex>, } #[async_trait] @@ -265,6 +272,20 @@ mod tests { Vec::new() } + async fn tools_for_context( + &self, + project_root: &str, + requester: &str, + ) -> Result, ToolInvocationError> { + *self.tools_context.lock().unwrap() = + Some((project_root.to_owned(), requester.to_owned())); + Ok(vec![ToolSpec { + name: "idea_memory_read".to_owned(), + description: "Read memory".to_owned(), + input_schema: json!({"type":"object"}), + }]) + } + async fn call(&self, name: &str, args_json: &str) -> Result { *self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned())); Ok("ok".to_owned()) @@ -299,6 +320,28 @@ mod tests { ); } + #[tokio::test] + async fn project_scoped_tool_invoker_filters_tools_with_bound_context() { + let recorder = Arc::new(RecordingToolInvoker::default()); + let invoker = ProjectScopedToolInvoker { + inner: recorder.clone(), + project_root: "/project/root".to_owned(), + requester: "agent-1".to_owned(), + }; + + let tools = invoker + .tools_for_bound_context() + .await + .expect("tools resolve"); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "idea_memory_read"); + assert_eq!( + recorder.tools_context.lock().unwrap().clone(), + Some(("/project/root".to_owned(), "agent-1".to_owned())) + ); + } + #[test] fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() { assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7"); diff --git a/crates/infrastructure/src/session/openai_compat.rs b/crates/infrastructure/src/session/openai_compat.rs index 81ab2cf..dc30672 100644 --- a/crates/infrastructure/src/session/openai_compat.rs +++ b/crates/infrastructure/src/session/openai_compat.rs @@ -281,7 +281,7 @@ impl OpenAiCompatibleSession { send_tap(&tap, &ReplyEvent::Heartbeat); for iteration in 0..=self.config.effective_max_tool_iterations() { let transcript = self.transcript.lock().expect("mutex sain").clone(); - let tools = self.effective_tools(); + let tools = self.effective_tools().await?; let response = self.post_chat(&transcript, &tools, true).await; let response = match response { Ok(response) => response, @@ -330,13 +330,17 @@ impl OpenAiCompatibleSession { unreachable!("loop returns at max_tool_iterations"); } - fn effective_tools(&self) -> Vec { + async fn effective_tools(&self) -> Result, AgentSessionError> { if *self.tools_disabled.lock().expect("mutex sain") { - return Vec::new(); + return Ok(Vec::new()); + } + match &self.tool_invoker { + Some(invoker) => invoker + .tools_for_bound_context() + .await + .map_err(|e| AgentSessionError::Start(format!("résolution tools: {e}"))), + None => Ok(Vec::new()), } - self.tool_invoker - .as_ref() - .map_or_else(Vec::new, |invoker| invoker.tools()) } async fn post_chat(