//! 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))); } }