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:
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