Files
IdeaSDK/crates/domain/src/mcp_tool_permissions.rs
Blomios 5175589aa5 feat(backend): domaine + catalogue et store durable des permissions tools MCP (#82 lot B1)
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 <noreply@anthropic.com>
2026-07-18 22:45:32 +02:00

315 lines
10 KiB
Rust

//! 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<String>,
}
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<String>,
known_tools: &[&str],
) -> Result<Self, McpToolPermissionError> {
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, McpToolPermissionError> {
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::<HashSet<_>>();
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<McpToolPolicy>,
/// Sparse agent overrides.
pub agents: Vec<AgentMcpToolPolicyOverride>,
}
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<McpToolPolicy>,
agents: Vec<AgentMcpToolPolicyOverride>,
known_tools: &[&str],
) -> Result<Self, McpToolPermissionError> {
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<McpToolPolicy, McpToolPermissionError> {
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)));
}
}