feat(backend): enforcement des permissions tools MCP au serveur (#82 lot B2)

orchestrator/mcp/server.rs applique désormais les règles de permission du
domaine mcp_tool_permissions (lot B1) à l'invocation d'un tool, y compris
le cas requester vide.

Lot B2 du ticket #82 : ferme la boucle enforcement sur le socle B1, la
parité OpenAI-compatible suit en B3 sur la même branche.

QA vert (32 tests dont le nouveau cas requester vide).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:54:42 +02:00
parent 5175589aa5
commit 37ead61911
3 changed files with 426 additions and 34 deletions

View File

@ -21,7 +21,10 @@ use std::sync::Arc;
use std::time::Instant;
use application::OrchestratorService;
use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project};
use domain::ports::McpToolPermissionStore;
use domain::{
AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, Project,
};
use serde_json::{json, Value};
use tokio::sync::mpsc;
@ -69,6 +72,9 @@ pub struct McpServer {
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// Optional durable MCP tool permission store. When wired, absence of project or
/// agent policy resolves to the canonical read-only fallback.
mcp_tool_permissions: Option<Arc<dyn McpToolPermissionStore>>,
}
impl McpServer {
@ -85,6 +91,7 @@ impl McpServer {
ready_sink: None,
ticket_tools: None,
tool_policies: None,
mcp_tool_permissions: None,
}
}
@ -123,6 +130,16 @@ impl McpServer {
self
}
/// Attaches the durable MCP tool permission store.
#[must_use]
pub fn with_mcp_tool_permissions(
mut self,
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
) -> Self {
self.mcp_tool_permissions = Some(mcp_tool_permissions);
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
@ -141,6 +158,7 @@ impl McpServer {
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
}
}
@ -282,7 +300,7 @@ impl McpServer {
self.notify_ready();
Ok(self.initialize_result())
}
"tools/list" => Ok(self.tools_list_result()),
"tools/list" => self.tools_list_result().await,
"tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await,
other => Err(JsonRpcError::new(
error_codes::METHOD_NOT_FOUND,
@ -312,17 +330,18 @@ impl McpServer {
}
/// The `tools/list` result: the catalogue as MCP tool descriptors.
fn tools_list_result(&self) -> Value {
let policy = self
.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester));
async fn tools_list_result(&self) -> Result<Value, JsonRpcError> {
let ephemeral_policy = self.ephemeral_tool_policy();
let durable_policy = self.durable_tool_policy().await?;
let tools: Vec<Value> = tools::catalogue()
.into_iter()
.filter(|t| {
policy
ephemeral_policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
&& durable_policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
})
.map(|t| {
json!({
@ -332,7 +351,82 @@ impl McpServer {
})
})
.collect();
json!({ "tools": tools })
Ok(json!({ "tools": tools }))
}
fn ephemeral_tool_policy(&self) -> Option<AgentToolPolicy> {
self.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester))
}
async fn durable_tool_policy(&self) -> Result<Option<McpToolPolicy>, JsonRpcError> {
let Some(store) = &self.mcp_tool_permissions else {
return Ok(None);
};
let known_tools = tools::classified_tool_names();
let policy = if let Some(agent_id) = self.requester_agent_id() {
let doc = store
.load_mcp_tool_permissions(&self.project)
.await
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("failed to load MCP tool permissions: {e}"),
)
})?;
doc.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("invalid MCP tool permissions: {e}"),
)
})?
} else if self.requester.is_empty()
|| self.requester == "mcp"
|| self.ephemeral_tool_policy().is_none()
{
// Anonymous/legacy peers cannot be mapped to an agent override. Fail
// closed to the canonical read-only policy. Non-agent requesters with an
// ephemeral policy (ticket assistants) are governed by that narrower,
// session-scoped policy instead of the durable per-agent store.
McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools).map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("invalid read-only MCP tool fallback: {e}"),
)
})?
} else {
return Ok(None);
};
Ok(Some(policy))
}
fn requester_agent_id(&self) -> Option<AgentId> {
uuid::Uuid::parse_str(&self.requester)
.ok()
.map(AgentId::from_uuid)
}
fn enforce_durable_tool_policy(
&self,
policy: &McpToolPolicy,
name: &str,
) -> Result<(), JsonRpcError> {
if policy.permits(name) {
return Ok(());
}
let requester = if self.requester.is_empty() {
"mcp"
} else {
&self.requester
};
Err(JsonRpcError::new(
error_codes::INVALID_PARAMS,
format!("MCP tool `{name}` is not permitted for requester {requester}"),
))
}
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
@ -346,13 +440,12 @@ impl McpServer {
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
if let Some(policy) = self
.tool_policies
.as_ref()
.and_then(|registry| registry.get(&self.requester))
{
if let Some(policy) = self.ephemeral_tool_policy() {
self.enforce_tool_policy(&policy, &name, &arguments)?;
}
if let Some(policy) = self.durable_tool_policy().await? {
self.enforce_durable_tool_policy(&policy, &name)?;
}
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.