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 live_state;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
pub mod mcp_tool_permissions;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_harvest;
|
pub mod memory_harvest;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
@ -84,6 +85,11 @@ pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|||||||
|
|
||||||
pub use agent_tool_policy::AgentToolPolicy;
|
pub use agent_tool_policy::AgentToolPolicy;
|
||||||
|
|
||||||
|
pub use mcp_tool_permissions::{
|
||||||
|
AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, ProjectMcpToolPermissions,
|
||||||
|
MCP_TOOL_PERMISSIONS_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
pub use background_task::{
|
pub use background_task::{
|
||||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
|
||||||
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
BackgroundTaskState, BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||||
@ -204,11 +210,11 @@ pub use ports::{
|
|||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||||
Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError,
|
||||||
ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||||
RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
||||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore,
|
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||||
WindowStateStore,
|
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,
|
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||||
};
|
};
|
||||||
use crate::markdown::MarkdownDoc;
|
use crate::markdown::MarkdownDoc;
|
||||||
|
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
||||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||||
use crate::model_server::{
|
use crate::model_server::{
|
||||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||||
@ -1791,6 +1792,33 @@ pub trait PermissionStore: Send + Sync {
|
|||||||
) -> Result<(), StoreError>;
|
) -> 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.
|
/// Persistence port for first-class background tasks.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait BackgroundTaskStore: Send + Sync {
|
pub trait BackgroundTaskStore: Send + Sync {
|
||||||
|
|||||||
@ -99,8 +99,8 @@ pub use store::{
|
|||||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||||
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore,
|
||||||
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -34,6 +34,70 @@ pub struct ToolDef {
|
|||||||
pub input_schema: Value,
|
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<McpToolAccess> {
|
||||||
|
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`].
|
/// Errors mapping a tool call into a validated [`OrchestratorCommand`].
|
||||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
pub enum ToolMapError {
|
pub enum ToolMapError {
|
||||||
@ -498,6 +562,7 @@ fn optional_u64(value: Option<&Value>, tool: &str) -> Result<Option<u64>, ToolMa
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use domain::OrchestratorVisibility;
|
use domain::OrchestratorVisibility;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
|
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
|
||||||
const REQ: &str = "11111111-1111-1111-1111-111111111111";
|
const REQ: &str = "11111111-1111-1111-1111-111111111111";
|
||||||
@ -507,6 +572,44 @@ mod tests {
|
|||||||
map_tool_call(name, args, "")
|
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::<HashSet<_>>();
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
|
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
|
||||||
let requester = uuid::Uuid::from_u128(42).to_string();
|
let requester = uuid::Uuid::from_u128(42).to_string();
|
||||||
|
|||||||
81
crates/infrastructure/src/store/mcp_tool_permission.rs
Normal file
81
crates/infrastructure/src/store/mcp_tool_permission.rs
Normal file
@ -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 `<project>/.ideai/mcp-tool-permissions.json`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsMcpToolPermissionStore {
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsMcpToolPermissionStore {
|
||||||
|
/// Builds the store from an injected filesystem port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>) -> 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<ProjectMcpToolPermissions, StoreError> {
|
||||||
|
match self.fs.read(&Self::path(project)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let permissions = serde_json::from_slice::<ProjectMcpToolPermissions>(&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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ mod context;
|
|||||||
mod device_session;
|
mod device_session;
|
||||||
mod embedder;
|
mod embedder;
|
||||||
mod live_state;
|
mod live_state;
|
||||||
|
mod mcp_tool_permission;
|
||||||
mod memory;
|
mod memory;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod profile;
|
mod profile;
|
||||||
@ -31,6 +32,7 @@ pub use embedder::{
|
|||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
pub use live_state::FsLiveStateStore;
|
pub use live_state::FsLiveStateStore;
|
||||||
|
pub use mcp_tool_permission::FsMcpToolPermissionStore;
|
||||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||||
pub use permission::FsPermissionStore;
|
pub use permission::FsPermissionStore;
|
||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
|
|||||||
176
crates/infrastructure/tests/mcp_tool_permission_store.rs
Normal file
176
crates/infrastructure/tests/mcp_tool_permission_store.rs
Normal file
@ -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<dyn FileSystem> = 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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user