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