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/backend/src/lib.rs b/crates/backend/src/lib.rs index f0559e2..6b8c30a 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -2352,6 +2352,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); 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); + } }