From 5175589aa56412e4f95fed077e45a09d712bb756 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 22:45:32 +0200 Subject: [PATCH] feat(backend): domaine + catalogue et store durable des permissions tools MCP (#82 lot B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduit le domaine mcp_tool_permissions (règles de permission par tool MCP) et étend le port de store correspondant. Le catalogue read/write des tools MCP (orchestrator/mcp/tools.rs) s'appuie désormais sur ces règles, et un store durable (mcp_tool_permission.rs) persiste les permissions au-delà d'une session. Lot B1 du ticket #82 : pose le socle domaine/store, l'enforcement suit en B2 sur la même branche. QA vert. Co-Authored-By: Claude Sonnet 5 --- crates/domain/src/lib.rs | 20 +- crates/domain/src/mcp_tool_permissions.rs | 314 ++++++++++++++++++ crates/domain/src/ports.rs | 28 ++ crates/infrastructure/src/lib.rs | 8 +- .../src/orchestrator/mcp/tools.rs | 103 ++++++ .../src/store/mcp_tool_permission.rs | 81 +++++ crates/infrastructure/src/store/mod.rs | 2 + .../tests/mcp_tool_permission_store.rs | 176 ++++++++++ 8 files changed, 721 insertions(+), 11 deletions(-) create mode 100644 crates/domain/src/mcp_tool_permissions.rs create mode 100644 crates/infrastructure/src/store/mcp_tool_permission.rs create mode 100644 crates/infrastructure/tests/mcp_tool_permission_store.rs 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/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_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()); +}