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>
This commit is contained in:
@ -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,
|
||||
};
|
||||
|
||||
314
crates/domain/src/mcp_tool_permissions.rs
Normal file
314
crates/domain/src/mcp_tool_permissions.rs
Normal file
@ -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<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)));
|
||||
}
|
||||
}
|
||||
@ -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<ProjectMcpToolPermissions, StoreError>;
|
||||
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user