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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user