diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 9628a12..d8ed44e 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -19,12 +19,14 @@ use application::{ LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, - ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, - SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, - UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, + ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, + ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, + UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, + UpdateProjectPermissionsInput, UpdateSkillInput, }; use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; @@ -49,17 +51,19 @@ use crate::dto::{ LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, - ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, - RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, - ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, - SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, - UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, - UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, + ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, + SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, + SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, + StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, + SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, + TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, + UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::embedded_server::{ EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, @@ -544,6 +548,68 @@ pub async fn resolve_agent_permissions( .map_err(ErrorDto::from) } +/// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid project id, invalid stored policy, or store failure. +#[tauri::command] +pub async fn get_mcp_tool_permissions( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .read_mcp_tool_permissions + .execute(ReadMcpToolPermissionsInput { project }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from) +} + +/// `update_project_mcp_tool_permissions` — replace or remove project MCP tool defaults. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid project id, invalid tool policy, or store failure. +#[tauri::command] +pub async fn update_project_mcp_tool_permissions( + request: UpdateProjectMcpToolPermissionsRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .update_project_mcp_tool_permissions + .execute(UpdateProjectMcpToolPermissionsInput { + project, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from) +} + +/// `update_agent_mcp_tool_permissions` — replace or remove one agent MCP tool override. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid ids, invalid tool policy, or store failure. +#[tauri::command] +pub async fn update_agent_mcp_tool_permissions( + request: UpdateAgentMcpToolPermissionsRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .update_agent_mcp_tool_permissions + .execute(UpdateAgentMcpToolPermissionsInput { + project, + agent_id, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from) +} + // --------------------------------------------------------------------------- // Terminals (L3) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index ba7e0f4..35b1611 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -176,6 +176,9 @@ pub fn run() { commands::update_project_permissions, commands::update_agent_permissions, commands::resolve_agent_permissions, + commands::get_mcp_tool_permissions, + commands::update_project_mcp_tool_permissions, + commands::update_agent_mcp_tool_permissions, commands::open_terminal, commands::write_terminal, commands::resize_terminal, diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index c9c35ab..1b9e509 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -10,9 +10,10 @@ use std::sync::{Arc, Mutex}; use application::OrchestratorService; use async_trait::async_trait; use domain::ports::{ - AgentToolPolicyStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, + AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker, + ToolSpec, }; -use domain::{AgentToolPolicy, IssueRef}; +use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use infrastructure::TicketToolProvider; use serde_json::Value; @@ -24,6 +25,7 @@ pub struct AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, policies: Arc, + mcp_tool_permissions: Arc, ticket_tools: Arc, } @@ -79,12 +81,14 @@ impl AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, policies: Arc, + mcp_tool_permissions: Arc, ticket_tools: Arc, ) -> Self { Self { orchestrator, projects, policies, + mcp_tool_permissions, ticket_tools, } } @@ -129,7 +133,10 @@ impl ToolInvoker for AppOpenAiToolInvoker { "identité requester interne absente pour l'outil".to_owned(), ) })?; - enforce_tool_policy(self.policies.as_ref(), &requester, name, &value)?; + let ephemeral_policy = self.policies.get_policy(&requester); + if let Some(policy) = &ephemeral_policy { + enforce_policy(policy, &requester, name, &value)?; + } let project = self .projects .list_projects() @@ -142,6 +149,14 @@ impl ToolInvoker for AppOpenAiToolInvoker { "projet introuvable pour root `{project_root}`" )) })?; + enforce_durable_tool_policy( + self.mcp_tool_permissions.as_ref(), + &project, + ephemeral_policy.as_ref(), + &requester, + name, + ) + .await?; if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) { let value = self .ticket_tools @@ -178,16 +193,68 @@ impl ToolInvoker for AppOpenAiToolInvoker { } } -fn enforce_tool_policy( - policies: &dyn AgentToolPolicyStore, +async fn enforce_durable_tool_policy( + store: &dyn McpToolPermissionStore, + project: &Project, + ephemeral_policy: Option<&AgentToolPolicy>, requester: &str, name: &str, - arguments: &Value, ) -> Result<(), ToolInvocationError> { - let Some(policy) = policies.get_policy(requester) else { + let Some(policy) = durable_tool_policy(store, project, ephemeral_policy, requester).await? + else { return Ok(()); }; - enforce_policy(&policy, requester, name, arguments) + if policy.permits(name) { + return Ok(()); + } + let requester = if requester.is_empty() { + "mcp" + } else { + requester + }; + Err(ToolInvocationError::Rejected(format!( + "MCP tool `{name}` is not permitted for requester {requester}" + ))) +} + +async fn durable_tool_policy( + store: &dyn McpToolPermissionStore, + project: &Project, + 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) } fn enforce_policy( @@ -233,3 +300,360 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { | "idea_ticket_unlink" ) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::str::FromStr; + use std::sync::{Arc, Mutex}; + + use domain::ports::{AgentToolPolicyStore, McpToolPermissionStore, ProjectStore}; + use domain::{ + AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, + ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, + }; + use infrastructure::TicketToolError; + use serde_json::json; + use uuid::Uuid; + + use super::*; + + #[derive(Default)] + struct FakePolicies(Mutex>); + + impl AgentToolPolicyStore for FakePolicies { + fn set_policy(&self, requester: String, policy: AgentToolPolicy) { + self.0.lock().unwrap().insert(requester, policy); + } + + fn get_policy(&self, requester: &str) -> Option { + self.0.lock().unwrap().get(requester).cloned() + } + + fn clear_policy(&self, requester: &str) { + self.0.lock().unwrap().remove(requester); + } + } + + #[derive(Default)] + struct FakeProjects { + projects: Mutex>, + } + + impl FakeProjects { + fn with(project: Project) -> Self { + Self { + projects: Mutex::new(vec![project]), + } + } + } + + #[async_trait] + impl ProjectStore for FakeProjects { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.projects.lock().unwrap().clone()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.projects + .lock() + .unwrap() + .iter() + .find(|project| project.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.projects.lock().unwrap().push(project.clone()); + Ok(()) + } + + async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(Workspace::default()) + } + } + + struct FakeMcpToolPermissions { + doc: Mutex, + } + + impl FakeMcpToolPermissions { + fn new(doc: ProjectMcpToolPermissions) -> Self { + Self { + doc: Mutex::new(doc), + } + } + } + + #[async_trait] + impl McpToolPermissionStore for FakeMcpToolPermissions { + async fn load_mcp_tool_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.doc.lock().unwrap().clone()) + } + + async fn save_mcp_tool_permissions( + &self, + _project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + *self.doc.lock().unwrap() = permissions.clone(); + Ok(()) + } + } + + #[derive(Default)] + struct FakeTicketTools { + calls: Mutex>, + } + + #[async_trait] + impl TicketToolProvider for FakeTicketTools { + async fn handle_ticket_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + self.calls.lock().unwrap().push(( + requester.to_owned(), + name.to_owned(), + arguments.clone(), + )); + Ok(json!({ + "ok": true, + "requester": requester, + "ref": arguments.get("ref").and_then(Value::as_str).unwrap_or_default(), + })) + } + } + + fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/project").unwrap(), + RemoteRef::local(), + 1_000, + ) + .unwrap() + } + + fn mcp_permissions(doc: ProjectMcpToolPermissions) -> Arc { + Arc::new(FakeMcpToolPermissions::new(doc)) + } + + fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions { + let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); + ProjectMcpToolPermissions::new( + None, + vec![AgentMcpToolPolicyOverride::new( + agent, + McpToolPolicy::new( + allowed_tools + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + &known_tools, + ) + .unwrap(), + )], + &known_tools, + ) + .unwrap() + } + + fn issue_ref(raw: &str) -> IssueRef { + IssueRef::from_str(raw).unwrap() + } + + #[tokio::test] + async fn openai_general_agent_without_durable_override_is_read_only() { + let temp = std::env::temp_dir().join(format!( + "idea-app-tauri-openai-mcp-permissions-readonly-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(ProjectMcpToolPermissions::default()), + ticket_tools.clone(), + ); + + invoker + .call( + "idea_ticket_list", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + }) + .to_string(), + ) + .await + .expect("read tool must pass the durable read-only policy"); + + for (tool, arguments) in [ + ( + "idea_memory_write", + json!({ "slug": "note-a", "content": "body" }), + ), + ( + "idea_ask_agent", + json!({ "target": "architect", "task": "do it" }), + ), + ( + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + ), + ( + "idea_run_in_background", + json!({ "label": "task", "command": "echo" }), + ), + ] { + let mut payload = arguments.as_object().unwrap().clone(); + payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); + payload.insert(REQUESTER_ARG.to_owned(), json!(requester.clone())); + let err = invoker + .call(tool, &Value::Object(payload).to_string()) + .await + .expect_err("write tool must be rejected without durable override"); + assert!( + matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)), + "expected readable rejection for {tool}, got {err:?}" + ); + } + + let calls = ticket_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "only the read ticket tool should run"); + assert_eq!(calls[0].1, "idea_ticket_list"); + + 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!( + "idea-app-tauri-openai-mcp-permissions-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(83)); + let requester = agent.to_string(); + let ticket_tools = Arc::new(FakeTicketTools::default()); + 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_update_carnet"])), + ticket_tools.clone(), + ); + + let result = invoker + .call( + "idea_ticket_update_carnet", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "ref": "#7", + "expectedVersion": 1, + "carnet": "body", + }) + .to_string(), + ) + .await + .expect("durable override should let the write tool reach the provider"); + let result: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["requester"], requester); + assert_eq!(result["ref"], "#7"); + + let calls = ticket_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "idea_ticket_update_carnet"); + + let _ = std::fs::remove_dir_all(temp); + } + + #[tokio::test] + async fn openai_ticket_assistant_policy_still_bounds_ticket() { + let temp = std::env::temp_dir().join(format!( + "idea-app-tauri-openai-ticket-policy-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let requester = "ticket-assistant:00000000000000000000000000000001:7"; + let policies = Arc::new(FakePolicies::default()); + policies.set_policy( + requester.to_owned(), + AgentToolPolicy::new( + vec!["idea_ticket_update_carnet".to_owned()], + Some(issue_ref("#7")), + true, + ), + ); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + policies, + mcp_permissions(ProjectMcpToolPermissions::default()), + ticket_tools.clone(), + ); + + let denied = invoker + .call( + "idea_ticket_update_carnet", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "ref": "#8", + "expectedVersion": 1, + "carnet": "denied", + }) + .to_string(), + ) + .await + .expect_err("out-of-scope ticket must be rejected"); + assert!(matches!(denied, ToolInvocationError::Rejected(message) if message.contains("#8"))); + assert!( + ticket_tools.calls.lock().unwrap().is_empty(), + "ticket policy rejection happens before TicketToolProvider dispatch" + ); + + let allowed = invoker + .call( + "idea_ticket_update_carnet", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "ref": "#7", + "expectedVersion": 1, + "carnet": "allowed", + }) + .to_string(), + ) + .await + .expect("bound ticket update allowed"); + let allowed: Value = serde_json::from_str(&allowed).unwrap(); + assert_eq!(allowed["requester"], requester); + assert_eq!(allowed["ref"], "#7"); + + let calls = ticket_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, requester); + assert_eq!(calls[0].1, "idea_ticket_update_carnet"); + + let _ = std::fs::remove_dir_all(temp); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 0105aed..46ce815 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -22,6 +22,7 @@ pub mod git; pub mod health; pub mod issues; pub mod layout; +pub mod mcp_tool_permissions; pub mod memory; pub mod model_server; pub mod orchestrator; @@ -105,6 +106,12 @@ pub use layout::{ SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE, }; +pub use mcp_tool_permissions::{ + McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput, + ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions, + UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions, + UpdateProjectMcpToolPermissionsInput, +}; pub use memory::{ CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories, diff --git a/crates/application/src/mcp_tool_permissions.rs b/crates/application/src/mcp_tool_permissions.rs new file mode 100644 index 0000000..2e5a398 --- /dev/null +++ b/crates/application/src/mcp_tool_permissions.rs @@ -0,0 +1,402 @@ +//! MCP tool permission use cases. +//! +//! These use cases expose the durable per-project MCP tool policy document while +//! keeping the application layer independent from the concrete MCP catalogue +//! adapter. The catalogue classification is injected by the composition root. + +use std::collections::HashSet; +use std::sync::Arc; + +use domain::ports::McpToolPermissionStore; +use domain::{ + AgentId, AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, Project, + ProjectMcpToolPermissions, +}; + +use crate::error::AppError; + +/// Read/write MCP tool catalogue classification used to validate policies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpToolPermissionCatalogue { + /// Tools allowed by the default fallback policy. + pub read_only_tools: Vec, + /// Tools denied by default because they write, act, or execute. + pub write_action_tools: Vec, +} + +impl McpToolPermissionCatalogue { + /// Builds a catalogue from read-only and write/action tool names. + /// + /// # Errors + /// [`AppError::Invalid`] if a tool name is empty or appears twice. + pub fn new( + read_only_tools: Vec, + write_action_tools: Vec, + ) -> Result { + let catalogue = Self { + read_only_tools, + write_action_tools, + }; + catalogue.validate()?; + Ok(catalogue) + } + + /// Returns all known tool names as borrowed strings. + #[must_use] + pub fn known_tool_refs(&self) -> Vec<&str> { + self.read_only_tools + .iter() + .chain(self.write_action_tools.iter()) + .map(String::as_str) + .collect() + } + + fn validate(&self) -> Result<(), AppError> { + let mut seen = HashSet::new(); + for tool in self + .read_only_tools + .iter() + .chain(self.write_action_tools.iter()) + { + if tool.is_empty() { + return Err(AppError::Invalid( + "MCP tool name cannot be empty".to_owned(), + )); + } + if !seen.insert(tool.as_str()) { + return Err(AppError::Invalid(format!("duplicate MCP tool: {tool}"))); + } + } + Ok(()) + } +} + +/// Reads durable MCP tool permissions for a project. +pub struct ReadMcpToolPermissions { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl ReadMcpToolPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Executes the read. + /// + /// # Errors + /// [`AppError`] on store or validation failure. + pub async fn execute( + &self, + input: ReadMcpToolPermissionsInput, + ) -> Result { + let permissions = self.store.load_mcp_tool_permissions(&input.project).await?; + validate_doc(&permissions, &self.catalogue)?; + Ok(ReadMcpToolPermissionsOutput { + catalogue: self.catalogue.clone(), + permissions, + }) + } +} + +/// Input for [`ReadMcpToolPermissions`]. +pub struct ReadMcpToolPermissionsInput { + /// Target project. + pub project: Project, +} + +/// Output for MCP tool permission reads and writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadMcpToolPermissionsOutput { + /// Canonical catalogue classification. + pub catalogue: McpToolPermissionCatalogue, + /// Persisted permission document. + pub permissions: ProjectMcpToolPermissions, +} + +/// Replaces the project-wide default MCP tool policy. +pub struct UpdateProjectMcpToolPermissions { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl UpdateProjectMcpToolPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Executes the mutation. + /// + /// # Errors + /// [`AppError`] on store or validation failure. + pub async fn execute( + &self, + input: UpdateProjectMcpToolPermissionsInput, + ) -> Result { + validate_policy(input.policy.as_ref(), &self.catalogue)?; + let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; + doc.project_default = input.policy; + validate_doc(&doc, &self.catalogue)?; + self.store + .save_mcp_tool_permissions(&input.project, &doc) + .await?; + Ok(ReadMcpToolPermissionsOutput { + catalogue: self.catalogue.clone(), + permissions: doc, + }) + } +} + +/// Input for [`UpdateProjectMcpToolPermissions`]. +pub struct UpdateProjectMcpToolPermissionsInput { + /// Target project. + pub project: Project, + /// New project default policy. `None` removes the project default. + pub policy: Option, +} + +/// Replaces or removes one agent MCP tool policy override. +pub struct UpdateAgentMcpToolPermissions { + store: Arc, + catalogue: McpToolPermissionCatalogue, +} + +impl UpdateAgentMcpToolPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + catalogue: McpToolPermissionCatalogue, + ) -> Self { + Self { store, catalogue } + } + + /// Executes the mutation. + /// + /// # Errors + /// [`AppError`] on store or validation failure. + pub async fn execute( + &self, + input: UpdateAgentMcpToolPermissionsInput, + ) -> Result { + validate_policy(input.policy.as_ref(), &self.catalogue)?; + let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; + doc.agents + .retain(|override_| override_.agent_id != input.agent_id); + if let Some(policy) = input.policy { + doc.agents + .push(AgentMcpToolPolicyOverride::new(input.agent_id, policy)); + } + validate_doc(&doc, &self.catalogue)?; + self.store + .save_mcp_tool_permissions(&input.project, &doc) + .await?; + Ok(ReadMcpToolPermissionsOutput { + catalogue: self.catalogue.clone(), + permissions: doc, + }) + } +} + +/// Input for [`UpdateAgentMcpToolPermissions`]. +pub struct UpdateAgentMcpToolPermissionsInput { + /// Target project. + pub project: Project, + /// Target agent. + pub agent_id: AgentId, + /// New override policy. `None` removes the override. + pub policy: Option, +} + +fn validate_policy( + policy: Option<&McpToolPolicy>, + catalogue: &McpToolPermissionCatalogue, +) -> Result<(), AppError> { + if let Some(policy) = policy { + policy + .validate(&catalogue.known_tool_refs()) + .map_err(permission_error)?; + } + Ok(()) +} + +fn validate_doc( + doc: &ProjectMcpToolPermissions, + catalogue: &McpToolPermissionCatalogue, +) -> Result<(), AppError> { + doc.validate(&catalogue.known_tool_refs()) + .map_err(permission_error) +} + +fn permission_error(error: McpToolPermissionError) -> AppError { + AppError::Invalid(error.to_string()) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use async_trait::async_trait; + use domain::ids::ProjectId; + use domain::ports::StoreError; + use domain::project::ProjectPath; + use domain::remote::RemoteRef; + use uuid::Uuid; + + use super::*; + + struct FakeStore { + doc: Mutex, + } + + impl FakeStore { + fn new(doc: ProjectMcpToolPermissions) -> Self { + Self { + doc: Mutex::new(doc), + } + } + } + + #[async_trait] + impl McpToolPermissionStore for FakeStore { + async fn load_mcp_tool_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.doc.lock().unwrap().clone()) + } + + async fn save_mcp_tool_permissions( + &self, + _project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + *self.doc.lock().unwrap() = permissions.clone(); + Ok(()) + } + } + + fn catalogue() -> McpToolPermissionCatalogue { + McpToolPermissionCatalogue::new( + vec!["idea_memory_read".to_owned(), "idea_ticket_list".to_owned()], + vec!["idea_memory_write".to_owned(), "idea_ask_agent".to_owned()], + ) + .unwrap() + } + + fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/project").unwrap(), + RemoteRef::local(), + 1_000, + ) + .unwrap() + } + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) + } + + fn store(doc: ProjectMcpToolPermissions) -> Arc { + Arc::new(FakeStore::new(doc)) + } + + #[tokio::test] + async fn read_returns_classified_catalogue_and_current_document() { + let policy = McpToolPolicy::new( + vec!["idea_memory_write".to_owned()], + &catalogue().known_tool_refs(), + ) + .unwrap(); + let doc = ProjectMcpToolPermissions::new( + Some(policy), + Vec::new(), + &catalogue().known_tool_refs(), + ) + .unwrap(); + let use_case = ReadMcpToolPermissions::new(store(doc.clone()), catalogue()); + + let output = use_case + .execute(ReadMcpToolPermissionsInput { project: project() }) + .await + .unwrap(); + + assert_eq!(output.permissions, doc); + assert_eq!( + output.catalogue.read_only_tools, + vec!["idea_memory_read", "idea_ticket_list"] + ); + assert_eq!( + output.catalogue.write_action_tools, + vec!["idea_memory_write", "idea_ask_agent"] + ); + } + + #[tokio::test] + async fn update_rejects_unknown_tool() { + let use_case = UpdateAgentMcpToolPermissions::new( + store(ProjectMcpToolPermissions::default()), + catalogue(), + ); + + let err = use_case + .execute(UpdateAgentMcpToolPermissionsInput { + project: project(), + agent_id: agent(7), + policy: Some(McpToolPolicy { + allowed_tools: vec!["idea_unknown".to_owned()], + }), + }) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::Invalid(message) if message.contains("idea_unknown"))); + } + + #[tokio::test] + async fn update_agent_override_roundtrips_through_read() { + let shared = store(ProjectMcpToolPermissions::default()); + let update = UpdateAgentMcpToolPermissions::new(Arc::clone(&shared), catalogue()); + let read = ReadMcpToolPermissions::new(Arc::clone(&shared), catalogue()); + let agent_id = agent(9); + + update + .execute(UpdateAgentMcpToolPermissionsInput { + project: project(), + agent_id, + policy: Some( + McpToolPolicy::new( + vec!["idea_memory_write".to_owned()], + &catalogue().known_tool_refs(), + ) + .unwrap(), + ), + }) + .await + .unwrap(); + + let output = read + .execute(ReadMcpToolPermissionsInput { project: project() }) + .await + .unwrap(); + + assert_eq!(output.permissions.agents.len(), 1); + assert_eq!(output.permissions.agents[0].agent_id, agent_id); + assert_eq!( + output.permissions.agents[0].policy.allowed_tools, + vec!["idea_memory_write"] + ); + } +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 87a09bf..067005b 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1479,9 +1479,12 @@ pub fn parse_profile_id(raw: &str) -> Result { use application::{ ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, - ListAgentsOutput, ReadAgentContextOutput, + ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput, +}; +use domain::{ + Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet, + ProjectPermissions, TerminalSession, }; -use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; /// An agent crossing the wire. [`Agent`] already serialises camelCase /// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), @@ -1564,6 +1567,44 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions); #[serde(transparent)] pub struct EffectivePermissionsDto(pub EffectivePermissions); +/// Canonical MCP tool catalogue classification crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolCatalogueDto { + /// Tools allowed by the default read-only fallback. + pub read_only_tools: Vec, + /// Tools treated as writing/action/execution tools. + pub write_action_tools: Vec, +} + +/// Full MCP tool permission state crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMcpToolPermissionsDto { + /// Document format version. + pub version: u32, + /// Canonical catalogue classification used for validation and display. + pub catalogue: McpToolCatalogueDto, + /// Optional project-wide default MCP tool policy. + pub project_default: Option, + /// Per-agent overrides. + pub agents: Vec, +} + +impl From for ProjectMcpToolPermissionsDto { + fn from(out: ReadMcpToolPermissionsOutput) -> Self { + Self { + version: out.permissions.version, + catalogue: McpToolCatalogueDto { + read_only_tools: out.catalogue.read_only_tools, + write_action_tools: out.catalogue.write_action_tools, + }, + project_default: out.permissions.project_default, + agents: out.permissions.agents, + } + } +} + /// Request DTO for updating project default permissions. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1596,6 +1637,28 @@ pub struct ResolveAgentPermissionsRequestDto { pub agent_id: String, } +/// Request DTO for updating project default MCP tool permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectMcpToolPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project MCP tool policy. `null` removes the default. + pub policy: Option, +} + +/// Request DTO for updating one agent MCP tool permission override. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentMcpToolPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// New agent MCP tool policy. `null` removes the override. + pub policy: Option, +} + /// Request DTO for `update_project_context`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -3493,3 +3556,59 @@ pub struct SpawnBackgroundCommandRequestDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub deadline_ms: Option, } + +#[cfg(test)] +mod tests { + use application::McpToolPermissionCatalogue; + use domain::{AgentId, ProjectMcpToolPermissions}; + use serde_json::json; + use uuid::Uuid; + + use super::*; + + #[test] + fn project_mcp_tool_permissions_dto_uses_stable_camel_case_contract() { + let known_tools = ["idea_ticket_read", "idea_ticket_update"]; + let agent_id = AgentId::from_uuid(Uuid::from_u128(42)); + let output = ReadMcpToolPermissionsOutput { + catalogue: McpToolPermissionCatalogue::new( + vec!["idea_ticket_read".to_owned()], + vec!["idea_ticket_update".to_owned()], + ) + .unwrap(), + permissions: ProjectMcpToolPermissions { + version: 1, + project_default: Some( + McpToolPolicy::new(vec!["idea_ticket_read".to_owned()], &known_tools).unwrap(), + ), + agents: vec![AgentMcpToolPolicyOverride::new( + agent_id, + McpToolPolicy::new(vec!["idea_ticket_update".to_owned()], &known_tools) + .unwrap(), + )], + }, + }; + + let value = serde_json::to_value(ProjectMcpToolPermissionsDto::from(output)).unwrap(); + + assert_eq!( + value, + json!({ + "version": 1, + "catalogue": { + "readOnlyTools": ["idea_ticket_read"], + "writeActionTools": ["idea_ticket_update"] + }, + "projectDefault": { + "allowedTools": ["idea_ticket_read"] + }, + "agents": [{ + "agentId": agent_id, + "policy": { + "allowedTools": ["idea_ticket_update"] + } + }] + }) + ); + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 620280d..2359943 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -28,20 +28,21 @@ use application::{ ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, - RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, - RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, - SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, - UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, + RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, + SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, + UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; @@ -51,10 +52,10 @@ use domain::ports::{ AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, - IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, - ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, - StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, - WindowStateStore, + IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, + PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, + SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, + WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -74,17 +75,17 @@ use infrastructure::{ CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, - FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, - FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, - FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, - NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, - TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, + FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, + FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore, + Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, + TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, + ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -942,6 +943,14 @@ pub struct BackendCore { pub close_ticket_assistant: Arc, /// Shared MCP tool policy registry used by ticket assistant sessions. pub tool_policy_registry: Arc, + /// Durable per-agent MCP tool permission store. + pub mcp_tool_permission_store: Arc, + /// Read durable MCP tool permissions plus canonical catalogue classification. + pub read_mcp_tool_permissions: Arc, + /// Replace or clear the project-wide MCP tool policy. + pub update_project_mcp_tool_permissions: Arc, + /// Replace or clear one agent MCP tool policy override. + pub update_agent_mcp_tool_permissions: Arc, /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4) /// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture. @@ -1505,6 +1514,33 @@ impl BackendCore { // --- Project permissions (LP1) --- let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); let permission_store_port = Arc::clone(&permission_store) as Arc; + let mcp_tool_permission_store = + Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port))); + let mcp_tool_permission_store_port = + Arc::clone(&mcp_tool_permission_store) as Arc; + let mcp_tool_catalogue = McpToolPermissionCatalogue::new( + infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + infrastructure::orchestrator::mcp::tools::WRITE_ACTION_TOOLS + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + ) + .expect("MCP tool catalogue classification is valid"); + let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue.clone(), + )); + let update_project_mcp_tool_permissions = Arc::new(UpdateProjectMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue.clone(), + )); + let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new( + Arc::clone(&mcp_tool_permission_store_port), + mcp_tool_catalogue, + )); // --- Skill store (L12) --- // Global skills live in the machine-local app-data dir; project skills are @@ -2346,6 +2382,7 @@ impl BackendCore { Arc::clone(&orchestrator_service), Arc::clone(&store_port), Arc::clone(&tool_policy_registry) as Arc, + Arc::clone(&mcp_tool_permission_store_port), Arc::clone(&ticket_tool_provider), )) as Arc); @@ -2438,6 +2475,10 @@ impl BackendCore { open_ticket_assistant, close_ticket_assistant, tool_policy_registry, + mcp_tool_permission_store: mcp_tool_permission_store_port, + read_mcp_tool_permissions, + update_project_mcp_tool_permissions, + update_agent_mcp_tool_permissions, structured_sessions, create_agent, list_agents, @@ -2599,7 +2640,8 @@ impl BackendCore { .with_events(events) .with_ready_sink(ready_sink) .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) - .with_tool_policies(Arc::clone(&self.tool_policy_registry)), + .with_tool_policies(Arc::clone(&self.tool_policy_registry)) + .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), endpoint, listener, project_id, diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index f3b9a6d..f107e5d 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -10,9 +10,10 @@ use std::sync::{Arc, Mutex}; use application::OrchestratorService; use async_trait::async_trait; use domain::ports::{ - AgentToolPolicyStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, + AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker, + ToolSpec, }; -use domain::{AgentToolPolicy, IssueRef}; +use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use infrastructure::TicketToolProvider; use serde_json::Value; @@ -24,6 +25,7 @@ pub struct AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, policies: Arc, + mcp_tool_permissions: Arc, ticket_tools: Arc, } @@ -79,12 +81,14 @@ impl AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, policies: Arc, + mcp_tool_permissions: Arc, ticket_tools: Arc, ) -> Self { Self { orchestrator, projects, policies, + mcp_tool_permissions, ticket_tools, } } @@ -129,7 +133,10 @@ impl ToolInvoker for AppOpenAiToolInvoker { "identité requester interne absente pour l'outil".to_owned(), ) })?; - enforce_tool_policy(self.policies.as_ref(), &requester, name, &value)?; + let ephemeral_policy = self.policies.get_policy(&requester); + if let Some(policy) = &ephemeral_policy { + enforce_policy(policy, &requester, name, &value)?; + } let project = self .projects .list_projects() @@ -142,6 +149,14 @@ impl ToolInvoker for AppOpenAiToolInvoker { "projet introuvable pour root `{project_root}`" )) })?; + enforce_durable_tool_policy( + self.mcp_tool_permissions.as_ref(), + &project, + ephemeral_policy.as_ref(), + &requester, + name, + ) + .await?; if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) { let value = self .ticket_tools @@ -178,6 +193,71 @@ impl ToolInvoker for AppOpenAiToolInvoker { } } +async fn enforce_durable_tool_policy( + store: &dyn McpToolPermissionStore, + project: &Project, + ephemeral_policy: Option<&AgentToolPolicy>, + requester: &str, + name: &str, +) -> Result<(), ToolInvocationError> { + let Some(policy) = durable_tool_policy(store, project, ephemeral_policy, requester).await? + else { + return Ok(()); + }; + if policy.permits(name) { + return Ok(()); + } + let requester = if requester.is_empty() { + "mcp" + } else { + requester + }; + Err(ToolInvocationError::Rejected(format!( + "MCP tool `{name}` is not permitted for requester {requester}" + ))) +} + +async fn durable_tool_policy( + store: &dyn McpToolPermissionStore, + project: &Project, + 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) +} + +#[cfg(test)] fn enforce_tool_policy( policies: &dyn AgentToolPolicyStore, requester: &str, @@ -239,8 +319,11 @@ mod tests { use std::collections::HashMap; use std::sync::Mutex; - use domain::ports::{AgentToolPolicyStore, ProjectStore}; - use domain::{Project, ProjectId, ProjectPath, RemoteRef, StoreError, Workspace}; + use domain::ports::{AgentToolPolicyStore, McpToolPermissionStore, ProjectStore}; + use domain::{ + AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, + ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, + }; use infrastructure::TicketToolError; use serde_json::json; use uuid::Uuid; @@ -277,6 +360,37 @@ mod tests { } } + struct FakeMcpToolPermissions { + doc: Mutex, + } + + impl FakeMcpToolPermissions { + fn new(doc: ProjectMcpToolPermissions) -> Self { + Self { + doc: Mutex::new(doc), + } + } + } + + #[async_trait] + impl McpToolPermissionStore for FakeMcpToolPermissions { + async fn load_mcp_tool_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.doc.lock().unwrap().clone()) + } + + async fn save_mcp_tool_permissions( + &self, + _project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + *self.doc.lock().unwrap() = permissions.clone(); + Ok(()) + } + } + #[async_trait] impl ProjectStore for FakeProjects { async fn list_projects(&self) -> Result, StoreError> { @@ -349,6 +463,30 @@ mod tests { .unwrap() } + fn mcp_permissions(doc: ProjectMcpToolPermissions) -> Arc { + Arc::new(FakeMcpToolPermissions::new(doc)) + } + + fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions { + let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); + ProjectMcpToolPermissions::new( + None, + vec![AgentMcpToolPolicyOverride::new( + agent, + McpToolPolicy::new( + allowed_tools + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + &known_tools, + ) + .unwrap(), + )], + &known_tools, + ) + .unwrap() + } + #[test] fn openai_tool_policy_rejects_tool_outside_allowlist() { let policies = FakePolicies::default(); @@ -438,6 +576,7 @@ mod tests { Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, policies, + mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), ); @@ -486,4 +625,112 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + + #[tokio::test] + async fn openai_general_agent_without_durable_override_is_read_only() { + let temp = std::env::temp_dir().join(format!( + "idea-openai-mcp-permissions-readonly-{}", + Uuid::new_v4() + )); + let core = crate::BackendCore::build(temp.clone()); + let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(ProjectMcpToolPermissions::default()), + ticket_tools.clone(), + ); + + invoker + .call( + "idea_ticket_list", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + }) + .to_string(), + ) + .await + .expect("read tool must pass the durable read-only policy"); + + for (tool, arguments) in [ + ( + "idea_memory_write", + json!({ "slug": "note-a", "content": "body" }), + ), + ( + "idea_ask_agent", + json!({ "target": "architect", "task": "do it" }), + ), + ( + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + ), + ( + "idea_run_in_background", + json!({ "label": "task", "command": "echo" }), + ), + ] { + let mut payload = arguments.as_object().unwrap().clone(); + payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); + payload.insert(REQUESTER_ARG.to_owned(), json!(requester.clone())); + let err = invoker + .call(tool, &Value::Object(payload).to_string()) + .await + .expect_err("write tool must be rejected without durable override"); + assert!( + matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)), + "expected readable rejection for {tool}, got {err:?}" + ); + } + + let calls = ticket_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "only the read ticket tool should run"); + assert_eq!(calls[0].1, "idea_ticket_list"); + + 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!("idea-openai-mcp-permissions-{}", Uuid::new_v4())); + let core = crate::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(83)); + let requester = agent.to_string(); + let ticket_tools = Arc::new(FakeTicketTools::default()); + 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_update_carnet"])), + ticket_tools.clone(), + ); + + let result = invoker + .call( + "idea_ticket_update_carnet", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "ref": "#7", + "expectedVersion": 1, + "carnet": "body", + }) + .to_string(), + ) + .await + .expect("durable override should let the write tool reach the provider"); + let result: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["requester"], requester); + assert_eq!(result["ref"], "#7"); + + let calls = ticket_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "idea_ticket_update_carnet"); + + let _ = std::fs::remove_dir_all(temp); + } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index ff017a4..6744dc2 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -48,6 +48,7 @@ pub mod layout; pub mod live_state; pub mod mailbox; pub mod markdown; +pub mod mcp_tool_permissions; pub mod memory; pub mod memory_harvest; pub mod model_server; @@ -84,6 +85,11 @@ pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; pub use agent_tool_policy::AgentToolPolicy; +pub use mcp_tool_permissions::{ + AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, ProjectMcpToolPermissions, + MCP_TOOL_PERMISSIONS_VERSION, +}; + pub use background_task::{ BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS, @@ -204,11 +210,11 @@ pub use ports::{ EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore, - IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, - ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, - Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, - ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, - RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, - StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore, - WindowStateStore, + IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery, + MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, + ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError, + ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, + RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, + SprintStoreError, StoreError, StructuredSessionEnvironment, + StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/mcp_tool_permissions.rs b/crates/domain/src/mcp_tool_permissions.rs new file mode 100644 index 0000000..af3efe8 --- /dev/null +++ b/crates/domain/src/mcp_tool_permissions.rs @@ -0,0 +1,314 @@ +//! Durable MCP tool permissions for IdeA agents. +//! +//! This model is deliberately separate from [`crate::permission`]: file/process +//! permissions and Landlock sandboxing are OS-level capabilities, while MCP tools +//! are application capabilities enforced before dispatch. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ids::AgentId; + +/// Current schema version for `.ideai/mcp-tool-permissions.json`. +pub const MCP_TOOL_PERMISSIONS_VERSION: u32 = 1; + +/// Validation errors for durable MCP tool permission documents. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum McpToolPermissionError { + /// The stored document uses an unsupported schema version. + #[error("unsupported MCP tool permissions version: {0}")] + UnsupportedVersion(u32), + /// A tool name is empty. + #[error("MCP tool name cannot be empty")] + EmptyToolName, + /// A tool name is not in the known MCP catalogue. + #[error("unknown MCP tool: {0}")] + UnknownTool(String), + /// A tool appears more than once in one allowlist. + #[error("duplicate MCP tool: {0}")] + DuplicateTool(String), + /// An agent has more than one override entry. + #[error("duplicate MCP tool policy override for agent {0}")] + DuplicateAgent(AgentId), +} + +/// Allowlist of MCP tools. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolPolicy { + /// Exact MCP tool names allowed by this policy. + pub allowed_tools: Vec, +} + +impl McpToolPolicy { + /// Builds and validates a policy against the known catalogue. + /// + /// # Errors + /// [`McpToolPermissionError`] when a name is empty, duplicate, or unknown. + pub fn new( + allowed_tools: Vec, + known_tools: &[&str], + ) -> Result { + let policy = Self { allowed_tools }; + policy.validate(known_tools)?; + Ok(policy) + } + + /// Builds the canonical read-only fallback policy. + /// + /// # Errors + /// [`McpToolPermissionError`] if the provided read-only set is not a valid + /// subset of the known catalogue. + pub fn read_only( + read_only_tools: &[&str], + known_tools: &[&str], + ) -> Result { + Self::new( + read_only_tools + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + known_tools, + ) + } + + /// Returns whether `tool` is allowed by this allowlist. + #[must_use] + pub fn permits(&self, tool: &str) -> bool { + self.allowed_tools.iter().any(|allowed| allowed == tool) + } + + /// Validates the policy against a known catalogue. + /// + /// # Errors + /// [`McpToolPermissionError`] when a name is empty, duplicate, or unknown. + pub fn validate(&self, known_tools: &[&str]) -> Result<(), McpToolPermissionError> { + let known = known_tools.iter().copied().collect::>(); + let mut seen = HashSet::new(); + for tool in &self.allowed_tools { + if tool.is_empty() { + return Err(McpToolPermissionError::EmptyToolName); + } + if !known.contains(tool.as_str()) { + return Err(McpToolPermissionError::UnknownTool(tool.clone())); + } + if !seen.insert(tool.as_str()) { + return Err(McpToolPermissionError::DuplicateTool(tool.clone())); + } + } + Ok(()) + } +} + +/// Agent-specific policy override. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMcpToolPolicyOverride { + /// Agent whose effective policy is overridden. + pub agent_id: AgentId, + /// Replacement policy for the agent. + pub policy: McpToolPolicy, +} + +impl AgentMcpToolPolicyOverride { + /// Builds an agent override. + #[must_use] + pub fn new(agent_id: AgentId, policy: McpToolPolicy) -> Self { + Self { agent_id, policy } + } +} + +/// Project document persisted as `.ideai/mcp-tool-permissions.json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMcpToolPermissions { + /// Schema version. + pub version: u32, + /// Optional project-wide default policy. + pub project_default: Option, + /// Sparse agent overrides. + pub agents: Vec, +} + +impl Default for ProjectMcpToolPermissions { + fn default() -> Self { + Self { + version: MCP_TOOL_PERMISSIONS_VERSION, + project_default: None, + agents: Vec::new(), + } + } +} + +impl ProjectMcpToolPermissions { + /// Builds and validates a permissions document. + /// + /// # Errors + /// [`McpToolPermissionError`] when the document contains invalid policies or + /// duplicate agent overrides. + pub fn new( + project_default: Option, + agents: Vec, + known_tools: &[&str], + ) -> Result { + let doc = Self { + version: MCP_TOOL_PERMISSIONS_VERSION, + project_default, + agents, + }; + doc.validate(known_tools)?; + Ok(doc) + } + + /// Validates version, policy contents, and override uniqueness. + /// + /// # Errors + /// [`McpToolPermissionError`] when an invariant is violated. + pub fn validate(&self, known_tools: &[&str]) -> Result<(), McpToolPermissionError> { + if self.version != MCP_TOOL_PERMISSIONS_VERSION { + return Err(McpToolPermissionError::UnsupportedVersion(self.version)); + } + if let Some(policy) = &self.project_default { + policy.validate(known_tools)?; + } + let mut seen_agents = HashSet::new(); + for override_ in &self.agents { + if !seen_agents.insert(override_.agent_id) { + return Err(McpToolPermissionError::DuplicateAgent(override_.agent_id)); + } + override_.policy.validate(known_tools)?; + } + Ok(()) + } + + /// Resolves the effective policy for `agent_id`. + /// + /// Resolution order: agent override > project default > canonical read-only + /// fallback. + /// + /// # Errors + /// [`McpToolPermissionError`] if the document or fallback set is invalid. + pub fn effective_policy( + &self, + agent_id: AgentId, + read_only_tools: &[&str], + known_tools: &[&str], + ) -> Result { + self.validate(known_tools)?; + if let Some(override_) = self + .agents + .iter() + .find(|override_| override_.agent_id == agent_id) + { + return Ok(override_.policy.clone()); + } + if let Some(policy) = &self.project_default { + return Ok(policy.clone()); + } + McpToolPolicy::read_only(read_only_tools, known_tools) + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + const KNOWN: &[&str] = &["idea_memory_read", "idea_memory_write", "idea_ticket_list"]; + const READ_ONLY: &[&str] = &["idea_memory_read", "idea_ticket_list"]; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) + } + + #[test] + fn effective_policy_falls_back_to_read_only_when_unconfigured() { + let doc = ProjectMcpToolPermissions::default(); + + let effective = doc.effective_policy(agent(1), READ_ONLY, KNOWN).unwrap(); + + assert!(effective.permits("idea_memory_read")); + assert!(effective.permits("idea_ticket_list")); + assert!(!effective.permits("idea_memory_write")); + } + + #[test] + fn agent_override_replaces_project_default() { + let project_default = + McpToolPolicy::new(vec!["idea_memory_read".to_owned()], KNOWN).unwrap(); + let override_policy = + McpToolPolicy::new(vec!["idea_memory_write".to_owned()], KNOWN).unwrap(); + let doc = ProjectMcpToolPermissions::new( + Some(project_default), + vec![AgentMcpToolPolicyOverride::new(agent(7), override_policy)], + KNOWN, + ) + .unwrap(); + + let effective = doc.effective_policy(agent(7), READ_ONLY, KNOWN).unwrap(); + + assert!(effective.permits("idea_memory_write")); + assert!(!effective.permits("idea_memory_read")); + } + + #[test] + fn project_default_applies_when_no_agent_override_exists() { + let project_default = + McpToolPolicy::new(vec!["idea_memory_write".to_owned()], KNOWN).unwrap(); + let doc = ProjectMcpToolPermissions::new(Some(project_default), Vec::new(), KNOWN).unwrap(); + + let effective = doc.effective_policy(agent(8), READ_ONLY, KNOWN).unwrap(); + + assert!(effective.permits("idea_memory_write")); + assert!(!effective.permits("idea_memory_read")); + } + + #[test] + fn unknown_tool_is_rejected_in_allowlist() { + let err = McpToolPolicy::new(vec!["idea_unknown".to_owned()], KNOWN).unwrap_err(); + + assert_eq!( + err, + McpToolPermissionError::UnknownTool("idea_unknown".to_owned()) + ); + } + + #[test] + fn duplicate_tool_is_rejected_in_allowlist() { + let err = McpToolPolicy::new( + vec!["idea_memory_read".to_owned(), "idea_memory_read".to_owned()], + KNOWN, + ) + .unwrap_err(); + + assert_eq!( + err, + McpToolPermissionError::DuplicateTool("idea_memory_read".to_owned()) + ); + } + + #[test] + fn empty_tool_name_is_rejected_in_allowlist() { + let err = McpToolPolicy::new(vec![String::new()], KNOWN).unwrap_err(); + + assert_eq!(err, McpToolPermissionError::EmptyToolName); + } + + #[test] + fn duplicate_agent_override_is_rejected() { + let err = ProjectMcpToolPermissions::new( + None, + vec![ + AgentMcpToolPolicyOverride::new(agent(3), McpToolPolicy::default()), + AgentMcpToolPolicyOverride::new(agent(3), McpToolPolicy::default()), + ], + KNOWN, + ) + .unwrap_err(); + + assert_eq!(err, McpToolPermissionError::DuplicateAgent(agent(3))); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 973bd07..ff41e03 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -43,6 +43,7 @@ use crate::issue::{ Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, }; use crate::markdown::MarkdownDoc; +use crate::mcp_tool_permissions::ProjectMcpToolPermissions; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; use crate::model_server::{ HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus, @@ -1791,6 +1792,33 @@ pub trait PermissionStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Reads/writes a project's `.ideai/mcp-tool-permissions.json`. +/// +/// This is intentionally distinct from [`PermissionStore`]: it governs IdeA MCP +/// application tools, not filesystem/process/sandbox capabilities. +#[async_trait] +pub trait McpToolPermissionStore: Send + Sync { + /// Loads the project's MCP tool permission document. Missing file returns the + /// default empty document. + /// + /// # Errors + /// [`StoreError`] on I/O, deserialisation, or validation failure. + async fn load_mcp_tool_permissions( + &self, + project: &Project, + ) -> Result; + + /// Saves the project's MCP tool permission document. + /// + /// # Errors + /// [`StoreError`] on I/O, serialisation, or validation failure. + async fn save_mcp_tool_permissions( + &self, + project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError>; +} + /// Persistence port for first-class background tasks. #[async_trait] pub trait BackgroundTaskStore: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 934386d..3c19670 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -99,8 +99,8 @@ pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, - FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, - FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, - OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, - RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, + FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, + NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 87197e4..88176ad 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -21,7 +21,10 @@ use std::sync::Arc; use std::time::Instant; use application::OrchestratorService; -use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project}; +use domain::ports::McpToolPermissionStore; +use domain::{ + AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, Project, +}; use serde_json::{json, Value}; use tokio::sync::mpsc; @@ -69,6 +72,9 @@ pub struct McpServer { ticket_tools: Option>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, + /// Optional durable MCP tool permission store. When wired, absence of project or + /// agent policy resolves to the canonical read-only fallback. + mcp_tool_permissions: Option>, } impl McpServer { @@ -85,6 +91,7 @@ impl McpServer { ready_sink: None, ticket_tools: None, tool_policies: None, + mcp_tool_permissions: None, } } @@ -123,6 +130,16 @@ impl McpServer { self } + /// Attaches the durable MCP tool permission store. + #[must_use] + pub fn with_mcp_tool_permissions( + mut self, + mcp_tool_permissions: Arc, + ) -> Self { + self.mcp_tool_permissions = Some(mcp_tool_permissions); + self + } + /// Returns a per-connection clone of this server tagged with the connected /// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4). /// @@ -141,6 +158,7 @@ impl McpServer { ready_sink: self.ready_sink.clone(), ticket_tools: self.ticket_tools.clone(), tool_policies: self.tool_policies.clone(), + mcp_tool_permissions: self.mcp_tool_permissions.clone(), } } @@ -282,7 +300,7 @@ impl McpServer { self.notify_ready(); Ok(self.initialize_result()) } - "tools/list" => Ok(self.tools_list_result()), + "tools/list" => self.tools_list_result().await, "tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await, other => Err(JsonRpcError::new( error_codes::METHOD_NOT_FOUND, @@ -312,17 +330,18 @@ impl McpServer { } /// The `tools/list` result: the catalogue as MCP tool descriptors. - fn tools_list_result(&self) -> Value { - let policy = self - .tool_policies - .as_ref() - .and_then(|registry| registry.get(&self.requester)); + 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| { - policy + 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!({ @@ -332,7 +351,82 @@ impl McpServer { }) }) .collect(); - json!({ "tools": tools }) + Ok(json!({ "tools": tools })) + } + + fn ephemeral_tool_policy(&self) -> Option { + self.tool_policies + .as_ref() + .and_then(|registry| registry.get(&self.requester)) + } + + 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)) + } + + fn requester_agent_id(&self) -> Option { + uuid::Uuid::parse_str(&self.requester) + .ok() + .map(AgentId::from_uuid) + } + + fn enforce_durable_tool_policy( + &self, + policy: &McpToolPolicy, + name: &str, + ) -> Result<(), JsonRpcError> { + if policy.permits(name) { + return Ok(()); + } + let requester = if self.requester.is_empty() { + "mcp" + } else { + &self.requester + }; + Err(JsonRpcError::new( + error_codes::INVALID_PARAMS, + format!("MCP tool `{name}` is not permitted for requester {requester}"), + )) } /// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and @@ -346,13 +440,12 @@ impl McpServer { .to_owned(); let arguments = params.get("arguments").cloned().unwrap_or(json!({})); - if let Some(policy) = self - .tool_policies - .as_ref() - .and_then(|registry| registry.get(&self.requester)) - { + if let Some(policy) = self.ephemeral_tool_policy() { self.enforce_tool_policy(&policy, &name, &arguments)?; } + if let Some(policy) = self.durable_tool_policy().await? { + self.enforce_durable_tool_policy(&policy, &name)?; + } // Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace // l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs. diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 0d2fa79..c981cb5 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -34,6 +34,70 @@ pub struct ToolDef { pub input_schema: Value, } +/// Coarse MCP tool access class used to derive default permissions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolAccess { + /// Read-only application capability, allowed by default. + Read, + /// Write/action/execution capability, denied by default. + WriteAction, +} + +/// Canonical read-only MCP tools allowed by default. +pub const READ_ONLY_TOOLS: &[&str] = &[ + "idea_list_agents", + "idea_context_read", + "idea_memory_read", + "idea_skill_read", + "idea_workstate_read", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_read_carnet", + "idea_sprint_list", +]; + +/// Canonical write/action MCP tools denied by default. +pub const WRITE_ACTION_TOOLS: &[&str] = &[ + "idea_ask_agent", + "idea_run_in_background", + "idea_launch_agent", + "idea_stop_agent", + "idea_update_context", + "idea_context_propose", + "idea_memory_write", + "idea_workstate_set", + "idea_create_skill", + "idea_ticket_create", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", +]; + +/// All MCP tool names that have an explicit access classification. +#[must_use] +pub fn classified_tool_names() -> Vec<&'static str> { + READ_ONLY_TOOLS + .iter() + .chain(WRITE_ACTION_TOOLS.iter()) + .copied() + .collect() +} + +/// Returns the explicit access class for `tool`. +#[must_use] +pub fn tool_access(tool: &str) -> Option { + if READ_ONLY_TOOLS.contains(&tool) { + Some(McpToolAccess::Read) + } else if WRITE_ACTION_TOOLS.contains(&tool) { + Some(McpToolAccess::WriteAction) + } else { + None + } +} + /// Errors mapping a tool call into a validated [`OrchestratorCommand`]. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ToolMapError { @@ -498,6 +562,7 @@ fn optional_u64(value: Option<&Value>, tool: &str) -> Result, ToolMa mod tests { use super::*; use domain::OrchestratorVisibility; + use std::collections::HashSet; /// A well-formed handshake requester id for the tests (parsed as an `AgentId`). const REQ: &str = "11111111-1111-1111-1111-111111111111"; @@ -507,6 +572,44 @@ mod tests { map_tool_call(name, args, "") } + #[test] + fn catalogue_tools_have_explicit_read_or_write_access() { + let catalogue_names = catalogue() + .into_iter() + .map(|tool| tool.name) + .collect::>(); + + for tool in &catalogue_names { + assert!( + tool_access(tool).is_some(), + "MCP catalogue tool `{tool}` must choose Read or WriteAction explicitly" + ); + } + + for tool in classified_tool_names() { + assert!( + catalogue_names.contains(tool), + "classified MCP tool `{tool}` is not advertised by the catalogue" + ); + } + } + + #[test] + fn read_and_write_tool_classifications_are_disjoint_and_unique() { + let mut seen = HashSet::new(); + for tool in READ_ONLY_TOOLS { + assert!(seen.insert(*tool), "duplicate read-only MCP tool `{tool}`"); + assert_eq!(tool_access(tool), Some(McpToolAccess::Read)); + } + for tool in WRITE_ACTION_TOOLS { + assert!( + seen.insert(*tool), + "MCP tool `{tool}` is classified more than once" + ); + assert_eq!(tool_access(tool), Some(McpToolAccess::WriteAction)); + } + } + #[test] fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() { let requester = uuid::Uuid::from_u128(42).to_string(); diff --git a/crates/infrastructure/src/store/mcp_tool_permission.rs b/crates/infrastructure/src/store/mcp_tool_permission.rs new file mode 100644 index 0000000..9d28255 --- /dev/null +++ b/crates/infrastructure/src/store/mcp_tool_permission.rs @@ -0,0 +1,81 @@ +//! Filesystem-backed [`McpToolPermissionStore`] for project MCP tool permissions. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::ports::{FileSystem, FsError, McpToolPermissionStore, RemotePath, StoreError}; +use domain::{Project, ProjectMcpToolPermissions}; + +use crate::orchestrator::mcp::tools; + +const MCP_TOOL_PERMISSIONS_FILE: &str = "mcp-tool-permissions.json"; + +/// JSON-file implementation for `/.ideai/mcp-tool-permissions.json`. +#[derive(Clone)] +pub struct FsMcpToolPermissionStore { + fs: Arc, +} + +impl FsMcpToolPermissionStore { + /// Builds the store from an injected filesystem port. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + fn path(project: &Project) -> RemotePath { + let root = project.root.as_str().trim_end_matches(['/', '\\']); + RemotePath::new(format!("{root}/.ideai/{MCP_TOOL_PERMISSIONS_FILE}")) + } + + async fn ensure_ideai(&self, project: &Project) -> Result<(), StoreError> { + let root = project.root.as_str().trim_end_matches(['/', '\\']); + self.fs + .create_dir_all(&RemotePath::new(format!("{root}/.ideai"))) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + fn validate(permissions: &ProjectMcpToolPermissions) -> Result<(), StoreError> { + let known_tools = tools::classified_tool_names(); + permissions + .validate(&known_tools) + .map_err(|e| StoreError::Serialization(e.to_string())) + } +} + +#[async_trait] +impl McpToolPermissionStore for FsMcpToolPermissionStore { + async fn load_mcp_tool_permissions( + &self, + project: &Project, + ) -> Result { + match self.fs.read(&Self::path(project)).await { + Ok(bytes) => { + let permissions = serde_json::from_slice::(&bytes) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + Self::validate(&permissions)?; + Ok(permissions) + } + Err(FsError::NotFound(_)) => Ok(ProjectMcpToolPermissions::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + async fn save_mcp_tool_permissions( + &self, + project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + Self::validate(permissions)?; + self.ensure_ideai(project).await?; + let mut bytes = serde_json::to_vec_pretty(permissions) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&Self::path(project), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 032a11b..9dfd814 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -9,6 +9,7 @@ mod context; mod device_session; mod embedder; mod live_state; +mod mcp_tool_permission; mod memory; mod permission; mod profile; @@ -31,6 +32,7 @@ pub use embedder::{ RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub use live_state::FsLiveStateStore; +pub use mcp_tool_permission::FsMcpToolPermissionStore; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use permission::FsPermissionStore; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 7a8095b..1b0945a 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -33,9 +33,9 @@ use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, - ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, - PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, - StoreError, + ExitStatus, FileSystem, FsError, IdGenerator, McpToolPermissionStore, OutputStream, + PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, + SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, @@ -45,7 +45,10 @@ use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::terminal::{SessionKind, TerminalSession}; -use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId}; +use domain::{ + AgentMcpToolPolicyOverride, AgentToolPolicy, IssueRef, McpToolPolicy, + ProjectMcpToolPermissions, PtySize, SessionId, +}; use uuid::Uuid; use application::{ @@ -53,6 +56,7 @@ use application::{ OrchestratorService, TerminalSessions, UpdateAgentContext, }; use infrastructure::orchestrator::mcp::jsonrpc::error_codes; +use infrastructure::orchestrator::mcp::tools::classified_tool_names; use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider}; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, @@ -274,6 +278,38 @@ impl FileSystem for FakeFs { } } +#[derive(Clone)] +struct FakeMcpToolPermissionStore { + doc: Arc>, +} + +impl FakeMcpToolPermissionStore { + fn new(doc: ProjectMcpToolPermissions) -> Self { + Self { + doc: Arc::new(Mutex::new(doc)), + } + } +} + +#[async_trait] +impl McpToolPermissionStore for FakeMcpToolPermissionStore { + async fn load_mcp_tool_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.doc.lock().unwrap().clone()) + } + + async fn save_mcp_tool_permissions( + &self, + _project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + *self.doc.lock().unwrap() = permissions.clone(); + Ok(()) + } +} + #[derive(Clone)] struct FakePty; #[async_trait] @@ -433,6 +469,34 @@ fn server(service: Arc) -> McpServer { McpServer::new(service, project()) } +fn server_with_mcp_permissions( + service: Arc, + permissions: ProjectMcpToolPermissions, +) -> McpServer { + McpServer::new(service, project()) + .with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(permissions))) +} + +fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions { + let known_tools = classified_tool_names(); + ProjectMcpToolPermissions::new( + None, + vec![AgentMcpToolPolicyOverride::new( + agent, + McpToolPolicy::new( + allowed_tools + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + &known_tools, + ) + .unwrap(), + )], + &known_tools, + ) + .unwrap() +} + /// A capturing event sink (the MCP twin of the file watcher's publish closure): /// records every [`DomainEvent`] the server emits so a test can assert the /// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via @@ -507,6 +571,11 @@ impl TicketToolProvider for FakeTicketTools { ) -> Result { self.calls.lock().unwrap().push(name.to_owned()); match name { + "idea_ticket_read" => Ok(json!({ + "item": { "ref": "#7", "title": "Seeded ticket", "version": 1 } + })), + "idea_ticket_list" => Ok(json!({ "items": [] })), + "idea_ticket_read_carnet" => Ok(json!({ "ref": "#7", "carnet": "" })), "idea_sprint_list" => Ok(json!({ "items": self.sprints.lock().unwrap().clone() })), @@ -666,10 +735,232 @@ async fn requester_without_tool_policy_keeps_the_full_tools_list() { assert!(names.len() > 3, "unfiltered requester got {names:?}"); } +#[tokio::test] +async fn requester_with_durable_store_but_no_override_sees_read_only_tools() { + let (service, _s) = build_service(FakeContexts::new()); + let agent = AgentId::from_uuid(Uuid::from_u128(82)); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester(agent.to_string()); + + let raw = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list" + })) + .unwrap(); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!(response.error.is_none(), "got error: {:?}", response.error); + let result = response.result.expect("result"); + let names: Vec<&str> = result["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + + assert!(names.contains(&"idea_memory_read")); + assert!(names.contains(&"idea_ticket_list")); + assert!(!names.contains(&"idea_memory_write")); + assert!(!names.contains(&"idea_ask_agent")); + assert!(!names.contains(&"idea_ticket_update_carnet")); + assert!(!names.contains(&"idea_run_in_background")); +} + // --------------------------------------------------------------------------- // 2. tools/call → the right OrchestratorCommand (observed through the fakes) // --------------------------------------------------------------------------- +#[tokio::test] +async fn general_agent_without_mcp_override_is_read_only_for_tools_call() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let agent = AgentId::from_uuid(Uuid::from_u128(83)); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .with_ticket_tools(ticket_tools.clone()) + .for_requester(agent.to_string()); + + for (id, tool, arguments) in [ + (101, "idea_memory_read", json!({})), + (102, "idea_ticket_list", json!({})), + ] { + let response = server + .handle_raw(&tools_call(id, tool, arguments)) + .await + .expect("reply owed"); + assert!( + response.error.is_none(), + "read tool {tool} must not be rejected by MCP policy: {:?}", + response.error + ); + assert!(response.result.is_some(), "read tool {tool} should run"); + } + + for (id, tool, arguments) in [ + ( + 111, + "idea_memory_write", + json!({ "slug": "note-a", "content": "body" }), + ), + ( + 112, + "idea_ask_agent", + json!({ "target": "architect", "task": "do it" }), + ), + ( + 113, + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + ), + ( + 114, + "idea_run_in_background", + json!({ "label": "task", "command": "echo" }), + ), + ] { + let response = server + .handle_raw(&tools_call(id, tool, arguments)) + .await + .expect("reply owed"); + let error = response.error.expect("MCP policy rejection expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}"); + assert!( + error.message.contains("not permitted"), + "message should be readable for {tool}; got {}", + error.message + ); + assert!(response.result.is_none(), "denied {tool} must not run"); + } + + assert_eq!( + ticket_tools.calls(), + vec!["idea_ticket_list".to_owned()], + "denied ticket mutation must not reach the provider" + ); + assert_eq!(ticket_tools.mutation_attempts(), 0); +} + +#[tokio::test] +async fn legacy_mcp_requester_with_durable_store_is_read_only() { + let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new()); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester("mcp"); + + let response = server + .handle_raw(&tools_call( + 121, + "idea_run_in_background", + json!({ "label": "task", "command": "echo" }), + )) + .await + .expect("reply owed"); + + let error = response.error.expect("legacy requester must fail closed"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); + assert!(error.message.contains("not permitted")); +} + +#[tokio::test] +async fn empty_requester_with_durable_store_is_read_only() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester(""); + + let response = server + .handle_raw(&tools_call( + 122, + "idea_memory_write", + json!({ "slug": "note-a", "content": "body" }), + )) + .await + .expect("reply owed"); + + let error = response.error.expect("empty requester must fail closed"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); + assert!(error.message.contains("not permitted")); + assert!(response.result.is_none()); +} + +#[tokio::test] +async fn durable_agent_override_allows_an_explicit_write_tool() { + let (service, _s) = build_service(FakeContexts::new()); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let agent = AgentId::from_uuid(Uuid::from_u128(84)); + let server = + server_with_mcp_permissions(service, allow_doc(agent, &["idea_ticket_update_carnet"])) + .with_ticket_tools(ticket_tools.clone()) + .for_requester(agent.to_string()); + + let response = server + .handle_raw(&tools_call( + 131, + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + )) + .await + .expect("reply owed"); + + assert!( + response.error.is_none(), + "durable override should pass MCP policy, got {:?}", + response.error + ); + let result = response.result.expect("tool result"); + assert_eq!( + result["isError"], + json!(true), + "fake provider reports execution error after policy passes" + ); + assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]); + assert_eq!(ticket_tools.mutation_attempts(), 1); +} + +#[tokio::test] +async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() { + let (service, _s) = build_service(FakeContexts::new()); + let registry = Arc::new(ToolPolicyRegistry::new()); + registry.set( + "ticket-assistant:demo:7", + AgentToolPolicy::new( + vec!["idea_ticket_update_carnet".to_owned()], + Some(IssueRef::from_str("#7").unwrap()), + true, + ), + ); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .with_ticket_tools(ticket_tools.clone()) + .with_tool_policies(registry) + .for_requester("ticket-assistant:demo:7"); + + let rejected = server + .handle_raw(&tools_call( + 141, + "idea_ticket_update_carnet", + json!({ "ref": "#8", "expectedVersion": 1, "carnet": "body" }), + )) + .await + .expect("reply owed"); + let error = rejected.error.expect("bound ticket rejection expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); + assert!(error.message.contains("#8")); + assert!(ticket_tools.calls().is_empty()); + + let allowed = server + .handle_raw(&tools_call( + 142, + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + )) + .await + .expect("reply owed"); + assert!( + allowed.error.is_none(), + "bound ticket should pass policy, got {:?}", + allowed.error + ); + assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]); +} + #[tokio::test] async fn launch_agent_call_creates_and_launches_the_agent() { let contexts = FakeContexts::new(); diff --git a/crates/infrastructure/tests/mcp_tool_permission_store.rs b/crates/infrastructure/tests/mcp_tool_permission_store.rs new file mode 100644 index 0000000..89efedd --- /dev/null +++ b/crates/infrastructure/tests/mcp_tool_permission_store.rs @@ -0,0 +1,176 @@ +//! L2 integration tests for [`FsMcpToolPermissionStore`] against a real temp project. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ids::{AgentId, ProjectId}; +use domain::ports::{FileSystem, McpToolPermissionStore, StoreError}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::{ + AgentMcpToolPolicyOverride, McpToolPolicy, ProjectMcpToolPermissions, + MCP_TOOL_PERMISSIONS_VERSION, +}; +use infrastructure::orchestrator::mcp::tools::{classified_tool_names, READ_ONLY_TOOLS}; +use infrastructure::{FsMcpToolPermissionStore, LocalFileSystem}; +use uuid::Uuid; + +/// A unique scratch project directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let p = + std::env::temp_dir().join(format!("idea-l2-mcp-tool-permissions-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + + fn project_root(&self) -> String { + self.0.to_string_lossy().into_owned() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store() -> FsMcpToolPermissionStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsMcpToolPermissionStore::new(fs) +} + +fn project(tmp: &TempDir) -> Project { + Project::new( + ProjectId::new_random(), + "mcp-tool-permissions", + ProjectPath::new(tmp.project_root()).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +fn policy(tools: &[&str]) -> McpToolPolicy { + McpToolPolicy::new( + tools.iter().map(|tool| (*tool).to_owned()).collect(), + &classified_tool_names(), + ) + .unwrap() +} + +#[tokio::test] +async fn missing_mcp_tool_permissions_file_returns_default_document() { + let tmp = TempDir::new(); + let project = project(&tmp); + + let loaded = store().load_mcp_tool_permissions(&project).await.unwrap(); + + assert_eq!(loaded, ProjectMcpToolPermissions::default()); + assert_eq!(loaded.version, MCP_TOOL_PERMISSIONS_VERSION); + + let effective = loaded + .effective_policy( + AgentId::new_random(), + READ_ONLY_TOOLS, + &classified_tool_names(), + ) + .unwrap(); + assert!(effective.permits("idea_context_read")); + assert!(!effective.permits("idea_memory_write")); +} + +#[tokio::test] +async fn save_then_load_roundtrips_project_default_and_agent_override() { + let tmp = TempDir::new(); + let project = project(&tmp); + let agent = AgentId::new_random(); + let doc = ProjectMcpToolPermissions::new( + Some(policy(&["idea_context_read", "idea_memory_read"])), + vec![AgentMcpToolPolicyOverride::new( + agent, + policy(&["idea_ticket_update"]), + )], + &classified_tool_names(), + ) + .unwrap(); + + let store = store(); + store + .save_mcp_tool_permissions(&project, &doc) + .await + .unwrap(); + + let loaded = store.load_mcp_tool_permissions(&project).await.unwrap(); + assert_eq!(loaded, doc); + + let effective = loaded + .effective_policy(agent, READ_ONLY_TOOLS, &classified_tool_names()) + .unwrap(); + assert!(effective.permits("idea_ticket_update")); + assert!(!effective.permits("idea_context_read")); + + let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json"); + assert!( + path.exists(), + "store writes under .ideai/mcp-tool-permissions.json" + ); +} + +#[tokio::test] +async fn load_rejects_unknown_tool_permissions() { + let tmp = TempDir::new(); + let project = project(&tmp); + let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + r#"{ + "version": 1, + "projectDefault": { "allowedTools": ["idea_unknown"] }, + "agents": [] +} +"#, + ) + .unwrap(); + + let err = store() + .load_mcp_tool_permissions(&project) + .await + .unwrap_err(); + + assert!( + matches!(err, StoreError::Serialization(ref message) if message.contains("unknown MCP tool: idea_unknown")), + "unknown tools must not become latent permissions, got {err:?}" + ); +} + +#[tokio::test] +async fn save_rejects_invalid_allowlist_before_writing() { + let tmp = TempDir::new(); + let project = project(&tmp); + let invalid = ProjectMcpToolPermissions { + version: MCP_TOOL_PERMISSIONS_VERSION, + project_default: Some(McpToolPolicy { + allowed_tools: vec!["idea_unknown".to_owned()], + }), + agents: Vec::new(), + }; + + let err = store() + .save_mcp_tool_permissions(&project, &invalid) + .await + .unwrap_err(); + + assert!( + matches!(err, StoreError::Serialization(ref message) if message.contains("unknown MCP tool: idea_unknown")), + "invalid allowlists must be rejected before persistence, got {err:?}" + ); + assert!(!tmp + .0 + .join(".ideai") + .join("mcp-tool-permissions.json") + .exists()); +}