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:
2026-07-18 22:45:32 +02:00
parent 32408b5232
commit 5175589aa5
8 changed files with 721 additions and 11 deletions

View File

@ -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,
};

View File

@ -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<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`].
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ToolMapError {
@ -498,6 +562,7 @@ fn optional_u64(value: Option<&Value>, tool: &str) -> Result<Option<u64>, 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::<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]
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
let requester = uuid::Uuid::from_u128(42).to_string();

View 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()))
}
}

View File

@ -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};